10 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
352 changed files with 75345 additions and 7329 deletions

View File

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

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

@@ -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'));
@@ -112,6 +113,8 @@ const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates'));
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'));
@@ -126,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
@@ -349,6 +353,7 @@ const AppContent: React.FC = () => {
<Suspense fallback={<LoadingScreen />}>
<Routes>
<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 />} />
@@ -492,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/billing" element={<BillingManagement />} />
</>
)}
<Route path="/platform/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
@@ -626,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));
@@ -635,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>
);
@@ -664,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} />
@@ -696,199 +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="/site-editor"
path="/dashboard/site-editor"
element={
hasAccess(['owner', 'manager']) ? (
<PageEditor />
) : (
<Navigate to="/" />
<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 />} />
@@ -899,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

@@ -132,6 +132,8 @@ describe('Billing API', () => {
code: 'sms_pack',
name: 'SMS Pack',
price_monthly_cents: 500,
price_one_time_cents: 0,
is_stackable: false,
is_active: true,
},
];

View File

@@ -75,6 +75,10 @@ export interface AddOnProduct {
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;
}

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
@@ -51,6 +52,7 @@ export interface PlatformBusiness {
can_use_webhooks?: boolean;
can_use_calendar_sync?: boolean;
can_use_contracts?: boolean;
can_customize_booking_page?: boolean;
}
export interface PlatformBusinessUpdate {
@@ -59,6 +61,7 @@ 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;
@@ -83,10 +86,10 @@ export interface PlatformBusinessUpdate {
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;
can_customize_booking_page?: boolean;
advanced_reporting?: boolean;
priority_support?: boolean;
dedicated_support?: boolean;
@@ -100,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;
@@ -148,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)
*/
@@ -325,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

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

@@ -17,6 +17,8 @@ import {
Plug,
FileSignature,
CalendarOff,
LayoutTemplate,
MapPin,
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
@@ -107,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}
@@ -115,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}
@@ -123,7 +125,7 @@ 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}
@@ -133,7 +135,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
)}
{isStaff && (
<SidebarItem
to="/my-schedule"
to="/dashboard/my-schedule"
icon={CalendarDays}
label={t('nav.mySchedule', 'My Schedule')}
isCollapsed={isCollapsed}
@@ -141,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}
@@ -153,20 +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}
@@ -174,7 +183,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{canViewAdminPages && (
<>
<SidebarItem
to="/staff"
to="/dashboard/staff"
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
@@ -182,7 +191,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
/>
{canUse('contracts') && (
<SidebarItem
to="/contracts"
to="/dashboard/contracts"
icon={FileSignature}
label={t('nav.contracts', 'Contracts')}
isCollapsed={isCollapsed}
@@ -190,11 +199,18 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
/>
)}
<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>
@@ -205,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}
@@ -213,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}
@@ -226,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}
@@ -239,7 +255,7 @@ 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}
@@ -255,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} />);
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument();
});
it('should render modal with React node as message', () => {
const messageNode = (
<div>
<p>First paragraph</p>
<p>Second paragraph</p>
</div>
);
renderWithI18n(<ConfirmationModal {...defaultProps} message={messageNode} />);
expect(screen.getByText('First paragraph')).toBeInTheDocument();
expect(screen.getByText('Second paragraph')).toBeInTheDocument();
});
it('should not render when isOpen is false', () => {
const { container } = renderWithI18n(
it('returns null when not open', () => {
const { container } = render(
<ConfirmationModal {...defaultProps} isOpen={false} />
);
expect(container).toBeEmptyDOMElement();
expect(container.firstChild).toBeNull();
});
it('should render default confirm and cancel buttons', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
it('renders title when open', () => {
render(<ConfirmationModal {...defaultProps} />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('should render custom button labels', () => {
renderWithI18n(
it('renders message when open', () => {
render(<ConfirmationModal {...defaultProps} />);
expect(screen.getByText('Test message')).toBeInTheDocument();
});
it('renders message as ReactNode', () => {
render(
<ConfirmationModal
{...defaultProps}
confirmText="Yes, delete it"
cancelText="No, keep it"
message={<span data-testid="custom-message">Custom content</span>}
/>
);
expect(screen.getByRole('button', { name: 'Yes, delete it' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'No, keep it' })).toBeInTheDocument();
expect(screen.getByTestId('custom-message')).toBeInTheDocument();
});
it('should render close button in header', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} />);
// Close button is an SVG icon, so we find it by its parent button
const closeButtons = screen.getAllByRole('button');
const closeButton = closeButtons.find((button) =>
button.querySelector('svg') && button !== screen.getByRole('button', { name: /confirm/i })
);
expect(closeButton).toBeInTheDocument();
});
});
describe('User Interactions', () => {
it('should call onConfirm when confirm button is clicked', () => {
const onConfirm = vi.fn();
renderWithI18n(<ConfirmationModal {...defaultProps} onConfirm={onConfirm} />);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('should call onClose when cancel button is clicked', () => {
const onClose = vi.fn();
renderWithI18n(<ConfirmationModal {...defaultProps} onClose={onClose} />);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('should call onClose when close button is clicked', () => {
const onClose = vi.fn();
renderWithI18n(<ConfirmationModal {...defaultProps} onClose={onClose} />);
// Find the close button (X icon in header)
it('calls onClose when close button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
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);
}
fireEvent.click(buttons[0]);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('should not call onConfirm multiple times on multiple clicks', () => {
const onConfirm = vi.fn();
renderWithI18n(<ConfirmationModal {...defaultProps} onConfirm={onConfirm} />);
it('calls onClose when cancel button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByText('common.cancel'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
const confirmButton = screen.getByRole('button', { name: /confirm/i });
fireEvent.click(confirmButton);
fireEvent.click(confirmButton);
fireEvent.click(confirmButton);
it('calls onConfirm when confirm button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByText('common.confirm'));
expect(defaultProps.onConfirm).toHaveBeenCalled();
});
expect(onConfirm).toHaveBeenCalledTimes(3);
it('uses custom confirm text', () => {
render(<ConfirmationModal {...defaultProps} confirmText="Yes, delete" />);
expect(screen.getByText('Yes, delete')).toBeInTheDocument();
});
it('uses custom cancel text', () => {
render(<ConfirmationModal {...defaultProps} cancelText="No, keep" />);
expect(screen.getByText('No, keep')).toBeInTheDocument();
});
it('renders info variant', () => {
render(<ConfirmationModal {...defaultProps} variant="info" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('renders warning variant', () => {
render(<ConfirmationModal {...defaultProps} variant="warning" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('renders danger variant', () => {
render(<ConfirmationModal {...defaultProps} variant="danger" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('renders success variant', () => {
render(<ConfirmationModal {...defaultProps} variant="success" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('disables buttons when loading', () => {
render(<ConfirmationModal {...defaultProps} isLoading={true} />);
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
expect(button).toBeDisabled();
});
});
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');
it('shows spinner when loading', () => {
render(<ConfirmationModal {...defaultProps} isLoading={true} />);
const spinner = document.querySelector('.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();
});
});
});

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();
});
afterEach(() => {
queryClient.clear();
});
describe('Rendering with templates', () => {
it('should render with templates list', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Welcome Email' }),
createMockEmailTemplate({ id: '2', name: 'Confirmation Email', category: 'CONFIRMATION' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
it('renders select element', () => {
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
{ wrapper: createWrapper() }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
const options = Array.from(select.options);
expect(options).toHaveLength(3); // placeholder + 2 templates
expect(options[1]).toHaveTextContent('Welcome Email (APPOINTMENT)');
expect(options[2]).toHaveTextContent('Confirmation Email (CONFIRMATION)');
});
it('should render templates without category suffix for OTHER category', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Custom Email', category: 'OTHER' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
const options = Array.from(select.options);
expect(options[1]).toHaveTextContent('Custom Email');
expect(options[1]).not.toHaveTextContent('(OTHER)');
});
it('should convert numeric IDs to strings', async () => {
const mockData = [
{
id: 123,
name: 'Numeric ID Template',
description: 'Test',
category: 'REMINDER',
scope: 'BUSINESS',
updated_at: '2025-01-01T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockData });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options[1].value).toBe('123');
});
});
describe('Template selection', () => {
it('should select template on click', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
createMockEmailTemplate({ id: '2', name: 'Template 2' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
fireEvent.change(select, { target: { value: '2' } });
expect(mockOnChange).toHaveBeenCalledWith('2');
});
it('should call onChange with undefined when selecting empty option', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
fireEvent.change(select, { target: { value: '' } });
expect(mockOnChange).toHaveBeenCalledWith(undefined);
});
it('should handle numeric value prop', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={1} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.value).toBe('1');
});
});
describe('Selected template display', () => {
it('should show selected template as active', async () => {
const mockTemplates = [
createMockEmailTemplate({
id: '1',
name: 'Selected Template',
description: 'This template is selected',
}),
createMockEmailTemplate({ id: '2', name: 'Other Template' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.value).toBe('1');
});
it('should display selected template info with description', async () => {
const mockTemplates = [
createMockEmailTemplate({
id: '1',
name: 'Template Name',
description: 'Template description text',
}),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByText('Template description text')).toBeInTheDocument();
});
});
it('should display template name when description is empty', async () => {
const mockTemplates = [
createMockEmailTemplate({
id: '1',
name: 'No Description Template',
description: '',
}),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByText('No Description Template')).toBeInTheDocument();
});
});
it('should display edit link for selected template', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Editable Template' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const editLink = screen.getByRole('link', { name: /edit/i });
expect(editLink).toBeInTheDocument();
expect(editLink).toHaveAttribute('href', '#/email-templates');
expect(editLink).toHaveAttribute('target', '_blank');
expect(editLink).toHaveAttribute('rel', 'noopener noreferrer');
});
});
it('should not display template info when no template is selected', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
const editLink = screen.queryByRole('link', { name: /edit/i });
expect(editLink).not.toBeInTheDocument();
});
});
describe('Empty templates array', () => {
it('should handle empty templates array', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByText(/no email templates yet/i)).toBeInTheDocument();
});
});
it('should display create link when templates array is empty', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const createLink = screen.getByRole('link', { name: /create your first template/i });
expect(createLink).toBeInTheDocument();
expect(createLink).toHaveAttribute('href', '#/email-templates');
});
});
it('should render select with only placeholder option when empty', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options).toHaveLength(1); // only placeholder
});
});
});
describe('Loading states', () => {
it('should show loading text in placeholder when loading', async () => {
vi.mocked(apiClient.get).mockImplementation(
() => new Promise(() => {}) // Never resolves to keep loading state
);
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options[0]).toHaveTextContent('Loading...');
});
it('should disable select when loading', async () => {
vi.mocked(apiClient.get).mockImplementation(
() => new Promise(() => {}) // Never resolves
);
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
const select = screen.getByRole('combobox');
expect(select).toBeDisabled();
});
it('should not show empty state while loading', () => {
vi.mocked(apiClient.get).mockImplementation(
() => new Promise(() => {}) // Never resolves
);
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
const emptyMessage = screen.queryByText(/no email templates yet/i);
expect(emptyMessage).not.toBeInTheDocument();
});
});
describe('Disabled state', () => {
it('should disable select when disabled prop is true', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} disabled={true} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox');
expect(select).toBeDisabled();
});
});
it('should apply disabled attribute when disabled prop is true', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} disabled={true} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox');
expect(select).toBeDisabled();
});
// Verify the select element has disabled attribute
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select).toHaveAttribute('disabled');
});
});
describe('Category filtering', () => {
it('should fetch templates with category filter', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} category="REMINDER" />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?category=REMINDER');
});
});
it('should fetch templates without category filter when not provided', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?');
});
});
it('should refetch when category changes', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const { rerender } = render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} category="REMINDER" />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?category=REMINDER');
});
vi.clearAllMocks();
rerender(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} category="CONFIRMATION" />
);
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?category=CONFIRMATION');
});
});
});
describe('Props and customization', () => {
it('should use custom placeholder when provided', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
it('shows placeholder text after loading', async () => {
render(
<EmailTemplateSelector
value={undefined}
onChange={mockOnChange}
placeholder="Choose an email template"
onChange={() => {}}
placeholder="Select a template"
/>,
{ wrapper: createWrapper(queryClient) }
{ wrapper: createWrapper() }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options[0]).toHaveTextContent('Choose an email template');
});
// Wait for loading to finish and placeholder to appear
await screen.findByText('Select a template');
});
it('should use default placeholder when not provided', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
it('is disabled when disabled prop is true', () => {
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
<EmailTemplateSelector value={undefined} onChange={() => {}} disabled />,
{ wrapper: createWrapper() }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options[0]).toHaveTextContent('Select a template...');
});
expect(screen.getByRole('combobox')).toBeDisabled();
});
it('should apply custom className', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
it('applies custom className', () => {
const { container } = render(
<EmailTemplateSelector
value={undefined}
onChange={mockOnChange}
onChange={() => {}}
className="custom-class"
/>,
{ wrapper: createWrapper(queryClient) }
{ wrapper: createWrapper() }
);
await waitFor(() => {
const container = screen.getByRole('combobox').parentElement?.parentElement;
expect(container).toHaveClass('custom-class');
});
expect(container.firstChild).toHaveClass('custom-class');
});
it('should work without className prop', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
it('shows empty state message when no templates', async () => {
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
{ wrapper: createWrapper() }
);
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();
});
// Wait for loading to finish
await screen.findByText('No email templates yet.');
});
});

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', () => {
const renderHelpButton = (props: { helpPath: string; className?: string }) => {
return render(
<BrowserRouter>
<HelpButton {...props} />
</BrowserRouter>
);
};
describe('HelpButton', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render the button', () => {
render(<HelpButton helpPath="/help/getting-started" />, {
wrapper: createWrapper(),
});
it('renders help link', () => {
renderHelpButton({ helpPath: '/help/dashboard' });
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(),
});
it('has correct href', () => {
renderHelpButton({ helpPath: '/help/dashboard' });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/resources');
expect(link).toHaveAttribute('href', '/help/dashboard');
});
it('should render with different help paths', () => {
const { rerender } = render(<HelpButton helpPath="/help/page1" />, {
wrapper: createWrapper(),
});
let link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/page1');
rerender(<HelpButton helpPath="/help/page2" />);
link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/page2');
});
});
describe('Icon Display', () => {
it('should display the HelpCircle icon', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
// Check for SVG icon (lucide-react renders as SVG)
const svg = link.querySelector('svg');
expect(svg).toBeInTheDocument();
});
});
describe('Text Display', () => {
it('should display help text', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const text = screen.getByText('Help');
expect(text).toBeInTheDocument();
});
it('should apply responsive class to hide text on small screens', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const text = screen.getByText('Help');
expect(text).toHaveClass('hidden', 'sm:inline');
});
});
describe('Accessibility', () => {
it('should have title attribute', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
it('renders help text', () => {
renderHelpButton({ helpPath: '/help/test' });
expect(screen.getByText('Help')).toBeInTheDocument();
});
it('has title attribute', () => {
renderHelpButton({ helpPath: '/help/test' });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('title', 'Help');
});
it('should be keyboard accessible as a link', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
expect(link.tagName).toBe('A');
});
it('should have accessible name from text content', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /help/i });
expect(link).toBeInTheDocument();
});
});
describe('Styling', () => {
it('should apply default classes', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveClass('inline-flex');
expect(link).toHaveClass('items-center');
expect(link).toHaveClass('gap-1.5');
expect(link).toHaveClass('px-3');
expect(link).toHaveClass('py-1.5');
expect(link).toHaveClass('text-sm');
expect(link).toHaveClass('rounded-lg');
expect(link).toHaveClass('transition-colors');
});
it('should apply color classes for light mode', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveClass('text-gray-500');
expect(link).toHaveClass('hover:text-brand-600');
expect(link).toHaveClass('hover:bg-gray-100');
});
it('should apply color classes for dark mode', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveClass('dark:text-gray-400');
expect(link).toHaveClass('dark:hover:text-brand-400');
expect(link).toHaveClass('dark:hover:bg-gray-800');
});
it('should apply custom className when provided', () => {
render(<HelpButton helpPath="/help" className="custom-class" />, {
wrapper: createWrapper(),
});
it('applies custom className', () => {
renderHelpButton({ helpPath: '/help/test', className: 'custom-class' });
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(),
});
it('has default styles', () => {
renderHelpButton({ helpPath: '/help/test' });
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');
});
});
});

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', () => {
describe('dropdown variant', () => {
it('renders dropdown button', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button', { expanded: false });
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
it('should display current language name on desktop', () => {
it('shows current language flag by default', () => {
render(<LanguageSelector />);
const languageName = screen.getByText('English');
expect(languageName).toBeInTheDocument();
expect(languageName).toHaveClass('hidden', 'sm:inline');
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
});
it('should display current language flag by default', () => {
it('shows current language name on larger screens', () => {
render(<LanguageSelector />);
const flag = screen.getByText('🇺🇸');
expect(flag).toBeInTheDocument();
expect(screen.getByText('English')).toBeInTheDocument();
});
it('should display Globe icon', () => {
it('opens dropdown on click', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
const svg = button.querySelector('svg');
expect(svg).toBeInTheDocument();
fireEvent.click(button);
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
it('should display ChevronDown icon', () => {
it('shows all languages when open', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
const chevron = button.querySelector('svg.w-4.h-4.transition-transform');
expect(chevron).toBeInTheDocument();
fireEvent.click(button);
expect(screen.getByText('Español')).toBeInTheDocument();
expect(screen.getByText('Français')).toBeInTheDocument();
});
it('should not display flag when showFlag is false', () => {
it('hides 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', () => {
it('applies custom className', () => {
const { container } = render(<LanguageSelector className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
});
describe('inline variant', () => {
it('renders all language buttons', () => {
render(<LanguageSelector variant="inline" />);
const 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');
});
expect(buttons.length).toBe(3);
});
it('should apply hover classes to non-selected buttons', () => {
it('renders language names', () => {
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');
});
});
expect(screen.getByText(/English/)).toBeInTheDocument();
expect(screen.getByText(/Español/)).toBeInTheDocument();
expect(screen.getByText(/Français/)).toBeInTheDocument();
});
describe('Integration', () => {
it('should render correctly with all dropdown props together', () => {
render(
<LanguageSelector
variant="dropdown"
showFlag={true}
className="custom-class"
/>
);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(screen.getByText('English')).toBeInTheDocument();
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
const container = button.parentElement;
expect(container).toHaveClass('custom-class');
});
it('should render correctly with all inline props together', () => {
const { container } = render(
<LanguageSelector
variant="inline"
showFlag={true}
className="inline-custom"
/>
);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass('inline-custom');
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBe(4);
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
expect(screen.getByText('English')).toBeInTheDocument();
});
it('should maintain dropdown functionality across re-renders', () => {
const { rerender } = render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByRole('listbox')).toBeInTheDocument();
rerender(<LanguageSelector className="updated" />);
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle missing language gracefully', () => {
// The component should fall back to the first language if current language is not found
render(<LanguageSelector />);
// Should still render without crashing
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
it('should cleanup event listener on unmount', () => {
const { unmount } = render(<LanguageSelector />);
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
});
it('should not call changeLanguage when clicking current language', async () => {
it('highlights current language', () => {
render(<LanguageSelector variant="inline" />);
const englishButton = screen.getByRole('button', { name: /English/i });
fireEvent.click(englishButton);
await waitFor(() => {
expect(mockChangeLanguage).toHaveBeenCalledWith('en');
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');
});
});
describe('User Information Display', () => {
it('displays the effective user name and role', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
it('renders effective user name', () => {
render(<MasqueradeBanner {...defaultProps} />);
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('renders effective user role', () => {
render(<MasqueradeBanner {...defaultProps} />);
// The role is split across elements: "(" + "staff" + ")"
expect(screen.getByText(/staff/)).toBeInTheDocument();
});
it('displays masquerading as message', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText(/Masquerading as/i)).toBeInTheDocument();
});
it('displays different user roles correctly', () => {
const staffUser: User = {
id: '4',
name: 'Staff Member',
email: 'staff@example.com',
role: 'staff',
};
render(
<MasqueradeBanner
effectiveUser={staffUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText('Staff Member')).toBeInTheDocument();
// Use a more specific query to avoid matching "Staff Member" text
expect(screen.getByText(/\(staff\)/i)).toBeInTheDocument();
});
});
describe('Stop Masquerade Button', () => {
it('renders the stop masquerade button when no previous user', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
expect(button).toBeInTheDocument();
});
it('renders the return to user button when previous user exists', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={previousUser}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Return to Manager User/i });
expect(button).toBeInTheDocument();
it('renders original user info', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText(/Admin User/)).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);
render(<MasqueradeBanner {...defaultProps} />);
const stopButton = screen.getByRole('button');
fireEvent.click(stopButton);
expect(defaultProps.onStop).toHaveBeenCalled();
});
it('calls onStop when return button is clicked with previous user', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={previousUser}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Return to Manager User/i });
fireEvent.click(button);
expect(mockOnStop).toHaveBeenCalledTimes(1);
});
it('can be clicked multiple times', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
fireEvent.click(button);
fireEvent.click(button);
fireEvent.click(button);
expect(mockOnStop).toHaveBeenCalledTimes(3);
});
});
describe('Styling and Visual State', () => {
it('has warning/info styling with orange background', () => {
const { container } = render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const banner = container.firstChild as HTMLElement;
expect(banner).toHaveClass('bg-orange-600');
expect(banner).toHaveClass('text-white');
});
it('has proper button styling', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
expect(button).toHaveClass('bg-white');
expect(button).toHaveClass('text-orange-600');
expect(button).toHaveClass('hover:bg-orange-50');
});
it('has animated pulse effect on Eye icon container', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const eyeIcon = screen.getByTestId('eye-icon');
const iconContainer = eyeIcon.closest('div');
expect(iconContainer).toHaveClass('animate-pulse');
});
it('has proper layout classes for flexbox', () => {
const { container } = render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const banner = container.firstChild as HTMLElement;
expect(banner).toHaveClass('flex');
expect(banner).toHaveClass('items-center');
expect(banner).toHaveClass('justify-between');
});
it('has z-index for proper stacking', () => {
const { container } = render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const banner = container.firstChild as HTMLElement;
expect(banner).toHaveClass('z-50');
expect(banner).toHaveClass('relative');
});
it('has shadow for visual prominence', () => {
const { container } = render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const banner = container.firstChild as HTMLElement;
expect(banner).toHaveClass('shadow-md');
});
});
describe('Edge Cases', () => {
it('handles users with numeric IDs', () => {
const numericIdUser: User = {
id: 123,
name: 'Numeric User',
email: 'numeric@example.com',
role: 'customer',
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
effectiveUser={numericIdUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText('Numeric User')).toBeInTheDocument();
render(<MasqueradeBanner {...propsWithPrevious} />);
expect(screen.getByText(/platform.masquerade.returnTo/)).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('shows stop masquerading text when no previousUser', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText('platform.masquerade.stopMasquerading')).toBeInTheDocument();
});
it('handles all possible user roles', () => {
const roles: Array<User['role']> = [
'superuser',
'platform_manager',
'platform_support',
'owner',
'manager',
'staff',
'resource',
'customer',
];
roles.forEach((role) => {
const { unmount } = render(
<MasqueradeBanner
effectiveUser={{ ...effectiveUser, role }}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText(new RegExp(role, 'i'))).toBeInTheDocument();
unmount();
});
});
it('handles previousUser being null', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByRole('button', { name: /Stop Masquerading/i })).toBeInTheDocument();
expect(screen.queryByText(/Return to/i)).not.toBeInTheDocument();
});
it('handles previousUser being defined', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={previousUser}
onStop={mockOnStop}
/>
);
expect(screen.getByRole('button', { name: /Return to Manager User/i })).toBeInTheDocument();
expect(screen.queryByText(/Stop Masquerading/i)).not.toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('has a clickable button element', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button.tagName).toBe('BUTTON');
});
it('button has descriptive text', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button');
expect(button).toHaveTextContent(/Stop Masquerading/i);
});
it('displays user information in semantic HTML', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const strongElement = screen.getByText('John Doe');
expect(strongElement.tagName).toBe('STRONG');
});
});
describe('Component Integration', () => {
it('renders without crashing with minimal props', () => {
const minimalEffectiveUser: User = {
id: '1',
name: 'Test',
email: 'test@test.com',
role: 'customer',
};
const minimalOriginalUser: User = {
id: '2',
name: 'Admin',
email: 'admin@test.com',
role: 'superuser',
};
expect(() =>
render(
<MasqueradeBanner
effectiveUser={minimalEffectiveUser}
originalUser={minimalOriginalUser}
previousUser={null}
onStop={mockOnStop}
/>
)
).not.toThrow();
});
it('renders all required elements together', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
// Check all major elements are present
expect(screen.getByTestId('eye-icon')).toBeInTheDocument();
expect(screen.getByTestId('xcircle-icon')).toBeInTheDocument();
expect(screen.getByText(/Masquerading as/i)).toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText(/Logged in as Admin User/i)).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument();
});
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

@@ -33,20 +33,24 @@ export const BookingWidget: React.FC<BookingWidgetProps> = ({
};
return (
<div className="booking-widget p-6 bg-white rounded-lg shadow-md max-w-md mx-auto text-left">
<h2 className="text-2xl font-bold mb-2" style={{ color: accentColor }}>{headline}</h2>
<p className="text-gray-600 mb-6">{subheading}</p>
<div className="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>No services available.</p>}
{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 cursor-pointer transition-colors ${selectedService?.id === service.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-blue-300'}`}
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">{service.name}</h3>
<p className="text-sm text-gray-500">{service.duration} min - ${(service.price_cents / 100).toFixed(2)}</p>
<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>
@@ -54,8 +58,7 @@ export const BookingWidget: React.FC<BookingWidgetProps> = ({
<button
onClick={handleBook}
disabled={!selectedService}
className="w-full py-2 px-4 rounded text-white font-medium disabled:opacity-50 transition-opacity"
style={{ backgroundColor: accentColor }}
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>

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

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

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

@@ -2,14 +2,12 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import {
Clock,
MapPin,
User,
Calendar,
DollarSign,
Image as ImageIcon,
CheckCircle2,
AlertCircle
} from 'lucide-react';
import { Service, Business } from '../../types';
import Card from '../ui/Card';
import Badge from '../ui/Badge';
interface CustomerPreviewProps {
@@ -33,23 +31,22 @@ export const CustomerPreview: React.FC<CustomerPreviewProps> = ({
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);
};
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="space-y-4">
<div className="flex items-center justify-between mb-2">
@@ -59,82 +56,86 @@ export const CustomerPreview: React.FC<CustomerPreviewProps> = ({
<Badge variant="info" size="sm">Live Preview</Badge>
</div>
{/* Booking Page Card Simulation */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 overflow-hidden transform transition-all hover:scale-[1.02]">
{/* Cover Image Placeholder */}
{/* 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="h-32 w-full bg-cover bg-center relative"
className="absolute inset-0 flex items-center justify-center"
style={{
background: `linear-gradient(135deg, var(--color-brand-600, ${business.primaryColor}), var(--color-brand-400, ${business.secondaryColor}))`,
opacity: 0.9
background: `linear-gradient(135deg, var(--color-brand-600, ${business.primaryColor || '#2563eb'}), var(--color-brand-400, ${business.secondaryColor || '#0ea5e9'}))`
}}
>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-white/20 font-bold text-4xl select-none">
{data.name.charAt(0)}
</span>
<ImageIcon className="w-12 h-12 text-white/30" />
</div>
)}
</div>
<div className="p-6">
<div className="flex justify-between items-start gap-4 mb-4">
{/* Content Section - 2/3 width */}
<div className="w-2/3 p-5 flex flex-col justify-between">
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white leading-tight mb-1">
{data.name}
</h2>
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Clock size={14} />
<span>{formatDuration(data.durationMinutes)}</span>
<span></span>
<span>{data.category?.name || 'General'}</span>
</div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-brand-600 dark:text-brand-400">
{data.variable_pricing ? (
'Variable'
) : (
formatPrice(data.price)
{/* 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>
{data.deposit_amount && data.deposit_amount > 0 && (
<div className="text-xs text-gray-500">
{formatPrice(data.deposit_amount)} deposit
</div>
)}
</div>
</div>
<p className="text-gray-600 dark:text-gray-300 text-sm leading-relaxed mb-6">
{/* 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 className="space-y-3 pt-4 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<div className="p-1.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400">
<CheckCircle2 size={14} />
</div>
<span>Online booking available</span>
</div>
{(data.resource_ids?.length || 0) > 0 && !data.all_resources && (
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<div className="p-1.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
<User size={14} />
{/* 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>
<span>Specific staff only</span>
<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 className="mt-6">
<button className="w-full py-2.5 px-4 bg-brand-600 hover:bg-brand-700 text-white font-medium rounded-xl transition-colors shadow-sm shadow-brand-200 dark:shadow-none">
Book Now
</button>
</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">

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,40 +126,26 @@ 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)',
background: 'rgba(107, 114, 128, 0.25)', // Gray-500 at 25% opacity (more visible)
};
}
} else {
// Resource blocks: Purple (hard) / Cyan (soft)
// Resource-level blocks: Purple (hard) / Cyan (soft)
if (blockType === 'HARD') {
return {
...baseStyle,
@@ -181,7 +167,6 @@ const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
borderBottom: '2px dashed rgba(6, 182, 212, 0.7)',
};
}
}
};
const handleMouseEnter = (e: React.MouseEvent, block: BlockedDate) => {
@@ -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'}
{/* 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={() => setIsResourceLevel(true)}
onClick={() => {
setBlockLevel('location');
setIsResourceLevel(false);
setResourceId(null);
}}
className={`p-4 rounded-xl border-2 transition-all text-left ${
isResourceLevel
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 ${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 === '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 ${isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
<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={() => {
setBlockLevel('resource');
setIsResourceLevel(true);
}}
className={`p-4 rounded-xl border-2 transition-all text-left ${
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 ${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 ${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,6 +776,7 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
{/* Resource (if resource-level) - Hidden in staff mode since it's auto-selected */}
{isResourceLevel && !staffMode && (
<div className="space-y-4">
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
Resource
@@ -676,6 +792,17 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
))}
</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>
)}
{/* Block Type - hidden in staff mode (always SOFT for time-off requests) */}
@@ -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

@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react';
import React, { useState, useEffect, useRef } from 'react';
interface CurrencyInputProps {
value: number; // Value in cents (integer)
@@ -12,15 +12,15 @@ interface CurrencyInputProps {
}
/**
* ATM-style currency input where digits are entered as cents.
* As more digits are entered, they shift from cents to dollars.
* Only accepts integer values (digits 0-9).
* Currency input where digits represent cents.
* Only accepts integer input (0-9), no decimal points.
* Allows normal text selection and editing.
*
* Example: typing "1234" displays "$12.34"
* - Type "1" → $0.01
* - Type "2" → $0.12
* - Type "3" → $1.23
* - Type "4" → $12.34
* Examples:
* - Type "5" → $0.05
* - Type "50" → $0.50
* - Type "500" → $5.00
* - Type "1234" → $12.34
*/
const CurrencyInput: React.FC<CurrencyInputProps> = ({
value,
@@ -33,128 +33,110 @@ const CurrencyInput: React.FC<CurrencyInputProps> = ({
max,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isFocused, setIsFocused] = useState(false);
// Ensure value is always an integer
const safeValue = Math.floor(Math.abs(value)) || 0;
const [displayValue, setDisplayValue] = useState('');
// Format cents as dollars string (e.g., 1234 → "$12.34")
const formatCentsAsDollars = (cents: number): string => {
if (cents === 0 && !isFocused) return '';
if (cents === 0) return '';
const dollars = cents / 100;
return `$${dollars.toFixed(2)}`;
};
const displayValue = safeValue > 0 || isFocused ? formatCentsAsDollars(safeValue) : '';
// Process a new digit being added
const addDigit = (digit: number) => {
let newValue = safeValue * 10 + digit;
// Enforce max if specified
if (max !== undefined && newValue > max) {
newValue = max;
}
onChange(newValue);
// Extract just the digits from a string
const extractDigits = (str: string): string => {
return str.replace(/\D/g, '');
};
// Remove the last digit
const removeDigit = () => {
const newValue = Math.floor(safeValue / 10);
onChange(newValue);
// 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 keys without preventing default
if (
e.key === 'Tab' ||
e.key === 'Escape' ||
e.key === 'Enter' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'Home' ||
e.key === 'End'
) {
return;
// 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
}
// Handle backspace/delete
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
removeDigit();
// 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)) {
if (!/^[0-9]$/.test(e.key)) {
e.preventDefault();
addDigit(parseInt(e.key, 10));
return;
}
// Block everything else
e.preventDefault();
};
// Catch input from mobile keyboards, IME, voice input, etc.
const handleBeforeInput = (e: React.FormEvent<HTMLInputElement>) => {
const inputEvent = e.nativeEvent as InputEvent;
const data = inputEvent.data;
// Always prevent default - we handle all input ourselves
e.preventDefault();
if (!data) return;
// Extract only digits from the input
const digits = data.replace(/\D/g, '');
// Add each digit one at a time
for (const char of digits) {
addDigit(parseInt(char, 10));
}
};
const handleFocus = () => {
setIsFocused(true);
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
// Select all text for easy replacement
setTimeout(() => {
e.target.select();
}, 0);
};
const handleBlur = () => {
setIsFocused(false);
// 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 && safeValue < min && safeValue > 0) {
onChange(min);
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));
};
// Handle paste - extract digits only
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const pastedText = e.clipboardData.getData('text');
const digits = pastedText.replace(/\D/g, '');
const digits = extractDigits(pastedText);
if (digits) {
let newValue = parseInt(digits, 10);
if (max !== undefined && newValue > max) {
newValue = max;
}
onChange(newValue);
}
};
let cents = parseInt(digits, 10);
// Handle drop - extract digits only
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault();
const droppedText = e.dataTransfer.getData('text');
const digits = droppedText.replace(/\D/g, '');
if (digits) {
let newValue = parseInt(digits, 10);
if (max !== undefined && newValue > max) {
newValue = max;
if (max !== undefined && cents > max) {
cents = max;
}
onChange(newValue);
onChange(cents);
setDisplayValue(formatCentsAsDollars(cents));
}
};
@@ -163,15 +145,12 @@ const CurrencyInput: React.FC<CurrencyInputProps> = ({
ref={inputRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={displayValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBeforeInput={handleBeforeInput}
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
onDrop={handleDrop}
onChange={() => {}} // Controlled via onKeyDown/onBeforeInput
disabled={disabled}
required={required}
placeholder={placeholder}

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

View File

@@ -0,0 +1,310 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import CurrencyInput from '../CurrencyInput';
describe('CurrencyInput', () => {
const defaultProps = {
value: 0,
onChange: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders an input element', () => {
render(<CurrencyInput {...defaultProps} />);
const input = screen.getByRole('textbox');
expect(input).toBeInTheDocument();
});
it('renders with default placeholder', () => {
render(<CurrencyInput {...defaultProps} />);
const input = screen.getByPlaceholderText('$0.00');
expect(input).toBeInTheDocument();
});
it('renders with custom placeholder', () => {
render(<CurrencyInput {...defaultProps} placeholder="Enter amount" />);
const input = screen.getByPlaceholderText('Enter amount');
expect(input).toBeInTheDocument();
});
it('applies custom className', () => {
render(<CurrencyInput {...defaultProps} className="custom-class" />);
const input = screen.getByRole('textbox');
expect(input).toHaveClass('custom-class');
});
it('displays formatted value for non-zero cents', () => {
render(<CurrencyInput {...defaultProps} value={1234} />);
const input = screen.getByRole('textbox');
expect(input).toHaveValue('$12.34');
});
it('displays empty for zero value', () => {
render(<CurrencyInput {...defaultProps} value={0} />);
const input = screen.getByRole('textbox');
expect(input).toHaveValue('');
});
it('can be disabled', () => {
render(<CurrencyInput {...defaultProps} disabled />);
const input = screen.getByRole('textbox');
expect(input).toBeDisabled();
});
it('can be required', () => {
render(<CurrencyInput {...defaultProps} required />);
const input = screen.getByRole('textbox');
expect(input).toBeRequired();
});
});
describe('Value Formatting', () => {
it('formats 5 cents as $0.05', () => {
render(<CurrencyInput {...defaultProps} value={5} />);
const input = screen.getByRole('textbox');
expect(input).toHaveValue('$0.05');
});
it('formats 50 cents as $0.50', () => {
render(<CurrencyInput {...defaultProps} value={50} />);
const input = screen.getByRole('textbox');
expect(input).toHaveValue('$0.50');
});
it('formats 500 cents as $5.00', () => {
render(<CurrencyInput {...defaultProps} value={500} />);
const input = screen.getByRole('textbox');
expect(input).toHaveValue('$5.00');
});
it('formats 1234 cents as $12.34', () => {
render(<CurrencyInput {...defaultProps} value={1234} />);
const input = screen.getByRole('textbox');
expect(input).toHaveValue('$12.34');
});
it('formats large amounts correctly', () => {
render(<CurrencyInput {...defaultProps} value={999999} />);
const input = screen.getByRole('textbox');
expect(input).toHaveValue('$9999.99');
});
});
describe('User Input', () => {
it('calls onChange with cents value when digits entered', () => {
const onChange = vi.fn();
render(<CurrencyInput {...defaultProps} onChange={onChange} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '1234' } });
expect(onChange).toHaveBeenCalledWith(1234);
});
it('extracts only digits from input', () => {
const onChange = vi.fn();
render(<CurrencyInput {...defaultProps} onChange={onChange} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '$12.34' } });
expect(onChange).toHaveBeenCalledWith(1234);
});
it('handles empty input', () => {
const onChange = vi.fn();
render(<CurrencyInput {...defaultProps} onChange={onChange} value={100} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '' } });
expect(onChange).toHaveBeenCalledWith(0);
});
it('ignores non-digit characters', () => {
const onChange = vi.fn();
render(<CurrencyInput {...defaultProps} onChange={onChange} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'abc123xyz456' } });
expect(onChange).toHaveBeenCalledWith(123456);
});
});
describe('Min/Max Constraints', () => {
it('enforces max value on input', () => {
const onChange = vi.fn();
render(<CurrencyInput {...defaultProps} onChange={onChange} max={1000} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '5000' } });
expect(onChange).toHaveBeenCalledWith(1000);
});
it('enforces min value on blur', () => {
const onChange = vi.fn();
render(<CurrencyInput {...defaultProps} onChange={onChange} min={100} value={50} />);
const input = screen.getByRole('textbox');
fireEvent.blur(input);
expect(onChange).toHaveBeenCalledWith(100);
});
it('does not enforce min on blur when value is zero', () => {
const onChange = vi.fn();
render(<CurrencyInput {...defaultProps} onChange={onChange} min={100} value={0} />);
const input = screen.getByRole('textbox');
fireEvent.blur(input);
expect(onChange).not.toHaveBeenCalled();
});
});
describe('Focus Behavior', () => {
it('selects all text on focus', async () => {
vi.useFakeTimers();
render(<CurrencyInput {...defaultProps} value={1234} />);
const input = screen.getByRole('textbox') as HTMLInputElement;
const selectSpy = vi.spyOn(input, 'select');
fireEvent.focus(input);
vi.runAllTimers();
expect(selectSpy).toHaveBeenCalled();
vi.useRealTimers();
});
});
describe('Paste Handling', () => {
it('handles paste with digits', () => {
const onChange = vi.fn();
render(<CurrencyInput {...defaultProps} onChange={onChange} />);
const input = screen.getByRole('textbox');
const pasteEvent = {
preventDefault: vi.fn(),
clipboardData: {
getData: () => '1234',
},
};
fireEvent.paste(input, pasteEvent);
expect(onChange).toHaveBeenCalledWith(1234);
});
it('extracts digits from pasted text', () => {
const onChange = vi.fn();
render(<CurrencyInput {...defaultProps} onChange={onChange} />);
const input = screen.getByRole('textbox');
const pasteEvent = {
preventDefault: vi.fn(),
clipboardData: {
getData: () => '$12.34',
},
};
fireEvent.paste(input, pasteEvent);
expect(onChange).toHaveBeenCalledWith(1234);
});
it('enforces max on paste', () => {
const onChange = vi.fn();
render(<CurrencyInput {...defaultProps} onChange={onChange} max={500} />);
const input = screen.getByRole('textbox');
const pasteEvent = {
preventDefault: vi.fn(),
clipboardData: {
getData: () => '1000',
},
};
fireEvent.paste(input, pasteEvent);
expect(onChange).toHaveBeenCalledWith(500);
});
it('ignores empty paste', () => {
const onChange = vi.fn();
render(<CurrencyInput {...defaultProps} onChange={onChange} />);
const input = screen.getByRole('textbox');
const pasteEvent = {
preventDefault: vi.fn(),
clipboardData: {
getData: () => '',
},
};
fireEvent.paste(input, pasteEvent);
expect(onChange).not.toHaveBeenCalled();
});
});
describe('Keyboard Handling', () => {
it('allows digit keys (can type digits)', () => {
const onChange = vi.fn();
render(<CurrencyInput {...defaultProps} onChange={onChange} />);
const input = screen.getByRole('textbox');
// Type a digit
fireEvent.change(input, { target: { value: '5' } });
expect(onChange).toHaveBeenCalledWith(5);
});
it('handles navigation keys (component accepts them)', () => {
render(<CurrencyInput {...defaultProps} />);
const input = screen.getByRole('textbox');
const navigationKeys = ['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight'];
navigationKeys.forEach((key) => {
// These should not throw or cause issues
fireEvent.keyDown(input, { key });
});
// If we got here without errors, navigation keys are handled
expect(input).toBeInTheDocument();
});
it('allows Ctrl+A for select all', () => {
render(<CurrencyInput {...defaultProps} value={1234} />);
const input = screen.getByRole('textbox');
// Ctrl+A should not cause issues
fireEvent.keyDown(input, { key: 'a', ctrlKey: true });
expect(input).toBeInTheDocument();
});
it('allows Cmd+C for copy', () => {
render(<CurrencyInput {...defaultProps} value={1234} />);
const input = screen.getByRole('textbox');
// Cmd+C should not cause issues
fireEvent.keyDown(input, { key: 'c', metaKey: true });
expect(input).toBeInTheDocument();
});
});
describe('Input Attributes', () => {
it('has numeric input mode', () => {
render(<CurrencyInput {...defaultProps} />);
const input = screen.getByRole('textbox');
expect(input).toHaveAttribute('inputMode', 'numeric');
});
it('disables autocomplete', () => {
render(<CurrencyInput {...defaultProps} />);
const input = screen.getByRole('textbox');
expect(input).toHaveAttribute('autoComplete', 'off');
});
it('disables spell check', () => {
render(<CurrencyInput {...defaultProps} />);
const input = screen.getByRole('textbox');
expect(input).toHaveAttribute('spellCheck', 'false');
});
});
});

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EmptyState } from '../EmptyState';
describe('EmptyState', () => {
it('renders title', () => {
render(<EmptyState title="No items found" />);
expect(screen.getByText('No items found')).toBeInTheDocument();
});
it('renders description when provided', () => {
render(<EmptyState title="No items" description="Try adding some items" />);
expect(screen.getByText('Try adding some items')).toBeInTheDocument();
});
it('does not render description when not provided', () => {
const { container } = render(<EmptyState title="No items" />);
const descriptions = container.querySelectorAll('p');
expect(descriptions.length).toBe(0);
});
it('renders default icon when not provided', () => {
const { container } = render(<EmptyState title="No items" />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('renders custom icon when provided', () => {
render(<EmptyState title="No items" icon={<span data-testid="custom-icon">📭</span>} />);
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
});
it('renders action when provided', () => {
render(<EmptyState title="No items" action={<button>Add item</button>} />);
expect(screen.getByText('Add item')).toBeInTheDocument();
});
it('does not render action when not provided', () => {
render(<EmptyState title="No items" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('applies center text alignment', () => {
const { container } = render(<EmptyState title="No items" />);
expect(container.firstChild).toHaveClass('text-center');
});
it('applies padding', () => {
const { container } = render(<EmptyState title="No items" />);
expect(container.firstChild).toHaveClass('py-12', 'px-4');
});
it('applies custom className', () => {
const { container } = render(<EmptyState title="No items" className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
it('renders title with proper styling', () => {
render(<EmptyState title="No items" />);
const title = screen.getByText('No items');
expect(title).toHaveClass('text-lg', 'font-medium');
});
it('renders description with proper styling', () => {
render(<EmptyState title="No items" description="Add some items" />);
const description = screen.getByText('Add some items');
expect(description).toHaveClass('text-sm', 'text-gray-500');
});
it('centers the icon', () => {
const { container } = render(<EmptyState title="No items" />);
const iconContainer = container.querySelector('.flex.justify-center');
expect(iconContainer).toBeInTheDocument();
});
it('constrains description width', () => {
render(<EmptyState title="No items" description="Long description text" />);
const description = screen.getByText('Long description text');
expect(description).toHaveClass('max-w-sm', 'mx-auto');
});
});

View File

@@ -0,0 +1,241 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import FormCurrencyInput from '../FormCurrencyInput';
describe('FormCurrencyInput', () => {
const defaultProps = {
value: 0,
onChange: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Label Rendering', () => {
it('renders without label when not provided', () => {
render(<FormCurrencyInput {...defaultProps} />);
expect(screen.queryByRole('label')).not.toBeInTheDocument();
});
it('renders label when provided', () => {
render(<FormCurrencyInput {...defaultProps} label="Price" />);
expect(screen.getByText('Price')).toBeInTheDocument();
});
it('shows required indicator when required', () => {
render(<FormCurrencyInput {...defaultProps} label="Price" required />);
expect(screen.getByText('*')).toBeInTheDocument();
});
it('does not show required indicator when not required', () => {
render(<FormCurrencyInput {...defaultProps} label="Price" />);
expect(screen.queryByText('*')).not.toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('does not show error when not provided', () => {
render(<FormCurrencyInput {...defaultProps} />);
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
});
it('shows error message when provided', () => {
render(<FormCurrencyInput {...defaultProps} error="Price is required" />);
expect(screen.getByText('Price is required')).toBeInTheDocument();
});
it('applies error styling to input', () => {
render(<FormCurrencyInput {...defaultProps} error="Error" />);
const input = screen.getByRole('textbox');
expect(input).toHaveClass('border-red-500');
});
it('error has correct text color', () => {
render(<FormCurrencyInput {...defaultProps} error="Price is required" />);
const error = screen.getByText('Price is required');
expect(error).toHaveClass('text-red-600');
});
});
describe('Hint Rendering', () => {
it('does not show hint when not provided', () => {
render(<FormCurrencyInput {...defaultProps} />);
expect(screen.queryByText(/hint/i)).not.toBeInTheDocument();
});
it('shows hint when provided', () => {
render(<FormCurrencyInput {...defaultProps} hint="Enter price in dollars" />);
expect(screen.getByText('Enter price in dollars')).toBeInTheDocument();
});
it('hides hint when error is shown', () => {
render(
<FormCurrencyInput
{...defaultProps}
hint="Enter price"
error="Price required"
/>
);
expect(screen.queryByText('Enter price')).not.toBeInTheDocument();
expect(screen.getByText('Price required')).toBeInTheDocument();
});
it('hint has correct text color', () => {
render(<FormCurrencyInput {...defaultProps} hint="Helpful text" />);
const hint = screen.getByText('Helpful text');
expect(hint).toHaveClass('text-gray-500');
});
});
describe('Input Behavior', () => {
it('renders input with default placeholder', () => {
render(<FormCurrencyInput {...defaultProps} />);
const input = screen.getByPlaceholderText('$0.00');
expect(input).toBeInTheDocument();
});
it('renders input with custom placeholder', () => {
render(<FormCurrencyInput {...defaultProps} placeholder="Enter amount" />);
const input = screen.getByPlaceholderText('Enter amount');
expect(input).toBeInTheDocument();
});
it('displays formatted value', () => {
render(<FormCurrencyInput {...defaultProps} value={1000} />);
const input = screen.getByRole('textbox');
expect(input).toHaveValue('$10.00');
});
it('calls onChange when value changes', () => {
const onChange = vi.fn();
render(<FormCurrencyInput {...defaultProps} onChange={onChange} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '500' } });
expect(onChange).toHaveBeenCalledWith(500);
});
it('can be disabled', () => {
render(<FormCurrencyInput {...defaultProps} disabled />);
const input = screen.getByRole('textbox');
expect(input).toBeDisabled();
});
it('applies disabled styling', () => {
render(<FormCurrencyInput {...defaultProps} disabled />);
const input = screen.getByRole('textbox');
expect(input).toHaveClass('cursor-not-allowed');
});
});
describe('Min/Max Props', () => {
it('passes min prop to CurrencyInput', () => {
const onChange = vi.fn();
render(<FormCurrencyInput {...defaultProps} onChange={onChange} min={100} value={50} />);
const input = screen.getByRole('textbox');
fireEvent.blur(input);
expect(onChange).toHaveBeenCalledWith(100);
});
it('passes max prop to CurrencyInput', () => {
const onChange = vi.fn();
render(<FormCurrencyInput {...defaultProps} onChange={onChange} max={1000} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '5000' } });
expect(onChange).toHaveBeenCalledWith(1000);
});
});
describe('Styling', () => {
it('applies containerClassName to wrapper', () => {
const { container } = render(
<FormCurrencyInput {...defaultProps} containerClassName="my-container" />
);
expect(container.firstChild).toHaveClass('my-container');
});
it('applies className to input', () => {
render(<FormCurrencyInput {...defaultProps} className="my-input" />);
const input = screen.getByRole('textbox');
expect(input).toHaveClass('my-input');
});
it('applies base input classes', () => {
render(<FormCurrencyInput {...defaultProps} />);
const input = screen.getByRole('textbox');
expect(input).toHaveClass('w-full', 'px-3', 'py-2', 'border', 'rounded-lg');
});
it('applies normal border when no error', () => {
render(<FormCurrencyInput {...defaultProps} />);
const input = screen.getByRole('textbox');
expect(input).toHaveClass('border-gray-300');
});
it('applies error border when error provided', () => {
render(<FormCurrencyInput {...defaultProps} error="Error" />);
const input = screen.getByRole('textbox');
expect(input).toHaveClass('border-red-500');
expect(input).not.toHaveClass('border-gray-300');
});
});
describe('Accessibility', () => {
it('associates label with input', () => {
render(<FormCurrencyInput {...defaultProps} label="Price" />);
const label = screen.getByText('Price');
expect(label.tagName).toBe('LABEL');
});
it('marks input as required when required prop is true', () => {
render(<FormCurrencyInput {...defaultProps} required />);
const input = screen.getByRole('textbox');
expect(input).toBeRequired();
});
});
describe('Integration', () => {
it('full form flow works correctly', () => {
const onChange = vi.fn();
render(
<FormCurrencyInput
label="Service Price"
hint="Enter the price for this service"
value={0}
onChange={onChange}
required
min={100}
max={100000}
/>
);
// Check label and hint render
expect(screen.getByText('Service Price')).toBeInTheDocument();
expect(screen.getByText('Enter the price for this service')).toBeInTheDocument();
expect(screen.getByText('*')).toBeInTheDocument();
// Enter a value
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '2500' } });
expect(onChange).toHaveBeenCalledWith(2500);
});
it('shows error state correctly', () => {
render(
<FormCurrencyInput
label="Price"
error="Price must be greater than $1.00"
value={50}
onChange={vi.fn()}
/>
);
expect(screen.getByText('Price must be greater than $1.00')).toBeInTheDocument();
const input = screen.getByRole('textbox');
expect(input).toHaveClass('border-red-500');
});
});
});

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { FormInput } from '../FormInput';
describe('FormInput', () => {
it('renders input element', () => {
render(<FormInput />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('renders label when provided', () => {
render(<FormInput label="Username" />);
expect(screen.getByText('Username')).toBeInTheDocument();
});
it('shows required indicator when required', () => {
render(<FormInput label="Email" required />);
expect(screen.getByText('*')).toBeInTheDocument();
});
it('displays error message', () => {
render(<FormInput error="This field is required" />);
expect(screen.getByText('This field is required')).toBeInTheDocument();
});
it('displays hint when provided', () => {
render(<FormInput hint="Enter your username" />);
expect(screen.getByText('Enter your username')).toBeInTheDocument();
});
it('hides hint when error is shown', () => {
render(<FormInput hint="Enter your username" error="Required" />);
expect(screen.queryByText('Enter your username')).not.toBeInTheDocument();
expect(screen.getByText('Required')).toBeInTheDocument();
});
it('applies error styling when error present', () => {
render(<FormInput error="Invalid" />);
expect(screen.getByRole('textbox')).toHaveClass('border-red-500');
});
it('applies disabled styling', () => {
render(<FormInput disabled />);
expect(screen.getByRole('textbox')).toBeDisabled();
expect(screen.getByRole('textbox')).toHaveClass('cursor-not-allowed');
});
it('applies small size classes', () => {
render(<FormInput inputSize="sm" />);
expect(screen.getByRole('textbox')).toHaveClass('py-1');
});
it('applies medium size classes by default', () => {
render(<FormInput />);
expect(screen.getByRole('textbox')).toHaveClass('py-2');
});
it('applies large size classes', () => {
render(<FormInput inputSize="lg" />);
expect(screen.getByRole('textbox')).toHaveClass('py-3');
});
it('applies full width by default', () => {
render(<FormInput />);
expect(screen.getByRole('textbox')).toHaveClass('w-full');
});
it('can disable full width', () => {
render(<FormInput fullWidth={false} />);
expect(screen.getByRole('textbox')).not.toHaveClass('w-full');
});
it('renders left icon', () => {
render(<FormInput leftIcon={<span data-testid="left-icon">L</span>} />);
expect(screen.getByTestId('left-icon')).toBeInTheDocument();
});
it('renders right icon', () => {
render(<FormInput rightIcon={<span data-testid="right-icon">R</span>} />);
expect(screen.getByTestId('right-icon')).toBeInTheDocument();
});
it('applies custom className', () => {
render(<FormInput className="custom-class" />);
expect(screen.getByRole('textbox')).toHaveClass('custom-class');
});
it('applies custom containerClassName', () => {
const { container } = render(<FormInput containerClassName="container-class" />);
expect(container.firstChild).toHaveClass('container-class');
});
it('handles value changes', () => {
const handleChange = vi.fn();
render(<FormInput onChange={handleChange} />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test' } });
expect(handleChange).toHaveBeenCalled();
});
it('uses provided id', () => {
render(<FormInput id="custom-id" label="Label" />);
expect(screen.getByRole('textbox')).toHaveAttribute('id', 'custom-id');
});
it('uses name as id fallback', () => {
render(<FormInput name="username" label="Label" />);
expect(screen.getByRole('textbox')).toHaveAttribute('id', 'username');
});
it('generates random id when none provided', () => {
render(<FormInput label="Label" />);
expect(screen.getByRole('textbox')).toHaveAttribute('id');
});
it('links label to input', () => {
render(<FormInput id="my-input" label="My Label" />);
const input = screen.getByRole('textbox');
const label = screen.getByText('My Label');
expect(label).toHaveAttribute('for', 'my-input');
expect(input).toHaveAttribute('id', 'my-input');
});
it('forwards ref to input element', () => {
const ref = { current: null as HTMLInputElement | null };
render(<FormInput ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLInputElement);
});
it('passes through other input props', () => {
render(<FormInput placeholder="Enter text" type="email" maxLength={50} />);
const input = screen.getByRole('textbox');
expect(input).toHaveAttribute('placeholder', 'Enter text');
expect(input).toHaveAttribute('type', 'email');
expect(input).toHaveAttribute('maxLength', '50');
});
});

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { FormSelect } from '../FormSelect';
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3', disabled: true },
];
describe('FormSelect', () => {
it('renders select element', () => {
render(<FormSelect options={options} />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('renders options', () => {
render(<FormSelect options={options} />);
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
expect(screen.getByText('Option 3')).toBeInTheDocument();
});
it('renders label when provided', () => {
render(<FormSelect label="Select Option" options={options} />);
expect(screen.getByText('Select Option')).toBeInTheDocument();
});
it('shows required indicator when required', () => {
render(<FormSelect label="Category" options={options} required />);
expect(screen.getByText('*')).toBeInTheDocument();
});
it('displays placeholder option', () => {
render(<FormSelect options={options} placeholder="Choose one" />);
expect(screen.getByText('Choose one')).toBeInTheDocument();
});
it('disables placeholder option', () => {
render(<FormSelect options={options} placeholder="Choose one" />);
const placeholder = screen.getByText('Choose one');
expect(placeholder).toHaveAttribute('disabled');
});
it('displays error message', () => {
render(<FormSelect options={options} error="Selection required" />);
expect(screen.getByText('Selection required')).toBeInTheDocument();
});
it('displays hint when provided', () => {
render(<FormSelect options={options} hint="Select your preference" />);
expect(screen.getByText('Select your preference')).toBeInTheDocument();
});
it('hides hint when error is shown', () => {
render(<FormSelect options={options} hint="Select option" error="Required" />);
expect(screen.queryByText('Select option')).not.toBeInTheDocument();
expect(screen.getByText('Required')).toBeInTheDocument();
});
it('applies error styling when error present', () => {
render(<FormSelect options={options} error="Invalid" />);
expect(screen.getByRole('combobox')).toHaveClass('border-red-500');
});
it('applies disabled styling', () => {
render(<FormSelect options={options} disabled />);
expect(screen.getByRole('combobox')).toBeDisabled();
expect(screen.getByRole('combobox')).toHaveClass('cursor-not-allowed');
});
it('disables individual options', () => {
render(<FormSelect options={options} />);
const option3 = screen.getByText('Option 3');
expect(option3).toHaveAttribute('disabled');
});
it('applies small size classes', () => {
render(<FormSelect options={options} selectSize="sm" />);
expect(screen.getByRole('combobox')).toHaveClass('py-1');
});
it('applies medium size classes by default', () => {
render(<FormSelect options={options} />);
expect(screen.getByRole('combobox')).toHaveClass('py-2');
});
it('applies large size classes', () => {
render(<FormSelect options={options} selectSize="lg" />);
expect(screen.getByRole('combobox')).toHaveClass('py-3');
});
it('applies full width by default', () => {
render(<FormSelect options={options} />);
expect(screen.getByRole('combobox')).toHaveClass('w-full');
});
it('can disable full width', () => {
render(<FormSelect options={options} fullWidth={false} />);
expect(screen.getByRole('combobox')).not.toHaveClass('w-full');
});
it('applies custom className', () => {
render(<FormSelect options={options} className="custom-class" />);
expect(screen.getByRole('combobox')).toHaveClass('custom-class');
});
it('applies custom containerClassName', () => {
const { container } = render(<FormSelect options={options} containerClassName="container-class" />);
expect(container.firstChild).toHaveClass('container-class');
});
it('handles value changes', () => {
const handleChange = vi.fn();
render(<FormSelect options={options} onChange={handleChange} />);
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'option2' } });
expect(handleChange).toHaveBeenCalled();
});
it('uses provided id', () => {
render(<FormSelect options={options} id="custom-id" label="Label" />);
expect(screen.getByRole('combobox')).toHaveAttribute('id', 'custom-id');
});
it('uses name as id fallback', () => {
render(<FormSelect options={options} name="category" label="Label" />);
expect(screen.getByRole('combobox')).toHaveAttribute('id', 'category');
});
it('links label to select', () => {
render(<FormSelect options={options} id="my-select" label="My Label" />);
const select = screen.getByRole('combobox');
const label = screen.getByText('My Label');
expect(label).toHaveAttribute('for', 'my-select');
expect(select).toHaveAttribute('id', 'my-select');
});
it('forwards ref to select element', () => {
const ref = { current: null as HTMLSelectElement | null };
render(<FormSelect options={options} ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLSelectElement);
});
it('renders dropdown arrow icon', () => {
const { container } = render(<FormSelect options={options} />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { FormTextarea } from '../FormTextarea';
describe('FormTextarea', () => {
it('renders textarea element', () => {
render(<FormTextarea />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('renders label when provided', () => {
render(<FormTextarea label="Description" />);
expect(screen.getByText('Description')).toBeInTheDocument();
});
it('shows required indicator when required', () => {
render(<FormTextarea label="Comments" required />);
expect(screen.getByText('*')).toBeInTheDocument();
});
it('displays error message', () => {
render(<FormTextarea error="This field is required" />);
expect(screen.getByText('This field is required')).toBeInTheDocument();
});
it('displays hint when provided', () => {
render(<FormTextarea hint="Max 500 characters" />);
expect(screen.getByText('Max 500 characters')).toBeInTheDocument();
});
it('hides hint when error is shown', () => {
render(<FormTextarea hint="Enter description" error="Required" />);
expect(screen.queryByText('Enter description')).not.toBeInTheDocument();
expect(screen.getByText('Required')).toBeInTheDocument();
});
it('applies error styling when error present', () => {
render(<FormTextarea error="Invalid" />);
expect(screen.getByRole('textbox')).toHaveClass('border-red-500');
});
it('applies disabled styling', () => {
render(<FormTextarea disabled />);
expect(screen.getByRole('textbox')).toBeDisabled();
expect(screen.getByRole('textbox')).toHaveClass('cursor-not-allowed');
});
it('applies full width by default', () => {
render(<FormTextarea />);
expect(screen.getByRole('textbox')).toHaveClass('w-full');
});
it('can disable full width', () => {
render(<FormTextarea fullWidth={false} />);
expect(screen.getByRole('textbox')).not.toHaveClass('w-full');
});
it('applies custom className', () => {
render(<FormTextarea className="custom-class" />);
expect(screen.getByRole('textbox')).toHaveClass('custom-class');
});
it('applies custom containerClassName', () => {
const { container } = render(<FormTextarea containerClassName="container-class" />);
expect(container.firstChild).toHaveClass('container-class');
});
it('handles value changes', () => {
const handleChange = vi.fn();
render(<FormTextarea onChange={handleChange} />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test content' } });
expect(handleChange).toHaveBeenCalled();
});
it('uses provided id', () => {
render(<FormTextarea id="custom-id" label="Label" />);
expect(screen.getByRole('textbox')).toHaveAttribute('id', 'custom-id');
});
it('uses name as id fallback', () => {
render(<FormTextarea name="description" label="Label" />);
expect(screen.getByRole('textbox')).toHaveAttribute('id', 'description');
});
it('links label to textarea', () => {
render(<FormTextarea id="my-textarea" label="My Label" />);
const textarea = screen.getByRole('textbox');
const label = screen.getByText('My Label');
expect(label).toHaveAttribute('for', 'my-textarea');
expect(textarea).toHaveAttribute('id', 'my-textarea');
});
it('forwards ref to textarea element', () => {
const ref = { current: null as HTMLTextAreaElement | null };
render(<FormTextarea ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLTextAreaElement);
});
it('shows character count when enabled', () => {
render(<FormTextarea showCharCount value="Hello" />);
expect(screen.getByText('5')).toBeInTheDocument();
});
it('shows character count with max chars', () => {
render(<FormTextarea showCharCount maxChars={100} value="Hello" />);
expect(screen.getByText('5/100')).toBeInTheDocument();
});
it('applies warning style when over max chars', () => {
render(<FormTextarea showCharCount maxChars={5} value="Hello World" />);
expect(screen.getByText('11/5')).toHaveClass('text-red-500');
});
it('passes through other textarea props', () => {
render(<FormTextarea placeholder="Enter text" rows={5} />);
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveAttribute('placeholder', 'Enter text');
expect(textarea).toHaveAttribute('rows', '5');
});
});

View File

@@ -0,0 +1,147 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { LoadingSpinner, PageLoading, InlineLoading } from '../LoadingSpinner';
describe('LoadingSpinner', () => {
it('renders spinner element', () => {
const { container } = render(<LoadingSpinner />);
expect(container.querySelector('.animate-spin')).toBeInTheDocument();
});
it('applies default size (md)', () => {
const { container } = render(<LoadingSpinner />);
const spinner = container.querySelector('.animate-spin');
expect(spinner).toHaveClass('h-6', 'w-6');
});
it('applies xs size', () => {
const { container } = render(<LoadingSpinner size="xs" />);
const spinner = container.querySelector('.animate-spin');
expect(spinner).toHaveClass('h-3', 'w-3');
});
it('applies sm size', () => {
const { container } = render(<LoadingSpinner size="sm" />);
const spinner = container.querySelector('.animate-spin');
expect(spinner).toHaveClass('h-4', 'w-4');
});
it('applies lg size', () => {
const { container } = render(<LoadingSpinner size="lg" />);
const spinner = container.querySelector('.animate-spin');
expect(spinner).toHaveClass('h-8', 'w-8');
});
it('applies xl size', () => {
const { container } = render(<LoadingSpinner size="xl" />);
const spinner = container.querySelector('.animate-spin');
expect(spinner).toHaveClass('h-12', 'w-12');
});
it('applies default color', () => {
const { container } = render(<LoadingSpinner />);
const spinner = container.querySelector('.animate-spin');
expect(spinner).toHaveClass('text-gray-500');
});
it('applies white color', () => {
const { container } = render(<LoadingSpinner color="white" />);
const spinner = container.querySelector('.animate-spin');
expect(spinner).toHaveClass('text-white');
});
it('applies brand color', () => {
const { container } = render(<LoadingSpinner color="brand" />);
const spinner = container.querySelector('.animate-spin');
expect(spinner).toHaveClass('text-brand-600');
});
it('applies blue color', () => {
const { container } = render(<LoadingSpinner color="blue" />);
const spinner = container.querySelector('.animate-spin');
expect(spinner).toHaveClass('text-blue-600');
});
it('renders label when provided', () => {
render(<LoadingSpinner label="Loading data..." />);
expect(screen.getByText('Loading data...')).toBeInTheDocument();
});
it('does not render label when not provided', () => {
const { container } = render(<LoadingSpinner />);
const spans = container.querySelectorAll('span');
expect(spans.length).toBe(0);
});
it('centers spinner when centered prop is true', () => {
const { container } = render(<LoadingSpinner centered />);
expect(container.firstChild).toHaveClass('flex', 'items-center', 'justify-center');
});
it('does not center spinner by default', () => {
const { container } = render(<LoadingSpinner />);
expect(container.firstChild).not.toHaveClass('py-12');
});
it('applies custom className', () => {
const { container } = render(<LoadingSpinner className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
});
describe('PageLoading', () => {
it('renders with default loading text', () => {
render(<PageLoading />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('renders with custom label', () => {
render(<PageLoading label="Fetching data..." />);
expect(screen.getByText('Fetching data...')).toBeInTheDocument();
});
it('renders large spinner', () => {
const { container } = render(<PageLoading />);
const spinner = container.querySelector('.animate-spin');
expect(spinner).toHaveClass('h-8', 'w-8');
});
it('renders with brand color', () => {
const { container } = render(<PageLoading />);
const spinner = container.querySelector('.animate-spin');
expect(spinner).toHaveClass('text-brand-600');
});
it('is centered in container', () => {
const { container } = render(<PageLoading />);
expect(container.firstChild).toHaveClass('flex', 'items-center', 'justify-center');
});
});
describe('InlineLoading', () => {
it('renders spinner', () => {
const { container } = render(<InlineLoading />);
expect(container.querySelector('.animate-spin')).toBeInTheDocument();
});
it('renders label when provided', () => {
render(<InlineLoading label="Saving..." />);
expect(screen.getByText('Saving...')).toBeInTheDocument();
});
it('does not render label when not provided', () => {
render(<InlineLoading />);
expect(screen.queryByText(/./)).not.toBeInTheDocument();
});
it('renders small spinner', () => {
const { container } = render(<InlineLoading />);
const spinner = container.querySelector('.animate-spin');
expect(spinner).toHaveClass('h-4', 'w-4');
});
it('renders inline', () => {
const { container } = render(<InlineLoading />);
expect(container.firstChild).toHaveClass('inline-flex');
});
});

View File

@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Modal } from '../Modal';
describe('Modal', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
children: <div>Modal Content</div>,
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
document.body.style.overflow = '';
});
it('returns null when not open', () => {
render(<Modal {...defaultProps} isOpen={false} />);
expect(screen.queryByText('Modal Content')).not.toBeInTheDocument();
});
it('renders children when open', () => {
render(<Modal {...defaultProps} />);
expect(screen.getByText('Modal Content')).toBeInTheDocument();
});
it('renders title when provided', () => {
render(<Modal {...defaultProps} title="Modal Title" />);
expect(screen.getByText('Modal Title')).toBeInTheDocument();
});
it('renders footer when provided', () => {
render(<Modal {...defaultProps} footer={<button>Save</button>} />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('shows close button by default', () => {
render(<Modal {...defaultProps} title="Title" />);
expect(screen.getByLabelText('Close modal')).toBeInTheDocument();
});
it('hides close button when showCloseButton is false', () => {
render(<Modal {...defaultProps} showCloseButton={false} />);
expect(screen.queryByLabelText('Close modal')).not.toBeInTheDocument();
});
it('calls onClose when close button clicked', () => {
render(<Modal {...defaultProps} title="Title" />);
fireEvent.click(screen.getByLabelText('Close modal'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('calls onClose when overlay clicked', () => {
render(<Modal {...defaultProps} />);
// Click on backdrop
const backdrop = document.querySelector('.backdrop-blur-sm');
if (backdrop) {
fireEvent.click(backdrop);
expect(defaultProps.onClose).toHaveBeenCalled();
}
});
it('does not close when content clicked', () => {
render(<Modal {...defaultProps} />);
fireEvent.click(screen.getByText('Modal Content'));
expect(defaultProps.onClose).not.toHaveBeenCalled();
});
it('does not close on overlay click when closeOnOverlayClick is false', () => {
render(<Modal {...defaultProps} closeOnOverlayClick={false} />);
const backdrop = document.querySelector('.backdrop-blur-sm');
if (backdrop) {
fireEvent.click(backdrop);
expect(defaultProps.onClose).not.toHaveBeenCalled();
}
});
it('applies size classes', () => {
render(<Modal {...defaultProps} size="lg" />);
const modalContent = document.querySelector('.max-w-lg');
expect(modalContent).toBeInTheDocument();
});
it('applies custom className', () => {
render(<Modal {...defaultProps} className="custom-class" />);
const modalContent = document.querySelector('.custom-class');
expect(modalContent).toBeInTheDocument();
});
it('prevents body scroll when open', () => {
render(<Modal {...defaultProps} />);
expect(document.body.style.overflow).toBe('hidden');
});
});

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ModalFooter } from '../ModalFooter';
describe('ModalFooter', () => {
it('renders cancel button when onCancel provided', () => {
render(<ModalFooter onCancel={() => {}} />);
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
it('renders submit button when onSubmit provided', () => {
render(<ModalFooter onSubmit={() => {}} />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('renders both buttons when both handlers provided', () => {
render(<ModalFooter onCancel={() => {}} onSubmit={() => {}} />);
expect(screen.getByText('Cancel')).toBeInTheDocument();
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('uses custom submit text', () => {
render(<ModalFooter onSubmit={() => {}} submitText="Create" />);
expect(screen.getByText('Create')).toBeInTheDocument();
});
it('uses custom cancel text', () => {
render(<ModalFooter onCancel={() => {}} cancelText="Close" />);
expect(screen.getByText('Close')).toBeInTheDocument();
});
it('calls onCancel when cancel button clicked', () => {
const handleCancel = vi.fn();
render(<ModalFooter onCancel={handleCancel} />);
fireEvent.click(screen.getByText('Cancel'));
expect(handleCancel).toHaveBeenCalled();
});
it('calls onSubmit when submit button clicked', () => {
const handleSubmit = vi.fn();
render(<ModalFooter onSubmit={handleSubmit} />);
fireEvent.click(screen.getByText('Save'));
expect(handleSubmit).toHaveBeenCalled();
});
it('disables submit button when isDisabled is true', () => {
render(<ModalFooter onSubmit={() => {}} isDisabled />);
expect(screen.getByText('Save')).toBeDisabled();
});
it('disables buttons when isLoading is true', () => {
render(<ModalFooter onCancel={() => {}} onSubmit={() => {}} isLoading />);
expect(screen.getByText('Cancel')).toBeDisabled();
expect(screen.getByText('Save')).toBeDisabled();
});
it('shows loading spinner when isLoading', () => {
const { container } = render(<ModalFooter onSubmit={() => {}} isLoading />);
expect(container.querySelector('.animate-spin')).toBeInTheDocument();
});
it('renders back button when showBackButton is true', () => {
render(<ModalFooter onBack={() => {}} showBackButton />);
expect(screen.getByText('Back')).toBeInTheDocument();
});
it('uses custom back text', () => {
render(<ModalFooter onBack={() => {}} showBackButton backText="Previous" />);
expect(screen.getByText('Previous')).toBeInTheDocument();
});
it('calls onBack when back button clicked', () => {
const handleBack = vi.fn();
render(<ModalFooter onBack={handleBack} showBackButton />);
fireEvent.click(screen.getByText('Back'));
expect(handleBack).toHaveBeenCalled();
});
it('does not render back button without onBack handler', () => {
render(<ModalFooter showBackButton />);
expect(screen.queryByText('Back')).not.toBeInTheDocument();
});
it('applies primary variant by default', () => {
render(<ModalFooter onSubmit={() => {}} />);
expect(screen.getByText('Save')).toHaveClass('bg-blue-600');
});
it('applies danger variant', () => {
render(<ModalFooter onSubmit={() => {}} submitVariant="danger" />);
expect(screen.getByText('Save')).toHaveClass('bg-red-600');
});
it('applies success variant', () => {
render(<ModalFooter onSubmit={() => {}} submitVariant="success" />);
expect(screen.getByText('Save')).toHaveClass('bg-green-600');
});
it('applies warning variant', () => {
render(<ModalFooter onSubmit={() => {}} submitVariant="warning" />);
expect(screen.getByText('Save')).toHaveClass('bg-amber-600');
});
it('applies secondary variant', () => {
render(<ModalFooter onSubmit={() => {}} submitVariant="secondary" />);
expect(screen.getByText('Save')).toHaveClass('bg-gray-600');
});
it('renders children instead of default buttons', () => {
render(
<ModalFooter onSubmit={() => {}} onCancel={() => {}}>
<button>Custom Button</button>
</ModalFooter>
);
expect(screen.getByText('Custom Button')).toBeInTheDocument();
expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
expect(screen.queryByText('Save')).not.toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<ModalFooter className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
it('renders nothing when no handlers provided', () => {
const { container } = render(<ModalFooter />);
expect(container.querySelector('button')).not.toBeInTheDocument();
});
it('disables back button when loading', () => {
render(<ModalFooter onBack={() => {}} showBackButton isLoading />);
expect(screen.getByText('Back')).toBeDisabled();
});
});

View File

@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { StepIndicator } from '../StepIndicator';
const steps = [
{ id: 1, label: 'Step 1' },
{ id: 2, label: 'Step 2' },
{ id: 3, label: 'Step 3' },
];
describe('StepIndicator', () => {
it('renders all steps', () => {
render(<StepIndicator steps={steps} currentStep={1} />);
expect(screen.getByText('Step 1')).toBeInTheDocument();
expect(screen.getByText('Step 2')).toBeInTheDocument();
expect(screen.getByText('Step 3')).toBeInTheDocument();
});
it('shows step numbers', () => {
render(<StepIndicator steps={steps} currentStep={1} />);
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
});
it('highlights current step', () => {
render(<StepIndicator steps={steps} currentStep={2} />);
const step2Circle = screen.getByText('2').closest('div');
expect(step2Circle).toHaveClass('bg-blue-600');
});
it('shows checkmark for completed steps', () => {
const { container } = render(<StepIndicator steps={steps} currentStep={3} />);
const checkmarks = container.querySelectorAll('svg');
// Steps 1 and 2 should show checkmarks
expect(checkmarks.length).toBe(2);
});
it('applies pending style to future steps', () => {
render(<StepIndicator steps={steps} currentStep={1} />);
const step3Circle = screen.getByText('3').closest('div');
expect(step3Circle).toHaveClass('bg-gray-200');
});
it('shows connectors between steps by default', () => {
const { container } = render(<StepIndicator steps={steps} currentStep={1} />);
const connectors = container.querySelectorAll('.w-16');
expect(connectors.length).toBe(2);
});
it('hides connectors when showConnectors is false', () => {
const { container } = render(<StepIndicator steps={steps} currentStep={1} showConnectors={false} />);
const connectors = container.querySelectorAll('.w-16');
expect(connectors.length).toBe(0);
});
it('applies completed style to connector before current step', () => {
const { container } = render(<StepIndicator steps={steps} currentStep={2} />);
const connectors = container.querySelectorAll('.w-16');
expect(connectors[0]).toHaveClass('bg-blue-600');
expect(connectors[1]).toHaveClass('bg-gray-200');
});
it('applies blue color by default', () => {
render(<StepIndicator steps={steps} currentStep={1} />);
const currentStep = screen.getByText('1').closest('div');
expect(currentStep).toHaveClass('bg-blue-600');
});
it('applies brand color', () => {
render(<StepIndicator steps={steps} currentStep={1} color="brand" />);
const currentStep = screen.getByText('1').closest('div');
expect(currentStep).toHaveClass('bg-brand-600');
});
it('applies green color', () => {
render(<StepIndicator steps={steps} currentStep={1} color="green" />);
const currentStep = screen.getByText('1').closest('div');
expect(currentStep).toHaveClass('bg-green-600');
});
it('applies purple color', () => {
render(<StepIndicator steps={steps} currentStep={1} color="purple" />);
const currentStep = screen.getByText('1').closest('div');
expect(currentStep).toHaveClass('bg-purple-600');
});
it('applies custom className', () => {
const { container } = render(<StepIndicator steps={steps} currentStep={1} className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
it('centers steps', () => {
const { container } = render(<StepIndicator steps={steps} currentStep={1} />);
expect(container.firstChild).toHaveClass('flex', 'items-center', 'justify-center');
});
it('applies active text color to current step label', () => {
render(<StepIndicator steps={steps} currentStep={2} />);
const step2Label = screen.getByText('Step 2');
expect(step2Label).toHaveClass('text-blue-600');
});
it('applies pending text color to future step labels', () => {
render(<StepIndicator steps={steps} currentStep={1} />);
const step3Label = screen.getByText('Step 3');
expect(step3Label).toHaveClass('text-gray-400');
});
it('applies active text color to completed step labels', () => {
render(<StepIndicator steps={steps} currentStep={3} />);
const step1Label = screen.getByText('Step 1');
expect(step1Label).toHaveClass('text-blue-600');
});
it('handles single step', () => {
const singleStep = [{ id: 1, label: 'Only Step' }];
render(<StepIndicator steps={singleStep} currentStep={1} />);
expect(screen.getByText('Only Step')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
});
it('renders step circles with correct size', () => {
render(<StepIndicator steps={steps} currentStep={1} />);
const stepCircle = screen.getByText('1').closest('div');
expect(stepCircle).toHaveClass('w-8', 'h-8');
});
it('renders step circles as rounded', () => {
render(<StepIndicator steps={steps} currentStep={1} />);
const stepCircle = screen.getByText('1').closest('div');
expect(stepCircle).toHaveClass('rounded-full');
});
it('handles string IDs', () => {
const stepsWithStringIds = [
{ id: 'first', label: 'First' },
{ id: 'second', label: 'Second' },
];
render(<StepIndicator steps={stepsWithStringIds} currentStep={1} />);
expect(screen.getByText('First')).toBeInTheDocument();
expect(screen.getByText('Second')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,160 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { TabGroup } from '../TabGroup';
const tabs = [
{ id: 'tab1', label: 'Tab 1' },
{ id: 'tab2', label: 'Tab 2' },
{ id: 'tab3', label: 'Tab 3', disabled: true },
];
describe('TabGroup', () => {
it('renders all tabs', () => {
render(<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} />);
expect(screen.getByText('Tab 1')).toBeInTheDocument();
expect(screen.getByText('Tab 2')).toBeInTheDocument();
expect(screen.getByText('Tab 3')).toBeInTheDocument();
});
it('highlights active tab', () => {
render(<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} />);
const activeButton = screen.getByText('Tab 1').closest('button');
expect(activeButton).toHaveClass('bg-blue-600');
});
it('calls onChange when tab is clicked', () => {
const handleChange = vi.fn();
render(<TabGroup tabs={tabs} activeTab="tab1" onChange={handleChange} />);
fireEvent.click(screen.getByText('Tab 2'));
expect(handleChange).toHaveBeenCalledWith('tab2');
});
it('does not call onChange for disabled tabs', () => {
const handleChange = vi.fn();
render(<TabGroup tabs={tabs} activeTab="tab1" onChange={handleChange} />);
fireEvent.click(screen.getByText('Tab 3'));
expect(handleChange).not.toHaveBeenCalled();
});
it('applies disabled styling to disabled tabs', () => {
render(<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} />);
const disabledButton = screen.getByText('Tab 3').closest('button');
expect(disabledButton).toHaveClass('opacity-50');
expect(disabledButton).toHaveClass('cursor-not-allowed');
});
it('renders default variant by default', () => {
const { container } = render(<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} />);
expect(container.firstChild).toHaveClass('rounded-lg');
});
it('renders underline variant', () => {
const { container } = render(
<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} variant="underline" />
);
expect(container.firstChild).toHaveClass('border-b');
});
it('renders pills variant', () => {
const { container } = render(
<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} variant="pills" />
);
const activeButton = screen.getByText('Tab 1').closest('button');
expect(activeButton).toHaveClass('rounded-full');
});
it('applies small size classes', () => {
render(<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} size="sm" />);
const button = screen.getByText('Tab 1').closest('button');
expect(button).toHaveClass('py-1.5');
});
it('applies medium size classes by default', () => {
render(<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} />);
const button = screen.getByText('Tab 1').closest('button');
expect(button).toHaveClass('py-2');
});
it('applies large size classes', () => {
render(<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} size="lg" />);
const button = screen.getByText('Tab 1').closest('button');
expect(button).toHaveClass('py-2.5');
});
it('applies full width by default', () => {
render(<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} />);
const button = screen.getByText('Tab 1').closest('button');
expect(button).toHaveClass('flex-1');
});
it('can disable full width', () => {
render(<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} fullWidth={false} />);
const button = screen.getByText('Tab 1').closest('button');
expect(button).not.toHaveClass('flex-1');
});
it('applies custom className', () => {
const { container } = render(
<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} className="custom-class" />
);
expect(container.firstChild).toHaveClass('custom-class');
});
it('applies blue active color by default', () => {
render(<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} />);
const activeButton = screen.getByText('Tab 1').closest('button');
expect(activeButton).toHaveClass('bg-blue-600');
});
it('applies purple active color', () => {
render(<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} activeColor="purple" />);
const activeButton = screen.getByText('Tab 1').closest('button');
expect(activeButton).toHaveClass('bg-purple-600');
});
it('applies green active color', () => {
render(<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} activeColor="green" />);
const activeButton = screen.getByText('Tab 1').closest('button');
expect(activeButton).toHaveClass('bg-green-600');
});
it('applies brand active color', () => {
render(<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} activeColor="brand" />);
const activeButton = screen.getByText('Tab 1').closest('button');
expect(activeButton).toHaveClass('bg-brand-600');
});
it('renders tabs with icons', () => {
const tabsWithIcons = [
{ id: 'tab1', label: 'Tab 1', icon: <span data-testid="icon-1">🏠</span> },
{ id: 'tab2', label: 'Tab 2', icon: <span data-testid="icon-2">📧</span> },
];
render(<TabGroup tabs={tabsWithIcons} activeTab="tab1" onChange={() => {}} />);
expect(screen.getByTestId('icon-1')).toBeInTheDocument();
expect(screen.getByTestId('icon-2')).toBeInTheDocument();
});
it('renders tabs with ReactNode labels', () => {
const tabsWithNodes = [
{ id: 'tab1', label: <strong>Bold Tab</strong> },
];
render(<TabGroup tabs={tabsWithNodes} activeTab="tab1" onChange={() => {}} />);
expect(screen.getByText('Bold Tab')).toBeInTheDocument();
});
it('applies underline variant colors correctly', () => {
render(
<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} variant="underline" />
);
const activeButton = screen.getByText('Tab 1').closest('button');
expect(activeButton).toHaveClass('border-blue-600');
});
it('applies pills variant colors correctly', () => {
render(
<TabGroup tabs={tabs} activeTab="tab1" onChange={() => {}} variant="pills" />
);
const activeButton = screen.getByText('Tab 1').closest('button');
expect(activeButton).toHaveClass('bg-blue-100');
});
});

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { UnfinishedBadge } from '../UnfinishedBadge';
describe('UnfinishedBadge', () => {
it('renders WIP text', () => {
render(<UnfinishedBadge />);
expect(screen.getByText('WIP')).toBeInTheDocument();
});
it('renders as a badge', () => {
render(<UnfinishedBadge />);
const badge = screen.getByText('WIP').closest('span');
expect(badge).toBeInTheDocument();
});
it('uses warning variant', () => {
render(<UnfinishedBadge />);
const badge = screen.getByText('WIP').closest('span');
expect(badge).toHaveClass('bg-amber-100');
});
it('uses pill style', () => {
render(<UnfinishedBadge />);
const badge = screen.getByText('WIP').closest('span');
expect(badge).toHaveClass('rounded-full');
});
it('uses small size', () => {
render(<UnfinishedBadge />);
const badge = screen.getByText('WIP').closest('span');
expect(badge).toHaveClass('text-xs');
});
});

View File

@@ -0,0 +1,310 @@
/**
* Lumina Design System - Reusable UI Components
* Modern, premium design aesthetic with smooth animations and clean styling
*/
import React from 'react';
import { LucideIcon } from 'lucide-react';
// ============================================================================
// Button Components
// ============================================================================
interface LuminaButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
icon?: LucideIcon;
iconPosition?: 'left' | 'right';
loading?: boolean;
children: React.ReactNode;
}
export const LuminaButton: React.FC<LuminaButtonProps> = ({
variant = 'primary',
size = 'md',
icon: Icon,
iconPosition = 'right',
loading = false,
children,
className = '',
disabled,
...props
}) => {
const baseClasses = 'inline-flex items-center justify-center font-medium transition-all focus:outline-none focus:ring-2 focus:ring-offset-2';
const variantClasses = {
primary: 'bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500 shadow-sm',
secondary: 'bg-white text-gray-900 border border-gray-300 hover:bg-gray-50 focus:ring-indigo-500',
ghost: 'text-indigo-600 hover:bg-indigo-50 focus:ring-indigo-500',
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm rounded-lg',
md: 'px-4 py-2.5 text-sm rounded-lg',
lg: 'px-6 py-3 text-base rounded-lg',
};
const disabledClasses = 'disabled:opacity-70 disabled:cursor-not-allowed';
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${disabledClasses} ${className}`}
disabled={disabled || loading}
{...props}
>
{loading ? (
<span className="animate-pulse">Processing...</span>
) : (
<>
{Icon && iconPosition === 'left' && <Icon className="w-4 h-4 mr-2" />}
{children}
{Icon && iconPosition === 'right' && <Icon className="w-4 h-4 ml-2" />}
</>
)}
</button>
);
};
// ============================================================================
// Input Components
// ============================================================================
interface LuminaInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
hint?: string;
icon?: LucideIcon;
}
export const LuminaInput: React.FC<LuminaInputProps> = ({
label,
error,
hint,
icon: Icon,
className = '',
...props
}) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-gray-700 mb-1">
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="relative">
{Icon && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon className="h-5 w-5 text-gray-400" />
</div>
)}
<input
className={`block w-full ${Icon ? 'pl-10' : 'pl-3'} pr-3 py-2.5 border ${
error ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : 'border-gray-300 focus:ring-indigo-500 focus:border-indigo-500'
} rounded-lg transition-colors ${className}`}
{...props}
/>
</div>
{error && <p className="text-sm text-red-600 mt-1">{error}</p>}
{hint && !error && <p className="text-sm text-gray-500 mt-1">{hint}</p>}
</div>
);
};
// ============================================================================
// Card Components
// ============================================================================
interface LuminaCardProps {
children: React.ReactNode;
className?: string;
padding?: 'none' | 'sm' | 'md' | 'lg';
hover?: boolean;
}
export const LuminaCard: React.FC<LuminaCardProps> = ({
children,
className = '',
padding = 'md',
hover = false,
}) => {
const paddingClasses = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
};
const hoverClasses = hover ? 'hover:shadow-lg hover:-translate-y-0.5 transition-all' : '';
return (
<div className={`bg-white rounded-2xl shadow-sm border border-gray-100 ${paddingClasses[padding]} ${hoverClasses} ${className}`}>
{children}
</div>
);
};
// ============================================================================
// Badge Components
// ============================================================================
interface LuminaBadgeProps {
children: React.ReactNode;
variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
size?: 'sm' | 'md';
}
export const LuminaBadge: React.FC<LuminaBadgeProps> = ({
children,
variant = 'default',
size = 'md',
}) => {
const variantClasses = {
default: 'bg-gray-100 text-gray-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-amber-100 text-amber-800',
error: 'bg-red-100 text-red-800',
info: 'bg-blue-100 text-blue-800',
};
const sizeClasses = {
sm: 'text-xs px-2 py-0.5',
md: 'text-sm px-2.5 py-1',
};
return (
<span className={`inline-flex items-center font-medium rounded-full ${variantClasses[variant]} ${sizeClasses[size]}`}>
{children}
</span>
);
};
// ============================================================================
// Section Container
// ============================================================================
interface LuminaSectionProps {
children: React.ReactNode;
title?: string;
subtitle?: string;
className?: string;
}
export const LuminaSection: React.FC<LuminaSectionProps> = ({
children,
title,
subtitle,
className = '',
}) => {
return (
<section className={`py-16 px-4 sm:px-6 lg:px-8 ${className}`}>
<div className="max-w-7xl mx-auto">
{(title || subtitle) && (
<div className="text-center mb-12">
{title && <h2 className="text-3xl font-bold text-gray-900 mb-3">{title}</h2>}
{subtitle && <p className="text-lg text-gray-600 max-w-2xl mx-auto">{subtitle}</p>}
</div>
)}
{children}
</div>
</section>
);
};
// ============================================================================
// Icon Box Component
// ============================================================================
interface LuminaIconBoxProps {
icon: LucideIcon;
color?: 'indigo' | 'green' | 'amber' | 'red' | 'blue';
size?: 'sm' | 'md' | 'lg';
}
export const LuminaIconBox: React.FC<LuminaIconBoxProps> = ({
icon: Icon,
color = 'indigo',
size = 'md',
}) => {
const colorClasses = {
indigo: 'bg-indigo-100 text-indigo-600',
green: 'bg-green-100 text-green-600',
amber: 'bg-amber-100 text-amber-600',
red: 'bg-red-100 text-red-600',
blue: 'bg-blue-100 text-blue-600',
};
const sizeClasses = {
sm: 'w-10 h-10',
md: 'w-12 h-12',
lg: 'w-16 h-16',
};
const iconSizeClasses = {
sm: 'w-5 h-5',
md: 'w-6 h-6',
lg: 'w-8 h-8',
};
return (
<div className={`${sizeClasses[size]} ${colorClasses[color]} rounded-xl flex items-center justify-center`}>
<Icon className={iconSizeClasses[size]} />
</div>
);
};
// ============================================================================
// Feature Card Component
// ============================================================================
interface LuminaFeatureCardProps {
icon: LucideIcon;
title: string;
description: string;
onClick?: () => void;
}
export const LuminaFeatureCard: React.FC<LuminaFeatureCardProps> = ({
icon,
title,
description,
onClick,
}) => {
return (
<LuminaCard
hover={!!onClick}
className={onClick ? 'cursor-pointer' : ''}
onClick={onClick}
>
<div className="flex flex-col items-center text-center">
<LuminaIconBox icon={icon} size="lg" />
<h3 className="mt-4 text-lg font-semibold text-gray-900">{title}</h3>
<p className="mt-2 text-gray-600">{description}</p>
</div>
</LuminaCard>
);
};
// ============================================================================
// Loading Spinner
// ============================================================================
interface LuminaSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export const LuminaSpinner: React.FC<LuminaSpinnerProps> = ({
size = 'md',
className = '',
}) => {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
};
return (
<div className={`animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600 ${sizeClasses[size]} ${className}`} />
);
};

View File

@@ -0,0 +1,293 @@
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import {
useFormValidation,
required,
email,
minLength,
maxLength,
minValue,
maxValue,
pattern,
url,
matches,
phone,
} from '../useFormValidation';
describe('useFormValidation', () => {
describe('hook functionality', () => {
it('initializes with no errors', () => {
const { result } = renderHook(() => useFormValidation({}));
expect(result.current.errors).toEqual({});
expect(result.current.isValid).toBe(true);
});
it('validates form and returns errors', () => {
const schema = {
name: [required('Name is required')],
};
const { result } = renderHook(() => useFormValidation(schema));
act(() => {
result.current.validateForm({ name: '' });
});
expect(result.current.errors.name).toBe('Name is required');
expect(result.current.isValid).toBe(false);
});
it('validates single field', () => {
const schema = {
email: [email('Invalid email')],
};
const { result } = renderHook(() => useFormValidation(schema));
const error = result.current.validateField('email', 'invalid');
expect(error).toBe('Invalid email');
});
it('returns undefined for valid field', () => {
const schema = {
email: [email('Invalid email')],
};
const { result } = renderHook(() => useFormValidation(schema));
const error = result.current.validateField('email', 'test@example.com');
expect(error).toBeUndefined();
});
it('sets error manually', () => {
const { result } = renderHook(() => useFormValidation({}));
act(() => {
result.current.setError('field', 'Custom error');
});
expect(result.current.errors.field).toBe('Custom error');
});
it('clears single error', () => {
const { result } = renderHook(() => useFormValidation({}));
act(() => {
result.current.setError('field', 'Error');
result.current.clearError('field');
});
expect(result.current.errors.field).toBeUndefined();
});
it('clears all errors', () => {
const { result } = renderHook(() => useFormValidation({}));
act(() => {
result.current.setError('field1', 'Error 1');
result.current.setError('field2', 'Error 2');
result.current.clearAllErrors();
});
expect(result.current.errors).toEqual({});
});
it('getError returns correct error', () => {
const { result } = renderHook(() => useFormValidation({}));
act(() => {
result.current.setError('field', 'Test error');
});
expect(result.current.getError('field')).toBe('Test error');
});
it('hasError returns true when error exists', () => {
const { result } = renderHook(() => useFormValidation({}));
act(() => {
result.current.setError('field', 'Error');
});
expect(result.current.hasError('field')).toBe(true);
});
it('hasError returns false when no error', () => {
const { result } = renderHook(() => useFormValidation({}));
expect(result.current.hasError('field')).toBe(false);
});
});
describe('required validator', () => {
it('returns error for undefined', () => {
const validator = required('Required');
expect(validator(undefined)).toBe('Required');
});
it('returns error for null', () => {
const validator = required('Required');
expect(validator(null)).toBe('Required');
});
it('returns error for empty string', () => {
const validator = required('Required');
expect(validator('')).toBe('Required');
});
it('returns error for empty array', () => {
const validator = required('Required');
expect(validator([])).toBe('Required');
});
it('returns undefined for valid value', () => {
const validator = required('Required');
expect(validator('value')).toBeUndefined();
});
it('uses default message', () => {
const validator = required();
expect(validator('')).toBe('This field is required');
});
});
describe('email validator', () => {
it('returns error for invalid email', () => {
const validator = email('Invalid');
expect(validator('notanemail')).toBe('Invalid');
});
it('returns undefined for valid email', () => {
const validator = email('Invalid');
expect(validator('test@example.com')).toBeUndefined();
});
it('returns undefined for empty value', () => {
const validator = email('Invalid');
expect(validator('')).toBeUndefined();
});
});
describe('minLength validator', () => {
it('returns error when too short', () => {
const validator = minLength(5, 'Too short');
expect(validator('ab')).toBe('Too short');
});
it('returns undefined when long enough', () => {
const validator = minLength(5, 'Too short');
expect(validator('abcde')).toBeUndefined();
});
it('uses default message', () => {
const validator = minLength(5);
expect(validator('ab')).toBe('Must be at least 5 characters');
});
});
describe('maxLength validator', () => {
it('returns error when too long', () => {
const validator = maxLength(3, 'Too long');
expect(validator('abcd')).toBe('Too long');
});
it('returns undefined when short enough', () => {
const validator = maxLength(3, 'Too long');
expect(validator('abc')).toBeUndefined();
});
it('uses default message', () => {
const validator = maxLength(3);
expect(validator('abcd')).toBe('Must be at most 3 characters');
});
});
describe('minValue validator', () => {
it('returns error when below min', () => {
const validator = minValue(10, 'Too small');
expect(validator(5)).toBe('Too small');
});
it('returns undefined when at or above min', () => {
const validator = minValue(10, 'Too small');
expect(validator(10)).toBeUndefined();
});
it('returns undefined for null/undefined', () => {
const validator = minValue(10);
expect(validator(undefined as unknown as number)).toBeUndefined();
});
});
describe('maxValue validator', () => {
it('returns error when above max', () => {
const validator = maxValue(10, 'Too big');
expect(validator(15)).toBe('Too big');
});
it('returns undefined when at or below max', () => {
const validator = maxValue(10, 'Too big');
expect(validator(10)).toBeUndefined();
});
});
describe('pattern validator', () => {
it('returns error when pattern does not match', () => {
const validator = pattern(/^[a-z]+$/, 'Letters only');
expect(validator('abc123')).toBe('Letters only');
});
it('returns undefined when pattern matches', () => {
const validator = pattern(/^[a-z]+$/, 'Letters only');
expect(validator('abc')).toBeUndefined();
});
});
describe('url validator', () => {
it('returns error for invalid URL', () => {
const validator = url('Invalid URL');
expect(validator('not-a-url')).toBe('Invalid URL');
});
it('returns undefined for valid URL', () => {
const validator = url('Invalid URL');
expect(validator('https://example.com')).toBeUndefined();
});
it('returns undefined for empty value', () => {
const validator = url('Invalid URL');
expect(validator('')).toBeUndefined();
});
});
describe('matches validator', () => {
it('returns error when fields do not match', () => {
const validator = matches('password', 'Must match');
expect(validator('abc', { password: 'xyz' })).toBe('Must match');
});
it('returns undefined when fields match', () => {
const validator = matches('password', 'Must match');
expect(validator('abc', { password: 'abc' })).toBeUndefined();
});
it('returns undefined when no form data', () => {
const validator = matches('password');
expect(validator('abc')).toBeUndefined();
});
});
describe('phone validator', () => {
it('returns error for invalid phone', () => {
const validator = phone('Invalid phone');
expect(validator('abc')).toBe('Invalid phone');
});
it('returns undefined for valid phone', () => {
const validator = phone('Invalid phone');
// Use a phone format that matches the regex: /^[+]?[(]?[0-9]{1,4}[)]?[-\s./0-9]*$/
expect(validator('+15551234567')).toBeUndefined();
});
it('returns undefined for empty value', () => {
const validator = phone('Invalid phone');
expect(validator('')).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock apiClient
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
useLocations,
useLocation,
useCreateLocation,
useUpdateLocation,
useDeleteLocation,
useSetPrimaryLocation,
useSetLocationActive,
} from '../useLocations';
import apiClient from '../../api/client';
// Create wrapper
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
};
describe('useLocations hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useLocations', () => {
it('fetches locations and returns data', async () => {
const mockLocations = [
{
id: 1,
name: 'Main Office',
city: 'Denver',
state: 'CO',
is_active: true,
is_primary: true,
display_order: 0,
resource_count: 5,
service_count: 10,
},
{
id: 2,
name: 'Branch Office',
city: 'Boulder',
state: 'CO',
is_active: true,
is_primary: false,
display_order: 1,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockLocations });
const { result } = renderHook(() => useLocations(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/locations/');
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0]).toEqual(expect.objectContaining({
id: 1,
name: 'Main Office',
is_primary: true,
}));
});
it('fetches all locations when includeInactive is true', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
renderHook(() => useLocations({ includeInactive: true }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/locations/?include_inactive=true');
});
});
});
describe('useLocation', () => {
it('fetches a single location by id', async () => {
const mockLocation = {
id: 1,
name: 'Main Office',
is_active: true,
is_primary: true,
display_order: 0,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockLocation });
const { result } = renderHook(() => useLocation(1), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/locations/1/');
expect(result.current.data?.name).toBe('Main Office');
});
it('does not fetch when id is undefined', async () => {
renderHook(() => useLocation(undefined), {
wrapper: createWrapper(),
});
expect(apiClient.get).not.toHaveBeenCalled();
});
});
describe('useCreateLocation', () => {
it('creates location with correct data', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
const { result } = renderHook(() => useCreateLocation(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
name: 'New Location',
city: 'Denver',
state: 'CO',
});
});
expect(apiClient.post).toHaveBeenCalledWith('/locations/', {
name: 'New Location',
city: 'Denver',
state: 'CO',
});
});
});
describe('useUpdateLocation', () => {
it('updates location with mapped fields', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateLocation(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: 1,
updates: {
name: 'Updated Office',
city: 'Boulder',
},
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/locations/1/', {
name: 'Updated Office',
city: 'Boulder',
});
});
});
describe('useDeleteLocation', () => {
it('deletes location by id', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const { result } = renderHook(() => useDeleteLocation(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(1);
});
expect(apiClient.delete).toHaveBeenCalledWith('/locations/1/');
});
});
describe('useSetPrimaryLocation', () => {
it('sets location as primary', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_primary: true } });
const { result } = renderHook(() => useSetPrimaryLocation(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(1);
});
expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_primary/');
});
});
describe('useSetLocationActive', () => {
it('activates location', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_active: true } });
const { result } = renderHook(() => useSetLocationActive(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ id: 1, isActive: true });
});
expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_active/', {
is_active: true,
});
});
it('deactivates location', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_active: false } });
const { result } = renderHook(() => useSetLocationActive(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ id: 1, isActive: false });
});
expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_active/', {
is_active: false,
});
});
});
});

View File

@@ -67,6 +67,9 @@ describe('useResources hooks', () => {
maxConcurrentEvents: 2,
savedLaneCount: undefined,
userCanEditSchedule: false,
locationId: null,
locationName: null,
isMobile: false,
});
});

View File

@@ -0,0 +1,599 @@
/**
* Billing Admin Hooks
*
* Hooks for managing plans, features, and addons via the billing admin API.
* These use the versioned billing system that supports grandfathering.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
// =============================================================================
// Types
// =============================================================================
export interface Feature {
id: number;
code: string;
name: string;
description: string;
feature_type: 'boolean' | 'integer';
}
export interface FeatureCreate {
code: string;
name: string;
description?: string;
feature_type: 'boolean' | 'integer';
}
export interface PlanFeature {
id: number;
feature: Feature;
bool_value: boolean | null;
int_value: number | null;
value: boolean | number | null;
}
export interface PlanFeatureWrite {
feature_code: string;
bool_value?: boolean | null;
int_value?: number | null;
}
export interface PlanVersion {
id: number;
plan: Plan;
version: number;
name: string;
is_public: boolean;
is_legacy: boolean;
starts_at: string | null;
ends_at: string | null;
price_monthly_cents: number;
price_yearly_cents: number;
// Transaction fees
transaction_fee_percent: string; // Decimal comes as string from backend
transaction_fee_fixed_cents: number;
// Trial
trial_days: number;
// Communication pricing (costs when feature is enabled)
sms_price_per_message_cents: number;
masked_calling_price_per_minute_cents: number;
proxy_number_monthly_fee_cents: number;
// Credit settings
default_auto_reload_enabled: boolean;
default_auto_reload_threshold_cents: number;
default_auto_reload_amount_cents: number;
// Display settings
is_most_popular: boolean;
show_price: boolean;
marketing_features: string[];
// Stripe
stripe_product_id: string;
stripe_price_id_monthly: string;
stripe_price_id_yearly: string;
is_available: boolean;
// Features (via PlanFeature M2M - permissions/limits stored here)
features: PlanFeature[];
subscriber_count: number;
created_at: string;
}
export interface PlanVersionCreate {
plan_code: string;
name: string;
is_public?: boolean;
starts_at?: string | null;
ends_at?: string | null;
price_monthly_cents: number;
price_yearly_cents?: number;
// Transaction fees
transaction_fee_percent?: number;
transaction_fee_fixed_cents?: number;
// Trial
trial_days?: number;
// Communication pricing (costs when feature is enabled)
sms_price_per_message_cents?: number;
masked_calling_price_per_minute_cents?: number;
proxy_number_monthly_fee_cents?: number;
// Credit settings
default_auto_reload_enabled?: boolean;
default_auto_reload_threshold_cents?: number;
default_auto_reload_amount_cents?: number;
// Display settings
is_most_popular?: boolean;
show_price?: boolean;
marketing_features?: string[];
// Stripe
stripe_product_id?: string;
stripe_price_id_monthly?: string;
stripe_price_id_yearly?: string;
// Features (M2M via PlanFeature - permissions/limits)
features?: PlanFeatureWrite[];
}
export interface PlanVersionUpdate {
name?: string;
is_public?: boolean;
is_legacy?: boolean;
starts_at?: string | null;
ends_at?: string | null;
price_monthly_cents?: number;
price_yearly_cents?: number;
// Transaction fees
transaction_fee_percent?: number;
transaction_fee_fixed_cents?: number;
// Trial
trial_days?: number;
// Communication pricing (costs when feature is enabled)
sms_price_per_message_cents?: number;
masked_calling_price_per_minute_cents?: number;
proxy_number_monthly_fee_cents?: number;
// Credit settings
default_auto_reload_enabled?: boolean;
default_auto_reload_threshold_cents?: number;
default_auto_reload_amount_cents?: number;
// Display settings
is_most_popular?: boolean;
show_price?: boolean;
marketing_features?: string[];
// Stripe
stripe_product_id?: string;
stripe_price_id_monthly?: string;
stripe_price_id_yearly?: string;
// Features (M2M via PlanFeature - permissions/limits)
features?: PlanFeatureWrite[];
}
export interface Plan {
id: number;
code: string;
name: string;
description: string;
display_order: number;
is_active: boolean;
max_pages: number;
allow_custom_domains: boolean;
max_custom_domains: number;
}
export interface PlanWithVersions extends Plan {
versions: PlanVersion[];
active_version: PlanVersion | null;
total_subscribers: number;
}
export interface PlanCreate {
code: string;
name: string;
description?: string;
display_order?: number;
is_active?: boolean;
max_pages?: number;
allow_custom_domains?: boolean;
max_custom_domains?: number;
}
export interface AddOnFeature {
id: number;
feature: Feature;
bool_value: boolean | null;
int_value: number | null;
value: boolean | number | null;
}
export interface AddOnFeatureWrite {
feature_code: string;
bool_value?: boolean | null;
int_value?: number | null;
}
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;
features: AddOnFeature[];
}
export interface AddOnProductCreate {
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;
features?: AddOnFeatureWrite[];
}
// Grandfathering response when updating a version with subscribers
export interface GrandfatheringResponse {
message: string;
old_version: PlanVersion;
new_version: PlanVersion;
}
// =============================================================================
// Feature Hooks
// =============================================================================
// Note: Billing admin endpoints are at /billing/admin/ not /api/billing/admin/
const BILLING_BASE = '/billing/admin';
export const useFeatures = () => {
return useQuery<Feature[]>({
queryKey: ['billingAdmin', 'features'],
queryFn: async () => {
const { data } = await apiClient.get(`${BILLING_BASE}/features/`);
return data;
},
staleTime: 5 * 60 * 1000,
});
};
export const useCreateFeature = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (feature: FeatureCreate) => {
const { data } = await apiClient.post(`${BILLING_BASE}/features/`, feature);
return data as Feature;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'features'] });
},
});
};
export const useUpdateFeature = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...updates }: Partial<FeatureCreate> & { id: number }) => {
const { data } = await apiClient.patch(`${BILLING_BASE}/features/${id}/`, updates);
return data as Feature;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'features'] });
},
});
};
export const useDeleteFeature = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await apiClient.delete(`${BILLING_BASE}/features/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'features'] });
},
});
};
// =============================================================================
// Plan Hooks
// =============================================================================
export const usePlans = () => {
return useQuery<PlanWithVersions[]>({
queryKey: ['billingAdmin', 'plans'],
queryFn: async () => {
const { data } = await apiClient.get(`${BILLING_BASE}/plans/`);
return data;
},
staleTime: 5 * 60 * 1000,
});
};
export const usePlan = (id: number) => {
return useQuery<PlanWithVersions>({
queryKey: ['billingAdmin', 'plans', id],
queryFn: async () => {
const { data } = await apiClient.get(`${BILLING_BASE}/plans/${id}/`);
return data;
},
enabled: !!id,
});
};
export const useCreatePlan = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (plan: PlanCreate) => {
const { data } = await apiClient.post(`${BILLING_BASE}/plans/`, plan);
return data as Plan;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'plans'] });
},
});
};
export const useUpdatePlan = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...updates }: Partial<PlanCreate> & { id: number }) => {
const { data } = await apiClient.patch(`${BILLING_BASE}/plans/${id}/`, updates);
return data as Plan;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'plans'] });
},
});
};
export const useDeletePlan = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await apiClient.delete(`${BILLING_BASE}/plans/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'plans'] });
},
});
};
// =============================================================================
// Plan Version Hooks
// =============================================================================
export const usePlanVersions = () => {
return useQuery<PlanVersion[]>({
queryKey: ['billingAdmin', 'planVersions'],
queryFn: async () => {
const { data } = await apiClient.get(`${BILLING_BASE}/plan-versions/`);
return data;
},
staleTime: 5 * 60 * 1000,
});
};
export const useCreatePlanVersion = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (version: PlanVersionCreate) => {
const { data } = await apiClient.post(`${BILLING_BASE}/plan-versions/`, version);
return data as PlanVersion;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billingAdmin'] });
},
});
};
/**
* Update a plan version.
*
* IMPORTANT: If the version has active subscribers, this will:
* 1. Mark the current version as legacy
* 2. Create a new version with the updates
* 3. Return a GrandfatheringResponse with both versions
*
* Existing subscribers keep their current version (grandfathering).
* New subscribers will get the new version.
*/
export const useUpdatePlanVersion = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
...updates
}: PlanVersionUpdate & { id: number }): Promise<PlanVersion | GrandfatheringResponse> => {
const { data } = await apiClient.patch(`${BILLING_BASE}/plan-versions/${id}/`, updates);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billingAdmin'] });
},
});
};
export const useDeletePlanVersion = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await apiClient.delete(`${BILLING_BASE}/plan-versions/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billingAdmin'] });
},
});
};
export const useMarkVersionLegacy = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const { data } = await apiClient.post(`${BILLING_BASE}/plan-versions/${id}/mark_legacy/`);
return data as PlanVersion;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billingAdmin'] });
},
});
};
// Force update response type
export interface ForceUpdateResponse {
message: string;
version: PlanVersion;
affected_count: number;
affected_businesses: string[];
}
// Force update confirmation response (when confirm not provided)
export interface ForceUpdateConfirmRequired {
detail: string;
warning: string;
subscriber_count: number;
requires_confirm: true;
}
/**
* DANGEROUS: Force update a plan version in place, affecting all subscribers.
*
* This bypasses grandfathering and modifies the plan for ALL existing subscribers.
* Only superusers can use this action.
*
* Usage:
* 1. Call without confirm to get subscriber count and warning
* 2. Show warning to user
* 3. Call with confirm: true to execute
*/
export const useForceUpdatePlanVersion = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
confirm,
...updates
}: PlanVersionUpdate & { id: number; confirm?: boolean }): Promise<
ForceUpdateResponse | ForceUpdateConfirmRequired
> => {
const { data } = await apiClient.post(
`${BILLING_BASE}/plan-versions/${id}/force_update/`,
{ ...updates, confirm }
);
return data;
},
onSuccess: (data) => {
// Only invalidate if it was a confirmed update (not just checking)
if ('version' in data) {
queryClient.invalidateQueries({ queryKey: ['billingAdmin'] });
}
},
});
};
/**
* Check if response is a confirmation requirement
*/
export const isForceUpdateConfirmRequired = (
response: ForceUpdateResponse | ForceUpdateConfirmRequired
): response is ForceUpdateConfirmRequired => {
return 'requires_confirm' in response && response.requires_confirm === true;
};
export const usePlanVersionSubscribers = (id: number) => {
return useQuery({
queryKey: ['billingAdmin', 'planVersions', id, 'subscribers'],
queryFn: async () => {
const { data } = await apiClient.get(`${BILLING_BASE}/plan-versions/${id}/subscribers/`);
return data as {
version: string;
subscriber_count: number;
subscribers: Array<{
business_id: number;
business_name: string;
status: string;
started_at: string;
}>;
};
},
enabled: !!id,
});
};
// =============================================================================
// Add-on Product Hooks
// =============================================================================
export const useAddOnProducts = () => {
return useQuery<AddOnProduct[]>({
queryKey: ['billingAdmin', 'addons'],
queryFn: async () => {
const { data } = await apiClient.get(`${BILLING_BASE}/addons/`);
return data;
},
staleTime: 5 * 60 * 1000,
});
};
export const useCreateAddOnProduct = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (addon: AddOnProductCreate) => {
const { data } = await apiClient.post(`${BILLING_BASE}/addons/`, addon);
return data as AddOnProduct;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'addons'] });
},
});
};
export const useUpdateAddOnProduct = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...updates }: Partial<AddOnProductCreate> & { id: number }) => {
const { data } = await apiClient.patch(`${BILLING_BASE}/addons/${id}/`, updates);
return data as AddOnProduct;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'addons'] });
},
});
};
export const useDeleteAddOnProduct = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await apiClient.delete(`${BILLING_BASE}/addons/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'addons'] });
},
});
};
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Check if a mutation response is a grandfathering response
*/
export const isGrandfatheringResponse = (
response: PlanVersion | GrandfatheringResponse
): response is GrandfatheringResponse => {
return 'message' in response && 'old_version' in response && 'new_version' in response;
};
/**
* Format cents to dollars string
*/
export const formatCentsToDollars = (cents: number): string => {
return (cents / 100).toFixed(2);
};
/**
* Convert dollars to cents
*/
export const dollarsToCents = (dollars: number): number => {
return Math.round(dollars * 100);
};

View File

@@ -0,0 +1,372 @@
/**
* Billing Plans Hooks
*
* Provides access to the billing system's plans, features, and add-ons.
* Used by platform admin for managing tenant subscriptions.
*/
import { useQuery } from '@tanstack/react-query';
import apiClient from '../api/client';
// Feature from billing system - the SINGLE SOURCE OF TRUTH
export interface BillingFeature {
id: number;
code: string;
name: string;
description: string;
feature_type: 'boolean' | 'integer';
// Dynamic feature management
category: 'limits' | 'payments' | 'communication' | 'customization' | 'plugins' | 'advanced' | 'scheduling' | 'enterprise';
tenant_field_name: string; // Corresponding field on Tenant model
display_order: number;
is_overridable: boolean;
depends_on: number | null; // ID of parent feature
depends_on_code: string | null; // Code of parent feature (for convenience)
}
// Category metadata for display
export const FEATURE_CATEGORY_META: Record<BillingFeature['category'], { label: string; order: number }> = {
limits: { label: 'Limits', order: 0 },
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 },
};
// Plan feature with value
export interface BillingPlanFeature {
id: number;
feature: BillingFeature;
bool_value: boolean | null;
int_value: number | null;
value: boolean | number | null;
}
// Plan (logical grouping)
export interface BillingPlan {
id: number;
code: string;
name: string;
description: string;
display_order: number;
is_active: boolean;
max_pages: number;
allow_custom_domains: boolean;
max_custom_domains: number;
}
// Plan version (specific offer with pricing and features)
export interface BillingPlanVersion {
id: number;
plan: BillingPlan;
version: number;
name: string;
is_public: boolean;
is_legacy: boolean;
starts_at: string | null;
ends_at: string | null;
price_monthly_cents: number;
price_yearly_cents: number;
transaction_fee_percent: string;
transaction_fee_fixed_cents: number;
trial_days: number;
sms_price_per_message_cents: number;
masked_calling_price_per_minute_cents: number;
proxy_number_monthly_fee_cents: number;
default_auto_reload_enabled: boolean;
default_auto_reload_threshold_cents: number;
default_auto_reload_amount_cents: number;
is_most_popular: boolean;
show_price: boolean;
marketing_features: string[];
stripe_product_id: string;
stripe_price_id_monthly: string;
stripe_price_id_yearly: string;
is_available: boolean;
features: BillingPlanFeature[];
subscriber_count?: number;
created_at: string;
}
// Plan with all versions
export interface BillingPlanWithVersions {
id: number;
code: string;
name: string;
description: string;
display_order: number;
is_active: boolean;
max_pages: number;
allow_custom_domains: boolean;
max_custom_domains: number;
versions: BillingPlanVersion[];
active_version: BillingPlanVersion | null;
total_subscribers: number;
}
// Add-on product
export interface BillingAddOn {
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;
features: BillingPlanFeature[];
}
/**
* Hook to get all billing plans with their versions (admin view)
*/
export const useBillingPlans = () => {
return useQuery<BillingPlanWithVersions[]>({
queryKey: ['billingPlans'],
queryFn: async () => {
const { data } = await apiClient.get('/billing/admin/plans/');
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to get the public plan catalog (available versions only)
*/
export const useBillingPlanCatalog = () => {
return useQuery<BillingPlanVersion[]>({
queryKey: ['billingPlanCatalog'],
queryFn: async () => {
const { data } = await apiClient.get('/billing/plans/');
return data;
},
staleTime: 5 * 60 * 1000,
});
};
/**
* Hook to get all features
*/
export const useBillingFeatures = () => {
return useQuery<BillingFeature[]>({
queryKey: ['billingFeatures'],
queryFn: async () => {
const { data } = await apiClient.get('/billing/admin/features/');
return data;
},
staleTime: 10 * 60 * 1000, // 10 minutes (features rarely change)
});
};
/**
* Hook to get available add-ons
*/
export const useBillingAddOns = () => {
return useQuery<BillingAddOn[]>({
queryKey: ['billingAddOns'],
queryFn: async () => {
const { data } = await apiClient.get('/billing/addons/');
return data;
},
staleTime: 5 * 60 * 1000,
});
};
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Get a feature value from a plan version's features array
*/
export function getFeatureValue(
features: BillingPlanFeature[],
featureCode: string
): boolean | number | null {
const feature = features.find(f => f.feature.code === featureCode);
if (!feature) return null;
return feature.value;
}
/**
* Get a boolean feature value (defaults to false if not found)
*/
export function getBooleanFeature(
features: BillingPlanFeature[],
featureCode: string
): boolean {
const value = getFeatureValue(features, featureCode);
return typeof value === 'boolean' ? value : false;
}
/**
* Get an integer feature value (defaults to 0 if not found, null means unlimited)
*/
export function getIntegerFeature(
features: BillingPlanFeature[],
featureCode: string
): number | null {
const value = getFeatureValue(features, featureCode);
if (value === null || value === undefined) return null; // Unlimited
return typeof value === 'number' ? value : 0;
}
/**
* Convert a plan version's features to a flat object for form state
* Maps feature codes to their values
*/
export function planFeaturesToFormState(
planVersion: BillingPlanVersion | null
): Record<string, boolean | number | null> {
if (!planVersion) return {};
const state: Record<string, boolean | number | null> = {};
for (const pf of planVersion.features) {
state[pf.feature.code] = pf.value;
}
return state;
}
/**
* Map old tier names to new plan codes
*/
export const TIER_TO_PLAN_CODE: Record<string, string> = {
FREE: 'free',
STARTER: 'starter',
GROWTH: 'growth',
PROFESSIONAL: 'pro', // Old name -> new code
PRO: 'pro',
ENTERPRISE: 'enterprise',
};
/**
* Map new plan codes to display names
*/
export const PLAN_CODE_TO_NAME: Record<string, string> = {
free: 'Free',
starter: 'Starter',
growth: 'Growth',
pro: 'Pro',
enterprise: 'Enterprise',
};
/**
* Get the active plan version for a given plan code
*/
export function getActivePlanVersion(
plans: BillingPlanWithVersions[],
planCode: string
): BillingPlanVersion | null {
const plan = plans.find(p => p.code === planCode);
return plan?.active_version || null;
}
/**
* Feature code mapping from old permission names to new feature codes
*/
export const PERMISSION_TO_FEATURE_CODE: Record<string, string> = {
// Communication
can_use_sms_reminders: 'sms_enabled',
can_use_masked_phone_numbers: 'masked_calling_enabled',
// Platform
can_api_access: 'api_access',
can_use_custom_domain: 'custom_domain',
can_white_label: 'white_label',
// Features
can_accept_payments: 'payment_processing',
can_use_mobile_app: 'mobile_app_access',
advanced_reporting: 'advanced_reporting',
priority_support: 'priority_support',
dedicated_support: 'dedicated_account_manager',
// Limits (integer features)
max_users: 'max_users',
max_resources: 'max_resources',
max_locations: 'max_locations',
};
/**
* Convert plan features to legacy permission format for backward compatibility
*/
export function planFeaturesToLegacyPermissions(
planVersion: BillingPlanVersion | null
): Record<string, boolean | number> {
if (!planVersion) return {};
const permissions: Record<string, boolean | number> = {};
// Map features to legacy permission names
for (const pf of planVersion.features) {
const code = pf.feature.code;
const value = pf.value;
// Direct feature code
permissions[code] = value as boolean | number;
// Also add with legacy naming for backward compatibility
switch (code) {
case 'sms_enabled':
permissions.can_use_sms_reminders = value as boolean;
break;
case 'masked_calling_enabled':
permissions.can_use_masked_phone_numbers = value as boolean;
break;
case 'api_access':
permissions.can_api_access = value as boolean;
permissions.can_connect_to_api = value as boolean;
break;
case 'custom_domain':
permissions.can_use_custom_domain = value as boolean;
break;
case 'white_label':
permissions.can_white_label = value as boolean;
break;
case 'remove_branding':
permissions.can_white_label = permissions.can_white_label || (value as boolean);
break;
case 'payment_processing':
permissions.can_accept_payments = value as boolean;
break;
case 'mobile_app_access':
permissions.can_use_mobile_app = value as boolean;
break;
case 'advanced_reporting':
permissions.advanced_reporting = value as boolean;
break;
case 'priority_support':
permissions.priority_support = value as boolean;
break;
case 'dedicated_account_manager':
permissions.dedicated_support = value as boolean;
break;
case 'integrations_enabled':
permissions.can_use_webhooks = value as boolean;
permissions.can_use_calendar_sync = value as boolean;
break;
case 'team_permissions':
permissions.can_require_2fa = value as boolean;
break;
case 'audit_logs':
permissions.can_download_logs = value as boolean;
break;
case 'custom_branding':
permissions.can_customize_booking_page = value as boolean;
break;
case 'recurring_appointments':
permissions.can_book_repeated_events = value as boolean;
break;
}
}
return permissions;
}

View File

@@ -1,8 +1,27 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import api from '../api/client';
export interface PublicService {
id: number;
name: string;
description: string;
duration: number;
price_cents: number;
deposit_amount_cents: number | null;
photos: string[] | null;
}
export interface PublicBusinessInfo {
name: string;
logo_url: string | null;
primary_color: string;
secondary_color: string | null;
service_selection_heading: string;
service_selection_subheading: string;
}
export const usePublicServices = () => {
return useQuery({
return useQuery<PublicService[]>({
queryKey: ['publicServices'],
queryFn: async () => {
const response = await api.get('/public/services/');
@@ -12,8 +31,51 @@ export const usePublicServices = () => {
});
};
export const usePublicAvailability = (serviceId: string, date: string) => {
return useQuery({
export const usePublicBusinessInfo = () => {
return useQuery<PublicBusinessInfo>({
queryKey: ['publicBusinessInfo'],
queryFn: async () => {
const response = await api.get('/public/business/');
return response.data;
},
retry: false,
});
};
export interface AvailabilitySlot {
time: string; // ISO datetime string
display: string; // Human-readable time like "9:00 AM"
available: boolean;
}
export interface AvailabilityResponse {
date: string;
service_id: number;
is_open: boolean;
business_hours?: {
start: string;
end: string;
};
slots: AvailabilitySlot[];
business_timezone?: string;
timezone_display_mode?: 'business' | 'viewer';
}
export interface BusinessHoursDay {
date: string;
is_open: boolean;
hours: {
start: string;
end: string;
} | null;
}
export interface BusinessHoursResponse {
dates: BusinessHoursDay[];
}
export const usePublicAvailability = (serviceId: number | undefined, date: string | undefined) => {
return useQuery<AvailabilityResponse>({
queryKey: ['publicAvailability', serviceId, date],
queryFn: async () => {
const response = await api.get(`/public/availability/?service_id=${serviceId}&date=${date}`);
@@ -23,6 +85,17 @@ export const usePublicAvailability = (serviceId: string, date: string) => {
});
};
export const usePublicBusinessHours = (startDate: string | undefined, endDate: string | undefined) => {
return useQuery<BusinessHoursResponse>({
queryKey: ['publicBusinessHours', startDate, endDate],
queryFn: async () => {
const response = await api.get(`/public/business-hours/?start_date=${startDate}&end_date=${endDate}`);
return response.data;
},
enabled: !!startDate && !!endDate,
});
};
export const useCreateBooking = () => {
return useMutation({
mutationFn: async (data: any) => {

View File

@@ -38,7 +38,7 @@ export const useCurrentBusiness = () => {
timezone: data.timezone || 'America/New_York',
timezoneDisplayMode: data.timezone_display_mode || 'business',
whitelabelEnabled: data.whitelabel_enabled,
plan: data.tier, // Map tier to plan
plan: data.plan,
status: data.status,
joinedAt: data.created_at ? new Date(data.created_at) : undefined,
resourcesCanReschedule: data.resources_can_reschedule,
@@ -48,6 +48,9 @@ export const useCurrentBusiness = () => {
initialSetupComplete: data.initial_setup_complete,
websitePages: data.website_pages || {},
customerDashboardContent: data.customer_dashboard_content || [],
// Booking page customization
serviceSelectionHeading: data.service_selection_heading || 'Choose your experience',
serviceSelectionSubheading: data.service_selection_subheading || 'Select a service to begin your booking.',
paymentsEnabled: data.payments_enabled ?? false,
// Platform-controlled permissions
canManageOAuthCredentials: data.can_manage_oauth_credentials || false,
@@ -69,6 +72,7 @@ export const useCurrentBusiness = () => {
pos_system: false,
mobile_app: false,
contracts: false,
multi_location: false,
},
};
},
@@ -118,6 +122,12 @@ export const useUpdateBusiness = () => {
if (updates.customerDashboardContent !== undefined) {
backendData.customer_dashboard_content = updates.customerDashboardContent;
}
if (updates.serviceSelectionHeading !== undefined) {
backendData.service_selection_heading = updates.serviceSelectionHeading;
}
if (updates.serviceSelectionSubheading !== undefined) {
backendData.service_selection_subheading = updates.serviceSelectionSubheading;
}
const { data } = await apiClient.patch('/business/current/update/', backendData);
return data;

View File

@@ -127,6 +127,7 @@ export const FEATURE_CODES = {
MAX_RESOURCES: 'max_resources',
MAX_EVENT_TYPES: 'max_event_types',
MAX_CALENDARS_CONNECTED: 'max_calendars_connected',
MAX_PUBLIC_PAGES: 'max_public_pages',
} as const;
export type FeatureCode = (typeof FEATURE_CODES)[keyof typeof FEATURE_CODES];

View File

@@ -0,0 +1,153 @@
/**
* Location Management Hooks
*
* Provides hooks for managing business locations in a multi-location setup.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Location } from '../types';
interface LocationFilters {
includeInactive?: boolean;
}
/**
* Hook to fetch locations with optional inactive filter
*/
export const useLocations = (filters?: LocationFilters) => {
return useQuery<Location[]>({
queryKey: ['locations', filters],
queryFn: async () => {
let url = '/locations/';
if (filters?.includeInactive) {
url += '?include_inactive=true';
}
const { data } = await apiClient.get(url);
return data;
},
});
};
/**
* Hook to get a single location by ID
*/
export const useLocation = (id: number | undefined) => {
return useQuery<Location>({
queryKey: ['locations', id],
queryFn: async () => {
const { data } = await apiClient.get(`/locations/${id}/`);
return data;
},
enabled: id !== undefined,
});
};
/**
* Hook to create a new location
*/
export const useCreateLocation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (locationData: Partial<Location>) => {
const { data } = await apiClient.post('/locations/', locationData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] });
},
});
};
/**
* Hook to update a location
*/
export const useUpdateLocation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: number; updates: Partial<Location> }) => {
const { data } = await apiClient.patch(`/locations/${id}/`, updates);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] });
},
});
};
/**
* Hook to delete a location
*/
export const useDeleteLocation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await apiClient.delete(`/locations/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] });
},
});
};
/**
* Hook to set a location as primary
*/
export const useSetPrimaryLocation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const { data } = await apiClient.post(`/locations/${id}/set_primary/`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] });
},
});
};
/**
* Hook to activate or deactivate a location
*/
export const useSetLocationActive = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, isActive }: { id: number; isActive: boolean }) => {
const { data } = await apiClient.post(`/locations/${id}/set_active/`, {
is_active: isActive,
});
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] });
},
});
};
/**
* Hook to get only active locations (convenience wrapper)
*/
export const useActiveLocations = () => {
return useLocations();
};
/**
* Hook to get all locations including inactive
*/
export const useAllLocations = () => {
return useLocations({ includeInactive: true });
};
/**
* Hook to get the primary location
*/
export const usePrimaryLocation = () => {
const { data: locations, ...rest } = useLocations();
const primaryLocation = locations?.find(loc => loc.is_primary);
return { data: primaryLocation, locations, ...rest };
};

View File

@@ -93,6 +93,7 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
pos_system: 'POS System',
mobile_app: 'Mobile App',
contracts: 'Contracts',
multi_location: 'Multiple Locations',
};
/**
@@ -115,4 +116,5 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
pos_system: 'Process in-person payments with Point of Sale',
mobile_app: 'Access SmoothSchedule on mobile devices',
contracts: 'Create and manage contracts with customers',
multi_location: 'Manage multiple business locations with separate resources and services',
};

View File

@@ -11,6 +11,7 @@ import {
updateBusiness,
createBusiness,
deleteBusiness,
changeBusinessPlan,
PlatformBusinessUpdate,
PlatformBusinessCreate,
getTenantInvitations,
@@ -73,6 +74,22 @@ export const useUpdateBusiness = () => {
});
};
/**
* Hook to change a business's subscription plan (platform admin only)
*/
export const useChangeBusinessPlan = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ businessId, planCode }: { businessId: number; planCode: string }) =>
changeBusinessPlan(businessId, planCode),
onSuccess: () => {
// Invalidate and refetch businesses list
queryClient.invalidateQueries({ queryKey: ['platform', 'businesses'] });
},
});
};
/**
* Hook to create a new business (platform admin only)
*/

View File

@@ -38,7 +38,6 @@ export interface SubscriptionPlan {
stripe_price_id: string;
price_monthly: string | null;
price_yearly: string | null;
business_tier: string;
features: string[];
limits: Record<string, any>;
permissions: Record<string, boolean>;
@@ -71,7 +70,6 @@ export interface SubscriptionPlanCreate {
plan_type?: 'base' | 'addon';
price_monthly?: number | null;
price_yearly?: number | null;
business_tier?: string;
features?: string[];
limits?: Record<string, any>;
permissions?: Record<string, boolean>;

View File

@@ -0,0 +1,156 @@
/**
* Public Plans Hook
*
* Fetches public plans from the billing API for the marketing pricing page.
* This endpoint doesn't require authentication.
*/
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { API_BASE_URL } from '../api/config';
// =============================================================================
// Types
// =============================================================================
export interface Feature {
id: number;
code: string;
name: string;
description: string;
feature_type: 'boolean' | 'integer';
}
export interface PlanFeature {
id: number;
feature: Feature;
bool_value: boolean | null;
int_value: number | null;
value: boolean | number | null;
}
export interface Plan {
id: number;
code: string;
name: string;
description: string;
display_order: number;
is_active: boolean;
}
export interface PublicPlanVersion {
id: number;
plan: Plan;
version: number;
name: string;
is_public: boolean;
is_legacy: boolean;
price_monthly_cents: number;
price_yearly_cents: number;
transaction_fee_percent: string;
transaction_fee_fixed_cents: number;
trial_days: number;
is_most_popular: boolean;
show_price: boolean;
marketing_features: string[];
is_available: boolean;
features: PlanFeature[];
created_at: string;
}
// =============================================================================
// API Client (no auth required)
// =============================================================================
const publicApiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// =============================================================================
// API Functions
// =============================================================================
/**
* Fetch public plans from the billing catalog.
* No authentication required.
*/
export const fetchPublicPlans = async (): Promise<PublicPlanVersion[]> => {
const response = await publicApiClient.get<PublicPlanVersion[]>('/billing/plans/');
return response.data;
};
// =============================================================================
// Hook
// =============================================================================
/**
* Hook to fetch public plans for the pricing page.
*/
export const usePublicPlans = () => {
return useQuery({
queryKey: ['publicPlans'],
queryFn: fetchPublicPlans,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime)
});
};
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Format price from cents to dollars with currency symbol.
*/
export const formatPrice = (cents: number): string => {
if (cents === 0) return '$0';
return `$${(cents / 100).toFixed(0)}`;
};
/**
* Get a feature value from a plan version by feature code.
*/
export const getPlanFeatureValue = (
planVersion: PublicPlanVersion,
featureCode: string
): boolean | number | null => {
const planFeature = planVersion.features.find(
(pf) => pf.feature.code === featureCode
);
return planFeature?.value ?? null;
};
/**
* Check if a plan has a boolean feature enabled.
*/
export const hasPlanFeature = (
planVersion: PublicPlanVersion,
featureCode: string
): boolean => {
const value = getPlanFeatureValue(planVersion, featureCode);
return value === true;
};
/**
* Get an integer limit from a plan version.
* Returns 0 if not set (unlimited) or the actual limit.
*/
export const getPlanLimit = (
planVersion: PublicPlanVersion,
featureCode: string
): number => {
const value = getPlanFeatureValue(planVersion, featureCode);
return typeof value === 'number' ? value : 0;
};
/**
* Format a limit value for display.
* 0 means unlimited.
*/
export const formatLimit = (value: number): string => {
if (value === 0) return 'Unlimited';
return value.toLocaleString();
};

View File

@@ -31,6 +31,10 @@ export const useResources = (filters?: ResourceFilters) => {
maxConcurrentEvents: r.max_concurrent_events ?? 1,
savedLaneCount: r.saved_lane_count,
userCanEditSchedule: r.user_can_edit_schedule ?? false,
// Location fields
locationId: r.location ?? null,
locationName: r.location_name ?? null,
isMobile: r.is_mobile ?? false,
}));
},
});
@@ -53,6 +57,10 @@ export const useResource = (id: string) => {
maxConcurrentEvents: data.max_concurrent_events ?? 1,
savedLaneCount: data.saved_lane_count,
userCanEditSchedule: data.user_can_edit_schedule ?? false,
// Location fields
locationId: data.location ?? null,
locationName: data.location_name ?? null,
isMobile: data.is_mobile ?? false,
};
},
enabled: !!id,
@@ -82,6 +90,13 @@ export const useCreateResource = () => {
if (resourceData.userCanEditSchedule !== undefined) {
backendData.user_can_edit_schedule = resourceData.userCanEditSchedule;
}
// Location fields
if (resourceData.locationId !== undefined) {
backendData.location = resourceData.locationId;
}
if (resourceData.isMobile !== undefined) {
backendData.is_mobile = resourceData.isMobile;
}
const { data } = await apiClient.post('/resources/', backendData);
return data;
@@ -115,6 +130,13 @@ export const useUpdateResource = () => {
if (updates.userCanEditSchedule !== undefined) {
backendData.user_can_edit_schedule = updates.userCanEditSchedule;
}
// Location fields
if (updates.locationId !== undefined) {
backendData.location = updates.locationId;
}
if (updates.isMobile !== undefined) {
backendData.is_mobile = updates.isMobile;
}
const { data } = await apiClient.patch(`/resources/${id}/`, backendData);
return data;

View File

@@ -21,16 +21,25 @@ export const useServices = () => {
name: s.name,
durationMinutes: s.duration || s.duration_minutes,
price: parseFloat(s.price),
price_cents: s.price_cents ?? Math.round(parseFloat(s.price) * 100),
description: s.description || '',
displayOrder: s.display_order ?? 0,
photos: s.photos || [],
is_active: s.is_active ?? true,
created_at: s.created_at,
is_archived_by_quota: s.is_archived_by_quota ?? false,
// Pricing fields
variable_pricing: s.variable_pricing ?? false,
deposit_amount: s.deposit_amount ? parseFloat(s.deposit_amount) : null,
deposit_amount_cents: s.deposit_amount_cents ?? (s.deposit_amount ? Math.round(parseFloat(s.deposit_amount) * 100) : null),
deposit_percent: s.deposit_percent ? parseFloat(s.deposit_percent) : null,
requires_deposit: s.requires_deposit ?? false,
requires_saved_payment_method: s.requires_saved_payment_method ?? false,
deposit_display: s.deposit_display || null,
// Resource assignment
all_resources: s.all_resources ?? true,
resource_ids: (s.resource_ids || []).map((id: number) => String(id)),
resource_names: s.resource_names || [],
}));
},
retry: false, // Don't retry on 404 - endpoint may not exist yet
@@ -65,12 +74,26 @@ export const useService = (id: string) => {
interface ServiceInput {
name: string;
durationMinutes: number;
price: number;
price?: number; // Price in dollars
price_cents?: number; // Price in cents (preferred)
description?: string;
photos?: string[];
variable_pricing?: boolean;
deposit_amount?: number | null;
deposit_amount?: number | null; // Deposit in dollars
deposit_amount_cents?: number | null; // Deposit in cents (preferred)
deposit_percent?: number | null;
// Resource assignment (not yet implemented in backend)
all_resources?: boolean;
resource_ids?: string[];
// Buffer times (not yet implemented in backend)
prep_time?: number;
takedown_time?: number;
// Notification settings (not yet implemented in backend)
reminder_enabled?: boolean;
reminder_hours_before?: number;
reminder_email?: boolean;
reminder_sms?: boolean;
thank_you_email_enabled?: boolean;
}
/**
@@ -81,10 +104,15 @@ export const useCreateService = () => {
return useMutation({
mutationFn: async (serviceData: ServiceInput) => {
// Convert price: prefer cents, fall back to dollars
const priceInDollars = serviceData.price_cents !== undefined
? (serviceData.price_cents / 100).toString()
: (serviceData.price ?? 0).toString();
const backendData: Record<string, any> = {
name: serviceData.name,
duration: serviceData.durationMinutes,
price: serviceData.price.toString(),
price: priceInDollars,
description: serviceData.description || '',
photos: serviceData.photos || [],
};
@@ -93,13 +121,29 @@ export const useCreateService = () => {
if (serviceData.variable_pricing !== undefined) {
backendData.variable_pricing = serviceData.variable_pricing;
}
if (serviceData.deposit_amount !== undefined) {
// Convert deposit: prefer cents, fall back to dollars
if (serviceData.deposit_amount_cents !== undefined) {
backendData.deposit_amount = serviceData.deposit_amount_cents !== null
? serviceData.deposit_amount_cents / 100
: null;
} else if (serviceData.deposit_amount !== undefined) {
backendData.deposit_amount = serviceData.deposit_amount;
}
if (serviceData.deposit_percent !== undefined) {
backendData.deposit_percent = serviceData.deposit_percent;
}
// Resource assignment
if (serviceData.all_resources !== undefined) {
backendData.all_resources = serviceData.all_resources;
}
if (serviceData.resource_ids !== undefined) {
// Convert string IDs to numbers for the backend
backendData.resource_ids = serviceData.resource_ids.map(id => parseInt(id, 10));
}
const { data } = await apiClient.post('/services/', backendData);
return data;
},
@@ -120,14 +164,38 @@ export const useUpdateService = () => {
const backendData: Record<string, any> = {};
if (updates.name) backendData.name = updates.name;
if (updates.durationMinutes) backendData.duration = updates.durationMinutes;
if (updates.price !== undefined) backendData.price = updates.price.toString();
// Convert price: prefer cents, fall back to dollars
if (updates.price_cents !== undefined) {
backendData.price = (updates.price_cents / 100).toString();
} else if (updates.price !== undefined) {
backendData.price = updates.price.toString();
}
if (updates.description !== undefined) backendData.description = updates.description;
if (updates.photos !== undefined) backendData.photos = updates.photos;
// Pricing fields
if (updates.variable_pricing !== undefined) backendData.variable_pricing = updates.variable_pricing;
if (updates.deposit_amount !== undefined) backendData.deposit_amount = updates.deposit_amount;
// Convert deposit: prefer cents, fall back to dollars
if (updates.deposit_amount_cents !== undefined) {
backendData.deposit_amount = updates.deposit_amount_cents !== null
? updates.deposit_amount_cents / 100
: null;
} else if (updates.deposit_amount !== undefined) {
backendData.deposit_amount = updates.deposit_amount;
}
if (updates.deposit_percent !== undefined) backendData.deposit_percent = updates.deposit_percent;
// Resource assignment
if (updates.all_resources !== undefined) backendData.all_resources = updates.all_resources;
if (updates.resource_ids !== undefined) {
// Convert string IDs to numbers for the backend
backendData.resource_ids = updates.resource_ids.map(id => parseInt(id, 10));
}
const { data } = await apiClient.patch(`/services/${id}/`, backendData);
return data;
},

View File

@@ -25,7 +25,7 @@ export const usePage = (pageId: string) => {
return useQuery({
queryKey: ['page', pageId],
queryFn: async () => {
const response = await api.get(`/sites/me/pages/${pageId}/`);
const response = await api.get(`/pages/${pageId}/`);
return response.data;
},
enabled: !!pageId,
@@ -36,7 +36,7 @@ export const useUpdatePage = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: any }) => {
const response = await api.patch(`/sites/me/pages/${id}/`, data);
const response = await api.patch(`/pages/${id}/`, data);
return response.data;
},
onSuccess: (data, variables) => {
@@ -46,6 +46,31 @@ export const useUpdatePage = () => {
});
};
export const useCreatePage = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { title: string; slug?: string; is_home?: boolean }) => {
const response = await api.post('/sites/me/pages/', data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pages'] });
},
});
};
export const useDeletePage = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/pages/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pages'] });
},
});
};
export const usePublicPage = () => {
return useQuery({
queryKey: ['publicPage'],
@@ -56,3 +81,41 @@ export const usePublicPage = () => {
retry: false,
});
};
export const useSiteConfig = () => {
return useQuery({
queryKey: ['siteConfig'],
queryFn: async () => {
const response = await api.get('/sites/me/config/');
return response.data;
},
});
};
export const useUpdateSiteConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: {
theme?: Record<string, unknown>;
header?: Record<string, unknown>;
footer?: Record<string, unknown>;
}) => {
const response = await api.patch('/sites/me/config/', data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['siteConfig'] });
},
});
};
export const usePublicSiteConfig = () => {
return useQuery({
queryKey: ['publicSiteConfig'],
queryFn: async () => {
const response = await api.get('/public/site-config/');
return response.data;
},
retry: false,
});
};

View File

@@ -128,7 +128,9 @@ export const useBlockedDates = (params: BlockedDatesParams) => {
queryParams.append('include_business', String(params.include_business));
}
const { data } = await apiClient.get(`/time-blocks/blocked_dates/?${queryParams}`);
const url = `/time-blocks/blocked_dates/?${queryParams}`;
const { data } = await apiClient.get(url);
return data.blocked_dates.map((block: any) => ({
...block,
resource_id: block.resource_id ? String(block.resource_id) : null,

View File

@@ -114,6 +114,7 @@
"tickets": "Tickets",
"help": "Help",
"contracts": "Contracts",
"locations": "Locations",
"platformGuide": "Platform Guide",
"ticketingHelp": "Ticketing System",
"apiDocs": "API Docs",
@@ -1753,16 +1754,16 @@
"tiers": {
"free": {
"name": "Free",
"description": "Perfect for getting started",
"description": "Perfect for solo practitioners testing the platform.",
"price": "0",
"trial": "Free forever - no trial needed",
"features": [
"Up to 2 resources",
"Basic scheduling",
"Customer management",
"Direct Stripe integration",
"Subdomain (business.smoothschedule.com)",
"Community support"
"1 user",
"1 resource",
"50 appointments/month",
"Online booking",
"Email reminders",
"Basic reporting"
],
"transactionFee": "2.5% + $0.30 per transaction"
},
@@ -1797,53 +1798,73 @@
},
"enterprise": {
"name": "Enterprise",
"description": "For large organizations",
"price": "Custom",
"description": "For multi-location and white-label needs.",
"price": "199",
"trial": "14-day free trial",
"features": [
"All Business features",
"Custom integrations",
"Dedicated success manager",
"SLA guarantees",
"Custom contracts",
"On-premise option"
"Unlimited users & resources",
"Unlimited appointments",
"Multi-location support",
"White label branding",
"Priority support",
"Dedicated account manager",
"SLA guarantees"
],
"transactionFee": "Custom transaction fees"
},
"starter": {
"name": "Starter",
"description": "Perfect for solo practitioners and small studios.",
"description": "Perfect for small businesses getting started.",
"cta": "Start Free",
"features": {
"0": "1 User",
"1": "Unlimited Appointments",
"2": "1 Active Automation",
"3": "Basic Reporting",
"4": "Email Support"
"0": "3 Users",
"1": "5 Resources",
"2": "200 Appointments/month",
"3": "Payment Processing",
"4": "Mobile App Access"
},
"notIncluded": {
"0": "Custom Domain",
"1": "Python Scripting",
"2": "White-Labeling",
"3": "Priority Support"
"0": "SMS Reminders",
"1": "Custom Domain",
"2": "Integrations",
"3": "API Access"
}
},
"growth": {
"name": "Growth",
"description": "For growing teams needing SMS and integrations.",
"cta": "Start Trial",
"features": {
"0": "10 Users",
"1": "15 Resources",
"2": "1,000 Appointments/month",
"3": "SMS Reminders",
"4": "Custom Domain",
"5": "Integrations"
},
"notIncluded": {
"0": "API Access",
"1": "Advanced Reporting",
"2": "Team Permissions"
}
},
"pro": {
"name": "Pro",
"description": "For growing businesses that need automation.",
"description": "For established businesses needing API and analytics.",
"cta": "Start Trial",
"features": {
"0": "5 Users",
"1": "Unlimited Appointments",
"2": "5 Active Automations",
"3": "Advanced Reporting",
"4": "Priority Email Support",
"5": "SMS Reminders"
"0": "25 Users",
"1": "50 Resources",
"2": "5,000 Appointments/month",
"3": "API Access",
"4": "Advanced Reporting",
"5": "Team Permissions",
"6": "Audit Logs"
},
"notIncluded": {
"0": "Custom Domain",
"1": "Python Scripting",
"2": "White-Labeling"
"0": "Multi-location",
"1": "White Label",
"2": "Priority Support"
}
}
},
@@ -1865,8 +1886,63 @@
"question": "Is my data safe?",
"answer": "Absolutely. We use dedicated secure vaults to physically isolate your data from other customers. Your business data is never mixed with anyone else's."
}
},
"featureComparison": {
"title": "Compare Plans",
"subtitle": "See exactly what you get with each plan",
"features": "Features",
"categories": {
"limits": "Usage Limits",
"communication": "Communication",
"booking": "Booking & Payments",
"integrations": "Integrations & API",
"branding": "Branding & Customization",
"enterprise": "Enterprise Features",
"support": "Support",
"storage": "Storage"
},
"features": {
"max_users": "Team members",
"max_resources": "Resources",
"max_locations": "Locations",
"max_services": "Services",
"max_customers": "Customers",
"max_appointments_per_month": "Appointments/month",
"email_enabled": "Email notifications",
"max_email_per_month": "Emails/month",
"sms_enabled": "SMS reminders",
"max_sms_per_month": "SMS/month",
"masked_calling_enabled": "Masked calling",
"online_booking": "Online booking",
"recurring_appointments": "Recurring appointments",
"payment_processing": "Accept payments",
"mobile_app_access": "Mobile app",
"integrations_enabled": "Third-party integrations",
"api_access": "API access",
"max_api_calls_per_day": "API calls/day",
"custom_domain": "Custom domain",
"custom_branding": "Custom branding",
"remove_branding": "Remove \"Powered by\"",
"white_label": "White label",
"multi_location": "Multi-location management",
"team_permissions": "Team permissions",
"audit_logs": "Audit logs",
"advanced_reporting": "Advanced analytics",
"priority_support": "Priority support",
"dedicated_account_manager": "Dedicated account manager",
"sla_guarantee": "SLA guarantee",
"max_storage_mb": "File storage"
}
},
"loadError": "Unable to load pricing. Please try again later.",
"savePercent": "Save ~17%",
"perYear": "/year",
"trialDays": "{{days}}-day free trial",
"freeForever": "Free forever",
"custom": "Custom",
"getStartedFree": "Get Started Free",
"startTrial": "Start Free Trial"
},
"testimonials": {
"title": "Loved by Businesses Everywhere",
"subtitle": "See what our customers have to say"

View File

@@ -83,13 +83,13 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
// Check for trial expiration and redirect
useEffect(() => {
// Don't check if already on trial-expired page
if (location.pathname === '/trial-expired') {
if (location.pathname === '/dashboard/trial-expired') {
return;
}
// Redirect to trial-expired page if trial has expired
if (business.isTrialExpired && business.status === 'Trial') {
navigate('/trial-expired', { replace: true });
navigate('/dashboard/trial-expired', { replace: true });
}
}, [business.isTrialExpired, business.status, location.pathname, navigate]);

View File

@@ -21,6 +21,7 @@ import {
CreditCard,
AlertTriangle,
Calendar,
Clock,
} from 'lucide-react';
import {
SettingsSidebarSection,
@@ -38,11 +39,11 @@ interface ParentContext {
// Map settings pages to their required plan features
const SETTINGS_PAGE_FEATURES: Record<string, FeatureKey> = {
'/settings/branding': 'white_label',
'/settings/custom-domains': 'custom_domain',
'/settings/api': 'api_access',
'/settings/authentication': 'custom_oauth',
'/settings/sms-calling': 'sms_reminders',
'/dashboard/settings/branding': 'white_label',
'/dashboard/settings/custom-domains': 'custom_domain',
'/dashboard/settings/api': 'api_access',
'/dashboard/settings/authentication': 'custom_oauth',
'/dashboard/settings/sms-calling': 'sms_reminders',
};
const SettingsLayout: React.FC = () => {
@@ -71,7 +72,7 @@ const SettingsLayout: React.FC = () => {
{/* Back Button */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => navigate('/')}
onClick={() => navigate('/dashboard')}
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors"
>
<ArrowLeft size={16} />
@@ -91,43 +92,49 @@ const SettingsLayout: React.FC = () => {
{/* Business Section */}
<SettingsSidebarSection title={t('settings.sections.business', 'Business')}>
<SettingsSidebarItem
to="/settings/general"
to="/dashboard/settings/general"
icon={Building2}
label={t('settings.general.title', 'General')}
description={t('settings.general.description', 'Name, timezone, contact')}
/>
<SettingsSidebarItem
to="/settings/resource-types"
to="/dashboard/settings/resource-types"
icon={Layers}
label={t('settings.resourceTypes.title', 'Resource Types')}
description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')}
badgeElement={<UnfinishedBadge />}
/>
<SettingsSidebarItem
to="/settings/booking"
to="/dashboard/settings/booking"
icon={Calendar}
label={t('settings.booking.title', 'Booking')}
description={t('settings.booking.description', 'Booking URL, redirects')}
/>
<SettingsSidebarItem
to="/dashboard/settings/business-hours"
icon={Clock}
label={t('settings.businessHours.title', 'Business Hours')}
description={t('settings.businessHours.description', 'Operating hours')}
/>
</SettingsSidebarSection>
{/* Branding Section */}
<SettingsSidebarSection title={t('settings.sections.branding', 'Branding')}>
<SettingsSidebarItem
to="/settings/branding"
to="/dashboard/settings/branding"
icon={Palette}
label={t('settings.appearance.title', 'Appearance')}
description={t('settings.appearance.description', 'Logo, colors, theme')}
locked={isLocked('white_label')}
/>
<SettingsSidebarItem
to="/settings/email-templates"
to="/dashboard/settings/email-templates"
icon={Mail}
label={t('settings.emailTemplates.title', 'Email Templates')}
description={t('settings.emailTemplates.description', 'Customize email designs')}
/>
<SettingsSidebarItem
to="/settings/custom-domains"
to="/dashboard/settings/custom-domains"
icon={Globe}
label={t('settings.customDomains.title', 'Custom Domains')}
description={t('settings.customDomains.description', 'Use your own domain')}
@@ -138,7 +145,7 @@ const SettingsLayout: React.FC = () => {
{/* Integrations Section */}
<SettingsSidebarSection title={t('settings.sections.integrations', 'Integrations')}>
<SettingsSidebarItem
to="/settings/api"
to="/dashboard/settings/api"
icon={Key}
label={t('settings.api.title', 'API & Webhooks')}
description={t('settings.api.description', 'API tokens, webhooks')}
@@ -149,7 +156,7 @@ const SettingsLayout: React.FC = () => {
{/* Access Section */}
<SettingsSidebarSection title={t('settings.sections.access', 'Access')}>
<SettingsSidebarItem
to="/settings/authentication"
to="/dashboard/settings/authentication"
icon={Lock}
label={t('settings.authentication.title', 'Authentication')}
description={t('settings.authentication.description', 'OAuth, social login')}
@@ -160,13 +167,13 @@ const SettingsLayout: React.FC = () => {
{/* Communication Section */}
<SettingsSidebarSection title={t('settings.sections.communication', 'Communication')}>
<SettingsSidebarItem
to="/settings/email"
to="/dashboard/settings/email"
icon={Mail}
label={t('settings.email.title', 'Email Setup')}
description={t('settings.email.description', 'Email addresses for tickets')}
/>
<SettingsSidebarItem
to="/settings/sms-calling"
to="/dashboard/settings/sms-calling"
icon={Phone}
label={t('settings.smsCalling.title', 'SMS & Calling')}
description={t('settings.smsCalling.description', 'Credits, phone numbers')}
@@ -177,13 +184,13 @@ const SettingsLayout: React.FC = () => {
{/* Billing Section */}
<SettingsSidebarSection title={t('settings.sections.billing', 'Billing')}>
<SettingsSidebarItem
to="/settings/billing"
to="/dashboard/settings/billing"
icon={CreditCard}
label={t('settings.billing.title', 'Plan & Billing')}
description={t('settings.billing.description', 'Subscription, invoices')}
/>
<SettingsSidebarItem
to="/settings/quota"
to="/dashboard/settings/quota"
icon={AlertTriangle}
label={t('settings.quota.title', 'Quota Management')}
description={t('settings.quota.description', 'Usage limits, archiving')}

View File

@@ -62,6 +62,7 @@ vi.mock('lucide-react', () => ({
CreditCard: ({ size }: { size: number }) => <svg data-testid="credit-card-icon" width={size} height={size} />,
AlertTriangle: ({ size }: { size: number }) => <svg data-testid="alert-triangle-icon" width={size} height={size} />,
Calendar: ({ size }: { size: number }) => <svg data-testid="calendar-icon" width={size} height={size} />,
Clock: ({ size }: { size: number }) => <svg data-testid="clock-icon" width={size} height={size} />,
}));
// Mock usePlanFeatures hook

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