84 Commits

Author SHA1 Message Date
poduck
fbefccf436 Add media gallery with album organization and Puck integration
Backend:
- Add Album and MediaFile models for tenant-scoped media storage
- Add TenantStorageUsage model for per-tenant storage quota tracking
- Create StorageQuotaService with EntitlementService integration
- Add AlbumViewSet, MediaFileViewSet with bulk operations
- Add StorageUsageView for quota monitoring

Frontend:
- Create MediaGalleryPage with album management and file upload
- Add drag-and-drop upload with storage quota validation
- Create ImagePickerField custom Puck field for gallery integration
- Update Image, Testimonial components to use ImagePicker
- Add background image picker to Puck design controls
- Add gallery to sidebar navigation

Also includes:
- Puck marketing components (Hero, SplitContent, etc.)
- Enhanced ContactForm and BusinessHours components
- Platform login page improvements
- Site builder draft/preview enhancements

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 19:59:31 -05:00
poduck
e7733449dd Move tenant dashboard routes under /dashboard/ prefix
- Update App.tsx routes to use /dashboard/ prefix for all business user routes
- Add redirect from / to /dashboard for authenticated business users
- Update Sidebar.tsx navigation links with /dashboard/ prefix
- Update SettingsLayout.tsx settings navigation paths
- Update all help pages with /dashboard/help/ routes
- Update navigate() calls in components:
  - TrialBanner, PaymentSettingsSection, NotificationDropdown
  - BusinessLayout, UpgradePrompt, QuotaWarningBanner
  - QuotaOverageModal, OpenTicketsWidget, CreatePlugin
  - MyPlugins, PluginMarketplace, HelpTicketing
  - HelpGuide, Upgrade, TrialExpired
  - CustomDomainsSettings, QuotaSettings
- Fix hardcoded lvh.me URL in BusinessEditModal to use buildSubdomainUrl

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:11:38 -05:00
poduck
8dc2248f1f feat: Add comprehensive test suite and misc improvements
- Add frontend unit tests with Vitest for components, hooks, pages, and utilities
- Add backend tests for webhooks, notifications, middleware, and edge cases
- Add ForgotPassword, NotFound, and ResetPassword pages
- Add migration for orphaned staff resources conversion
- Add coverage directory to gitignore (generated reports)
- Various bug fixes and improvements from previous work

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 02:36:46 -05:00
poduck
c220612214 Revert "chore: Add WIP test files to gitignore for clean deploy"
This reverts commit 33137289ef.
2025-12-08 02:35:50 -05:00
poduck
33137289ef chore: Add WIP test files to gitignore for clean deploy 2025-12-08 02:34:56 -05:00
poduck
b2be35bdfa chore: Add coverage to gitignore 2025-12-08 02:34:21 -05:00
poduck
a4b23e44b6 feat(messaging): Add broadcast messaging system for owners and managers
- Add BroadcastMessage and MessageRecipient models for sending messages to groups or individuals
- Add Messages page with compose form and sent messages list
- Support targeting by role (owners, managers, staff, customers) or individual users
- Add can_send_messages permission (owners always, managers by default with revocable permission)
- Add autofill search dropdown with infinite scroll for selecting individual recipients
- Add staff permission toggle for managers' messaging access
- Integrate Messages link in sidebar for users with permission

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 02:33:27 -05:00
poduck
67ce2c433c Merge remote-tracking branch 'origin/main' into refactor/organize-django-apps
# Conflicts:
#	smoothschedule/smoothschedule/scheduling/schedule/serializers.py
2025-12-07 21:12:09 -05:00
poduck
1391374d45 test: Add comprehensive unit test coverage for all domains
This commit adds extensive unit tests across all Django app domains,
increasing test coverage significantly. All tests use mocks to avoid
database dependencies and follow the testing pyramid approach.

Domains covered:
- identity/core: mixins, models, permissions, OAuth, quota service
- identity/users: models, API views, MFA, services
- commerce/tickets: signals, serializers, views, email notifications
- commerce/payments: services, views
- communication/credits: models, tasks, views
- communication/mobile: serializers, views
- communication/notifications: models, serializers, views
- platform/admin: serializers, views
- platform/api: models, views, token security
- scheduling/schedule: models, serializers, services, signals, views
- scheduling/contracts: serializers, views
- scheduling/analytics: views

Key improvements:
- Fixed 54 previously failing tests in signals and serializers
- All tests use proper mocking patterns (no @pytest.mark.django_db)
- Added test factories for creating mock objects
- Updated conftest.py with shared fixtures

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 21:10:26 -05:00
poduck
8440ac945a feat(time-off): Reset approval when staff edits approved request
- Add pre_save signal to track changes to approved time blocks
- Reset to PENDING status when staff modifies approved time-off
- Send re-approval notifications to managers with changed fields
- Update email templates for modified requests
- Allow managers to have self-approval permission revoked (default: allowed)

A changed request is treated as a new request requiring re-approval.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 20:35:47 -05:00
poduck
f4332153f4 feat: Add timezone architecture for consistent date/time handling
- Create dateUtils.ts with helpers for UTC conversion and timezone display
- Add TimezoneSerializerMixin to include business_timezone in API responses
- Update GeneralSettings timezone dropdown with IANA identifiers
- Apply timezone mixin to Event, TimeBlock, and field mobile serializers
- Document timezone architecture in CLAUDE.md

All times stored in UTC, converted for display based on business timezone.
If business_timezone is null, uses user's local timezone.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 19:39:36 -05:00
poduck
b9e90e6f46 docs: Add comprehensive testing guidelines to CLAUDE.md
Add testing documentation emphasizing mocked unit tests over slow
database-hitting integration tests due to django-tenants overhead.

Guidelines include:
- Testing pyramid philosophy (prefer unit tests)
- Unit test examples with mocks
- Serializer and ViewSet testing patterns
- When to use integration tests (sparingly)
- Repository pattern for testable code
- Dependency injection examples
- Test file structure conventions
- Commands for running tests with coverage

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 19:12:01 -05:00
poduck
1af79cc019 refactor: Reorganize tests into tests/ directories
Follow cookiecutter-django convention by placing tests in dedicated
tests/ directories within each app instead of single tests.py files.

Changes:
- Created tests/ directories with __init__.py for all 13 apps
- Moved analytics/tests.py → analytics/tests/test_views.py
- Moved schedule/test_export.py → schedule/tests/test_export.py
- Moved platform/api/tests_token_security.py → platform/api/tests/test_token_security.py
- Deleted empty placeholder tests.py files

All apps now have a tests/ directory ready for proper test organization.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 18:31:00 -05:00
poduck
156cc2676d refactor: Reorganize Django apps into domain-based structure
Restructured 13 Django apps from flat/mixed organization into 5 logical
domain packages following cookiecutter-django conventions:

- identity/: core (tenant/domain models, middleware, mixins), users
- scheduling/: schedule, contracts, analytics
- communication/: notifications, credits, mobile, messaging
- commerce/: payments, tickets
- platform/: admin, api

Key changes:
- Moved all apps to smoothschedule/smoothschedule/{domain}/{app}/
- Updated all import paths across the codebase
- Updated settings (base.py, multitenancy.py, test.py)
- Updated URL configuration in config/urls.py
- Updated middleware and permission paths
- Preserved app_label in AppConfig for migration compatibility
- Updated CLAUDE.md documentation with new structure

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 18:24:50 -05:00
poduck
897a336d0b feat: Add click navigation for time-off request notifications
Clicking a time-off request notification now navigates to the
time blocks page where pending requests can be reviewed.

- Added Clock icon for time-off request notifications
- Handle notification.data.type === 'time_off_request' to navigate to /time-blocks

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:54:20 -05:00
poduck
410b46a896 feat: Add time block approval workflow and staff permission system
- Add TimeBlock approval status with manager approval workflow
- Create core mixins for staff permission restrictions (DenyStaffWritePermission, etc.)
- Add StaffDashboard page for staff-specific views
- Refactor MyAvailability page for time block management
- Update field mobile status machine and views
- Add per-user permission overrides via JSONField
- Document core mixins and permission system in CLAUDE.md

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:49:37 -05:00
poduck
01020861c7 feat(staff): Restrict staff permissions and add schedule view
- Backend: Restrict staff from accessing resources, customers, services, and tasks APIs
- Frontend: Hide management sidebar links from staff members
- Add StaffSchedule page with vertical timeline view of appointments
- Add StaffHelp page with staff-specific documentation
- Return linked_resource_id and can_edit_schedule in user profile for staff

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 02:23:00 -05:00
poduck
61882b300f feat(mobile): Add field app with date range navigation
- Add React Native Expo field app for mobile staff
- Use main /appointments/ endpoint with date range support
- Add X-Business-Subdomain header for tenant context
- Support day/week view navigation
- Remove WebSocket console logging from frontend
- Update AppointmentStatus type to include all backend statuses
- Add responsive status legend to scheduler header

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 01:23:24 -05:00
poduck
46b154e957 feat: Add favicon.ico and apple-touch-icon
- Create multi-resolution favicon.ico (48x48, 32x32, 16x16) from logo
- Add apple-touch-icon.png for iOS devices
- Update index.html to use new favicon

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 14:32:35 -05:00
poduck
023ea7f020 feat(contracts): Add contracts permission to subscription tiers
- Add contracts_enabled field to SubscriptionPlan model
- Add contracts toggle to plan create/edit modal in platform settings
- Hide contracts menu item for tenants without contracts permission
- Protect /contracts routes with canUse('contracts') check
- Add HasContractsPermission to contracts API ViewSets
- Add contracts to PlanPermissions interface and feature definitions

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-05 23:28:51 -05:00
poduck
35f4301fe1 feat(contracts): Add legal export package and ESIGN compliance improvements
- Add export_legal endpoint for signed contracts that generates a ZIP with:
  - Signed contract PDF
  - Audit certificate PDF with signature details and hash verification
  - Machine-readable signature_record.json
  - Integrity verification report
  - README documentation

- Add audit certificate template with:
  - Contract and signature information
  - Consent records with exact legal text
  - Document integrity verification (SHA-256 hash comparison)
  - ESIGN Act and UETA compliance statement

- Update ContractSigning page for ESIGN/UETA compliance:
  - Consent checkbox text now matches backend-stored legal text
  - Added proper legal notice with ESIGN Act references

- Add signed_at field to ContractListSerializer
- Add view/print buttons for signed contracts in Contracts page
- Allow viewing signed contracts via public signing URL

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 02:29:35 -05:00
poduck
6feaa8dda5 fix(i18n): Update French win-back translation
Changed "Reconquête Client" to "Réactivation des clients" for more
natural French phrasing.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 18:47:51 -05:00
poduck
f084e33621 fix(i18n): Complete German helpComprehensive translations
The German helpComprehensive section had a different structure with 250
missing keys. Replaced with complete translations matching the English
structure used by HelpComprehensive.tsx.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 18:44:26 -05:00
poduck
db0165dc5e fix(i18n): Add missing 'welcome' translation key to en/es/fr.json
The HelpComprehensive.tsx uses introduction.welcome but the translation
files only had introduction.title. Added the welcome key to match
the German translation structure.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 18:27:20 -05:00
poduck
af891d7e8f fix(i18n): Convert HelpComprehensive.tsx to use translation keys
Replaced all hardcoded English text with i18n translation function calls
to enable proper internationalization. All sections now use
helpComprehensive.* translation keys that are already present in
en.json, es.json, fr.json, and de.json.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 18:15:20 -05:00
poduck
7ef255a5f1 feat(help): Add Time Blocks section to comprehensive help docs
- Add Time Blocks section to HelpComprehensive.tsx with block levels,
  types, recurrence patterns, and key features documentation
- Add complete helpComprehensive translations for en, es, fr, de
- Update HelpContracts.tsx styling
- Enhance FeaturesPage.tsx and HomePage.tsx

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 17:58:41 -05:00
poduck
29e99631c9 feat(i18n): Add time blocks translations and fix deployment
- Add comprehensive timeBlocks translations (ES, FR, DE, EN)
- Add myAvailability translations (ES, FR, DE, EN)
- Add full helpTimeBlocks guide content (ES, FR, DE, EN)
- Add contracts guide translations (ES)
- Fix DATABASE_URL env var in deploy.sh for seed_platform_plugins
- Update Contracts page and HelpContracts guide

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 17:43:03 -05:00
poduck
2d7c1dcd27 feat(time-blocks): Add seed_holidays management command
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 17:21:02 -05:00
poduck
8d0cc1e90a feat(time-blocks): Add comprehensive time blocking system with contracts
- Add TimeBlock and Holiday models with recurrence support (one-time, weekly, monthly, yearly, holiday)
- Implement business-level and resource-level blocking with hard/soft block types
- Add multi-select holiday picker for bulk holiday blocking
- Add calendar overlay visualization with distinct colors:
  - Business blocks: Red (hard) / Yellow (soft)
  - Resource blocks: Purple (hard) / Cyan (soft)
- Add month view resource indicators showing 1/n width per resource
- Add yearly calendar view for block overview
- Add My Availability page for staff self-service
- Add contracts module with templates, signing flow, and PDF generation
- Update scheduler with click-to-day navigation in week view

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 17:19:12 -05:00
poduck
cf91bae24f feat(services): Add deposit percentage option for fixed-price services
- Add deposit_percent field back to Service model for percentage-based deposits
- Reorganize service form: variable pricing toggle at top, deposit toggle with
  amount/percent options (percent only available for fixed pricing)
- Disable price field when variable pricing is enabled
- Add backend validation: variable pricing cannot use percentage deposits
- Update frontend types and hooks to handle deposit_percent field

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:52:51 -05:00
poduck
c7308ad167 refactor(services): Simplify deposit to single amount field
- Remove deposit_percent field (doesn't work for variable pricing)
- Make deposit_amount default to 0 (no deposit)
- Deposit now applies to both variable and fixed pricing services
- Add requires_deposit and requires_saved_payment_method as computed properties
- Simplify frontend form with single deposit amount input
- Show deposit badge in service list when deposit > 0

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:42:46 -05:00
poduck
7da5d55831 fix(services): Update hooks to handle variable pricing fields
- Add ServiceInput interface for create/update operations
- Transform variable pricing fields in useServices query
- Handle deposit_amount and deposit_percent in mutations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:35:35 -05:00
poduck
3bc8167649 feat(payments): Add variable pricing with deposit collection
Services can now have variable pricing where:
- Final price is determined after service completion
- A deposit (fixed amount or percentage) is collected at booking
- Customer's saved payment method is charged for remaining balance

Changes:
- Add variable_pricing, deposit_amount, deposit_percent fields to Service model
- Add service FK and final_price fields to Event model
- Add AWAITING_PAYMENT status to Event
- Add SetFinalPriceView endpoint to charge customer's saved card
- Add EventPricingInfoView endpoint for pricing details
- Update Services page with variable pricing toggle and deposit config
- Show "From $X" and deposit info in customer preview

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:33:03 -05:00
poduck
b0512a660c feat(billing): Add customer billing page with payment method management
- Add CustomerBilling page for customers to view payment history and manage cards
- Create AddPaymentMethodModal with Stripe Elements for secure card saving
- Support both Stripe Connect and direct API payment modes
- Auto-set first payment method as default when no default exists
- Add dark mode support for Stripe card input styling
- Add customer billing API endpoints for payment history and saved cards
- Add stripe_customer_id field to User model for Stripe customer tracking

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:06:30 -05:00
poduck
65faaae864 fix(security): Multi-tenancy isolation and customer appointment filtering
- Add request tenant validation to all ViewSets (EventViewSet, ResourceViewSet,
  ParticipantViewSet, CustomerViewSet, StaffViewSet) to prevent cross-tenant
  data access via subdomain/header manipulation
- Change permission_classes from AllowAny to IsAuthenticated for EventViewSet
  and ResourceViewSet
- Filter events for customers to only show appointments where they are a
  participant
- Add customer field to EventSerializer to create Customer participants when
  appointments are created
- Update CustomerDashboard to fetch appointments from API instead of mock data
- Fix TenantViewSet.destroy() to properly handle cross-schema cascade when
  deleting tenants

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 11:05:01 -05:00
poduck
dbe91ec2ff feat(auth): Convert login system to use email as username
- Backend login now accepts 'email' field (with backward compatibility)
- User creation (signup, invitation, customer) uses email as username
- Frontend login form updated with email input and validation
- Updated test users to use email addresses as usernames
- Updated all translation files (en, es, fr, de)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 10:38:53 -05:00
poduck
a2f74ee769 fix(customers): Auto-generate username when creating customers
The CustomerSerializer was missing a create method to generate a unique
username, causing IntegrityError when trying to create customers.

- Add first_name and last_name as write-only fields
- Remove email from read_only_fields so it can be set on creation
- Generate username from email prefix (with counter for uniqueness)
- Fall back to UUID-based username if no email provided

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 10:29:45 -05:00
poduck
9073970189 fix(i18n): Add language selector to platform UI
Restore the LanguageSelector component to the platform layout header,
allowing platform users to switch languages.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 10:12:38 -05:00
poduck
6554e62d30 fix(seo): Add noindex for platform and business subdomains
Dynamically set robots meta tag to noindex/nofollow when on any
subdomain (platform.*, demo.*, etc.). Only the root domain
marketing pages should be indexed.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 10:08:47 -05:00
poduck
bd6d9144ce fix(seo): Block crawlers and add sitemap
- Set robots meta tag to noindex, nofollow (site not live)
- Update robots.txt with instructions for going live
- Add sitemap.xml with all marketing pages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 10:01:44 -05:00
poduck
ad04e5f6ff fix(seo): Remove technical jargon and add SEO meta tags
- Replace "multi-tenant" wording with user-friendly alternatives
  - Hero subheadline: "Secure" instead of "Multi-tenant"
  - Feature title: "Enterprise Security" instead of "Multi-Tenant Architecture"
  - Updated testimonials and FAQ to remove technical references
- Add comprehensive SEO meta tags to index.html:
  - Meta description for search engines
  - Open Graph tags for social sharing
  - Twitter card meta tags
  - Canonical URL and robots directives
- Update all language files (en, es, fr, de) with consistent changes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 23:01:32 -05:00
poduck
460bf200d0 fix(i18n): Sync marketing translations across all languages
Update es.json, fr.json, and de.json to match en.json structure:
- Add missing benefits, plugins, and home sections
- Add new hero keys (badge, title, titleHighlight, visualContent)
- Add features automation and multi-tenancy sections
- Add pricing FAQ, starter/pro tiers
- Add signup address fields and payment setup
- Restructure footer with proper nesting
- Add contact page new keys (formHeading, scheduleCall)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 22:49:44 -05:00
poduck
3e8634b370 fix(i18n): Add missing About page timeline translations
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 22:27:30 -05:00
poduck
bc094f2f80 feat(i18n): Internationalize marketing pages and components
- HomePage.tsx: Add translation keys for features, testimonials, section titles
- FeaturesPage.tsx: Add translation keys for automation engine, multi-tenancy sections
- Hero.tsx: Add translation keys for headline, CTAs, trust signals, visual content
- ContactPage.tsx: Add translation keys for form headings, success messages
- PricingPage.tsx: Add translation keys for FAQ section
- PrivacyPolicyPage.tsx: Full internationalization of 15-section privacy policy
- TermsOfServicePage.tsx: Full internationalization of 16-section terms of service
- Footer.tsx & Navbar.tsx: Add translation keys for brand name, aria-labels

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 22:25:11 -05:00
poduck
c7f241b30a feat(i18n): Comprehensive internationalization of frontend components and pages
Translate all hardcoded English strings to use i18n translation keys:

Components:
- TransactionDetailModal: payment details, refunds, technical info
- ConnectOnboarding/ConnectOnboardingEmbed: Stripe Connect setup
- StripeApiKeysForm: API key management
- DomainPurchase: domain registration flow
- Sidebar: navigation labels
- Schedule/Sidebar, PendingSidebar: scheduler UI
- MasqueradeBanner: masquerade status
- Dashboard widgets: metrics, capacity, customers, tickets
- Marketing: PricingTable, PluginShowcase, BenefitsSection
- ConfirmationModal, ServiceList: common UI

Pages:
- Staff: invitation flow, role management
- Customers: form placeholders
- Payments: transactions, payouts, billing
- BookingSettings: URL and redirect configuration
- TrialExpired: upgrade prompts and features
- PlatformSettings, PlatformBusinesses: admin UI
- HelpApiDocs: API documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 21:40:54 -05:00
poduck
902582f4ba feat(platform): Redesign tenant invite modal with tier-based permissions
- Simplified UI with Email, Business Name, and Subscription Tier fields
- Added collapsible "Override Tier Limits" section with sliding animation
- Permission options match platform settings structure (Payments, Communication, Customization, Plugins, Advanced, Enterprise)
- Permissions are loaded from subscription plans or fallback to static defaults
- Custom limits/permissions only sent to backend when override is checked

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 20:45:29 -05:00
poduck
7b18637b1e feat(tenant): Add public-facing landing page for business subdomains
- New TenantLandingPage component with 'Coming Soon' message
- Shows business name derived from subdomain
- Has 'Sign In' button that goes to /login
- 'Powered by SmoothSchedule' footer
- Will be customizable later for each tenant

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:54:46 -05:00
poduck
3a1b2f2dd8 fix(onboarding): Change 'Go to Dashboard' to 'Go to Login'
The button after tenant creation was misleading - users need to log in first.
Changed button text and URL to explicitly point to /login.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:47:59 -05:00
poduck
88b54ef9e4 chore(traefik): Remove debug logging, set production log level
Wildcard subdomain routing is now working. Removed access logging
that was added for debugging.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:39:39 -05:00
poduck
5cdbc19517 fix(traefik): Fix HostRegexp YAML escaping for subdomain routing
In YAML single-quoted strings, backslashes are literal characters.
'\\.' was being interpreted as two backslashes + dot, not as an
escaped dot in the regex.

Changed from '\\.smoothschedule\\.com' to '\.smoothschedule\.com'

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:38:57 -05:00
poduck
f3a0f1f07a debug: Add access logging to Traefik
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:37:47 -05:00
poduck
f3951295ac fix(traefik): Remove conflicting TCP router for subdomain handling
The TCP router was intercepting wildcard subdomain traffic at the TCP layer
and sending it directly to nginx:80, bypassing HTTP routing entirely.
This caused 404 errors because nginx wasn't receiving proper HTTP routing.

Now relying on:
- TLS store's defaultGeneratedCert for wildcard certificate
- HTTP HostRegexp router for subdomain routing to nginx

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:34:41 -05:00
poduck
9cbf19ed1b fix(traefik): Simplify HTTP HostRegexp pattern
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:32:53 -05:00
poduck
88c74398e4 fix(traefik): Simplify HostSNIRegexp pattern for wildcard subdomains
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:29:17 -05:00
poduck
86947ab206 feat(deploy): Add selective service rebuild and --no-migrate option
- Add support for specifying services to rebuild (e.g., ./deploy.sh traefik)
- Add --no-migrate flag to skip migrations for config-only changes
- Reduce wait time from 10s to 5s

Usage examples:
  ./deploy.sh                               # Build all, run migrations
  ./deploy.sh traefik --no-migrate          # Only rebuild traefik, skip migrations
  ./deploy.sh django nginx                  # Build django and nginx, run migrations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:27:17 -05:00
poduck
7cc013eaf2 fix(traefik): Add TCP router with HostSNIRegexp for wildcard subdomain TLS
Add a TCP-level router using HostSNIRegexp to match unknown subdomains
at the TLS layer and terminate TLS with wildcard certificate.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:19:21 -05:00
poduck
a723d784cd fix(traefik): Add TLS store for wildcard subdomain routing
- Add default TLS store with wildcard certificate for unknown SNIs
- Add priority=1 to subdomain-router for catch-all behavior
- Use proper Traefik v3 HostRegexp syntax with anchors

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:14:36 -05:00
poduck
13441d88fc fix(traefik): Use separate storage files for certificate resolvers
Separate acme.json storage for HTTP and DNS certificate resolvers
to prevent conflicts when requesting wildcard certificates.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:08:34 -05:00
poduck
b20fa5cfd8 fix(traefik): Update HostRegexp syntax for Traefik v3
Traefik v3 changed HostRegexp syntax from named capture groups to
standard regex. Added low priority to avoid matching specific routes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:59:45 -05:00
1020 changed files with 264293 additions and 9601 deletions

View File

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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Test coverage reports (generated)
frontend/coverage/

450
CLAUDE.md
View File

@@ -21,6 +21,169 @@
Note: `lvh.me` resolves to `127.0.0.1` - required for subdomain cookies to work.
## CRITICAL: Test-Driven Development (TDD) Required
**All code changes MUST follow TDD.** This is non-negotiable.
### TDD Workflow
1. **Write tests FIRST** before writing any implementation code
2. **Run tests** to verify they fail (red)
3. **Write minimal code** to make tests pass (green)
4. **Refactor** while keeping tests green
5. **Repeat** for each new feature or bug fix
### Coverage Requirements
| Target | Minimum | Goal |
|--------|---------|------|
| Backend (Django) | **80%** | 100% |
| Frontend (React) | **80%** | 100% |
### Running Tests with Coverage
**Backend (Django):**
```bash
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
# Run all tests with coverage
docker compose -f docker-compose.local.yml exec django pytest --cov --cov-report=term-missing
# Run tests for a specific app
docker compose -f docker-compose.local.yml exec django pytest smoothschedule/scheduling/schedule/tests/ --cov=smoothschedule/scheduling/schedule
# Run a single test file
docker compose -f docker-compose.local.yml exec django pytest smoothschedule/path/to/test_file.py -v
# Run tests matching a pattern
docker compose -f docker-compose.local.yml exec django pytest -k "test_create_resource" -v
```
**Frontend (React):**
```bash
cd /home/poduck/Desktop/smoothschedule2/frontend
# Run all tests with coverage
npm test -- --coverage
# Run tests in watch mode during development
npm test
# Run a single test file
npm test -- src/hooks/__tests__/useResources.test.ts
# Run tests matching a pattern
npm test -- -t "should create resource"
```
### Test File Organization
**Backend:**
```
smoothschedule/smoothschedule/{domain}/{app}/
├── models.py
├── views.py
├── serializers.py
└── tests/
├── __init__.py
├── test_models.py # Model unit tests
├── test_serializers.py # Serializer tests
├── test_views.py # API endpoint tests
└── factories.py # Test factories (optional)
```
**Frontend:**
```
frontend/src/
├── hooks/
│ ├── useResources.ts
│ └── __tests__/
│ └── useResources.test.ts
├── components/
│ ├── MyComponent.tsx
│ └── __tests__/
│ └── MyComponent.test.tsx
└── pages/
├── MyPage.tsx
└── __tests__/
└── MyPage.test.tsx
```
### What to Test
**Backend:**
- Model methods and properties
- Model validation (clean methods)
- Serializer validation
- API endpoints (all HTTP methods)
- Permission classes
- Custom querysets and managers
- Signals
- Celery tasks
- Utility functions
**Frontend:**
- Custom hooks (state changes, API calls)
- Component rendering
- User interactions (clicks, form submissions)
- Conditional rendering
- Error states
- Loading states
- API client functions
### TDD Example - Adding a New Feature
**Step 1: Write the test first**
```python
# Backend: test_views.py
def test_create_resource_with_schedule(self, api_client, tenant):
"""New feature: resources can have a default schedule."""
data = {
"name": "Test Resource",
"type": "STAFF",
"default_schedule": {
"monday": {"start": "09:00", "end": "17:00"},
"tuesday": {"start": "09:00", "end": "17:00"},
}
}
response = api_client.post("/api/resources/", data, format="json")
assert response.status_code == 201
assert response.data["default_schedule"]["monday"]["start"] == "09:00"
```
```typescript
// Frontend: useResources.test.ts
it('should create resource with schedule', async () => {
const { result } = renderHook(() => useCreateResource());
await act(async () => {
await result.current.mutateAsync({
name: 'Test Resource',
type: 'STAFF',
defaultSchedule: { monday: { start: '09:00', end: '17:00' } }
});
});
expect(mockApiClient.post).toHaveBeenCalledWith('/resources/', expect.objectContaining({
default_schedule: expect.any(Object)
}));
});
```
**Step 2: Run tests - they should FAIL**
**Step 3: Write minimal implementation to make tests pass**
**Step 4: Refactor if needed while keeping tests green**
### Pre-Commit Checklist
Before committing ANY code:
1. [ ] Tests written BEFORE implementation
2. [ ] All tests pass
3. [ ] Coverage meets minimum threshold (80%)
4. [ ] No skipped or disabled tests without justification
## CRITICAL: Backend Runs in Docker
**NEVER run Django commands directly.** Always use Docker Compose:
@@ -61,14 +224,293 @@ docker compose -f docker-compose.local.yml exec django python manage.py <command
| `frontend/src/api/client.ts` | Axios API client |
| `frontend/src/types.ts` | TypeScript interfaces |
| `frontend/src/i18n/locales/en.json` | Translations |
| `frontend/src/utils/dateUtils.ts` | Date formatting utilities |
## Key Django Apps
## Timezone Architecture (CRITICAL)
All date/time handling follows this architecture to ensure consistency across timezones.
### Core Principles
1. **Database**: All times stored in UTC
2. **API Communication**: Always use UTC (both directions)
3. **API Responses**: Include `business_timezone` field
4. **Frontend Display**: Convert UTC based on `business_timezone`
- If `business_timezone` is set → display in that timezone
- If `business_timezone` is null/blank → display in user's local timezone
### Data Flow
```
FRONTEND (User in Eastern Time selects "Dec 8, 2:00 PM")
Convert to UTC: "2024-12-08T19:00:00Z"
Send to API (always UTC)
DATABASE (stores UTC)
API RESPONSE:
{
"start_time": "2024-12-08T19:00:00Z", // Always UTC
"business_timezone": "America/Denver" // IANA timezone (or null for local)
}
FRONTEND CONVERTS:
- If business_timezone set: UTC → Mountain Time → "Dec 8, 12:00 PM MST"
- If business_timezone null: UTC → User local → "Dec 8, 2:00 PM EST"
```
### Frontend Helper Functions
Located in `frontend/src/utils/dateUtils.ts`:
```typescript
import {
toUTC,
fromUTC,
formatForDisplay,
formatDateForDisplay,
getDisplayTimezone,
} from '../utils/dateUtils';
// SENDING TO API - Always convert to UTC
const apiPayload = {
start_time: toUTC(selectedDateTime), // "2024-12-08T19:00:00Z"
};
// RECEIVING FROM API - Convert for display
const displayTime = formatForDisplay(
response.start_time, // UTC from API
response.business_timezone // "America/Denver" or null
);
// Result: "Dec 8, 2024 12:00 PM" (in business or local timezone)
// DATE-ONLY fields (time blocks)
const displayDate = formatDateForDisplay(
response.start_date,
response.business_timezone
);
```
### API Response Requirements
All endpoints returning date/time data MUST include:
```python
# In serializers or views
{
"start_time": "2024-12-08T19:00:00Z",
"business_timezone": business.timezone, # "America/Denver" or None
}
```
### Backend Serializer Mixin
Use `TimezoneSerializerMixin` from `core/mixins.py` to automatically add the timezone field:
```python
from core.mixins import TimezoneSerializerMixin
class EventSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
class Meta:
model = Event
fields = [
'id', 'start_time', 'end_time',
# ... other fields ...
'business_timezone', # Provided by mixin
]
read_only_fields = ['business_timezone']
```
The mixin automatically retrieves the timezone from the tenant context.
- Returns the IANA timezone string if set (e.g., "America/Denver")
- Returns `null` if not set (frontend uses user's local timezone)
### Common Mistakes to Avoid
```typescript
// BAD - Uses browser local time, not UTC
date.toISOString().split('T')[0]
// BAD - Doesn't respect business timezone setting
new Date(utcString).toLocaleString()
// GOOD - Use helper functions
toUTC(date) // For API requests
formatForDisplay(utcString, businessTimezone) // For displaying
```
## Django App Organization (Domain-Based)
Apps are organized into domain packages under `smoothschedule/smoothschedule/`:
### Identity Domain
| App | Location | Purpose |
|-----|----------|---------|
| `schedule` | `smoothschedule/smoothschedule/schedule/` | Resources, Events, Services |
| `users` | `smoothschedule/smoothschedule/users/` | Authentication, User model |
| `tenants` | `smoothschedule/smoothschedule/tenants/` | Multi-tenancy (Business model) |
| `core` | `identity/core/` | Tenant, Domain, PermissionGrant, middleware, mixins |
| `users` | `identity/users/` | User model, authentication, MFA |
### Scheduling Domain
| App | Location | Purpose |
|-----|----------|---------|
| `schedule` | `scheduling/schedule/` | Resources, Events, Services, Participants |
| `contracts` | `scheduling/contracts/` | Contract/e-signature system |
| `analytics` | `scheduling/analytics/` | Business analytics and reporting |
### Communication Domain
| App | Location | Purpose |
|-----|----------|---------|
| `notifications` | `communication/notifications/` | Notification system |
| `credits` | `communication/credits/` | SMS/calling credits |
| `mobile` | `communication/mobile/` | Field employee mobile app |
| `messaging` | `communication/messaging/` | Email templates and messaging |
### Commerce Domain
| App | Location | Purpose |
|-----|----------|---------|
| `payments` | `commerce/payments/` | Stripe Connect payments bridge |
| `tickets` | `commerce/tickets/` | Support ticket system |
### Platform Domain
| App | Location | Purpose |
|-----|----------|---------|
| `admin` | `platform/admin/` | Platform administration, subscriptions |
| `api` | `platform/api/` | Public API v1 for third-party integrations |
## Core Mixins & Base Classes
Located in `smoothschedule/smoothschedule/identity/core/mixins.py`. Use these to avoid code duplication.
### Permission Classes
```python
from smoothschedule.identity.core.mixins import DenyStaffWritePermission, DenyStaffAllAccessPermission, DenyStaffListPermission
class MyViewSet(ModelViewSet):
# Block write operations for staff (GET allowed)
permission_classes = [IsAuthenticated, DenyStaffWritePermission]
# Block ALL operations for staff
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
# Block list/create/update/delete but allow retrieve
permission_classes = [IsAuthenticated, DenyStaffListPermission]
```
#### Per-User Permission Overrides
Staff permissions can be overridden on a per-user basis using the `user.permissions` JSONField.
Permission keys are auto-derived from the view's basename or model name:
| Permission Class | Auto-derived Key | Example |
|-----------------|------------------|---------|
| `DenyStaffWritePermission` | `can_write_{basename}` | `can_write_resources` |
| `DenyStaffAllAccessPermission` | `can_access_{basename}` | `can_access_services` |
| `DenyStaffListPermission` | `can_list_{basename}` or `can_access_{basename}` | `can_list_customers` |
**Current ViewSet permission keys:**
| ViewSet | Permission Class | Override Key |
|---------|-----------------|--------------|
| `ResourceViewSet` | `DenyStaffAllAccessPermission` | `can_access_resources` |
| `ServiceViewSet` | `DenyStaffAllAccessPermission` | `can_access_services` |
| `CustomerViewSet` | `DenyStaffListPermission` | `can_list_customers` or `can_access_customers` |
| `ScheduledTaskViewSet` | `DenyStaffAllAccessPermission` | `can_access_scheduled-tasks` |
**Granting a specific staff member access:**
```bash
# Open Django shell
docker compose -f docker-compose.local.yml exec django python manage.py shell
```
```python
from smoothschedule.identity.users.models import User
# Find the staff member
staff = User.objects.get(email='john@example.com')
# Grant read access to resources
staff.permissions['can_access_resources'] = True
staff.save()
# Or grant list access to customers (but not full CRUD)
staff.permissions['can_list_customers'] = True
staff.save()
```
**Custom permission keys (optional):**
```python
class ResourceViewSet(ModelViewSet):
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
# Override the auto-derived key
staff_access_permission_key = 'can_manage_equipment'
```
Then grant via: `staff.permissions['can_manage_equipment'] = True`
### QuerySet Mixins
```python
from smoothschedule.identity.core.mixins import TenantFilteredQuerySetMixin, UserTenantFilteredMixin
# For tenant-scoped models (automatic django-tenants filtering)
class ResourceViewSet(TenantFilteredQuerySetMixin, ModelViewSet):
queryset = Resource.objects.all()
deny_staff_queryset = True # Optional: also filter staff at queryset level
def filter_queryset_for_tenant(self, queryset):
# Override for custom filtering
return queryset.filter(is_active=True)
# For User model (shared schema, needs explicit tenant filter)
class CustomerViewSet(UserTenantFilteredMixin, ModelViewSet):
queryset = User.objects.filter(role=User.Role.CUSTOMER)
```
### Feature Permission Mixins
```python
from smoothschedule.identity.core.mixins import PluginFeatureRequiredMixin, TaskFeatureRequiredMixin
# Checks can_use_plugins feature on list/retrieve/create
class PluginViewSet(PluginFeatureRequiredMixin, ModelViewSet):
pass
# Checks both can_use_plugins AND can_use_tasks
class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin, ModelViewSet):
pass
```
### Base API Views (for non-ViewSet views)
```python
from rest_framework.views import APIView
from smoothschedule.identity.core.mixins import TenantAPIView, TenantRequiredAPIView
# Optional tenant - use self.get_tenant()
class MyView(TenantAPIView, APIView):
def get(self, request):
tenant = self.get_tenant() # May be None
return self.success_response({'data': 'value'})
# or: return self.error_response('Something went wrong', status_code=400)
# Required tenant - self.tenant always available
class MyTenantView(TenantRequiredAPIView, APIView):
def get(self, request):
# self.tenant is guaranteed to exist (returns 400 if missing)
return Response({'name': self.tenant.name})
```
### Helper Methods Available
| Method | Description |
|--------|-------------|
| `self.get_tenant()` | Get tenant from request (may be None) |
| `self.get_tenant_or_error()` | Returns (tenant, error_response) tuple |
| `self.error_response(msg, status_code)` | Standard error response |
| `self.success_response(data, status_code)` | Standard success response |
| `self.check_feature(key, name)` | Check feature permission, returns error or None |
## Common Tasks

383
PLAN_APP_REORGANIZATION.md Normal file
View File

@@ -0,0 +1,383 @@
# Django App Reorganization Plan - Option C (Domain-Based)
## Overview
Reorganize Django apps from their current scattered locations into a clean domain-based structure within `smoothschedule/smoothschedule/`.
**Branch:** `refactor/organize-django-apps`
**Risk Level:** Medium-High (migration history must be preserved)
**Estimated Parallel Agents:** 6-8
---
## Current State Analysis
### Current App Locations (Inconsistent)
| App | Current Location | Registered As |
|-----|-----------------|---------------|
| core | `smoothschedule/core/` | `"core"` |
| schedule | `smoothschedule/schedule/` | `"schedule"` |
| payments | `smoothschedule/payments/` | `"payments"` |
| platform_admin | `smoothschedule/platform_admin/` | `"platform_admin.apps.PlatformAdminConfig"` |
| analytics | `smoothschedule/analytics/` | `"analytics"` |
| notifications | `smoothschedule/notifications/` | `"notifications"` |
| tickets | `smoothschedule/tickets/` | `"tickets"` |
| contracts | `smoothschedule/contracts/` | **NOT REGISTERED** |
| communication | `smoothschedule/communication/` | **NOT REGISTERED** |
| users | `smoothschedule/smoothschedule/users/` | `"smoothschedule.users"` |
| comms_credits | `smoothschedule/smoothschedule/comms_credits/` | `"smoothschedule.comms_credits"` |
| field_mobile | `smoothschedule/smoothschedule/field_mobile/` | `"smoothschedule.field_mobile"` |
| public_api | `smoothschedule/smoothschedule/public_api/` | `"smoothschedule.public_api"` |
### Migration Counts by App
| App | Migrations | Complexity |
|-----|------------|------------|
| core | 22 | High (Tenant model) |
| schedule | 30 | High (main business logic) |
| payments | 1 | Low |
| platform_admin | 12 | Medium |
| users | 10 | Medium |
| tickets | 13 | Medium |
| contracts | 1 | Low |
| notifications | 1 | Low |
| comms_credits | 2 | Low |
| field_mobile | 1 | Low |
| public_api | 3 | Low |
| analytics | 0 | None |
| communication | 1 | Low |
---
## Target Structure (Option C - Domain-Based)
```
smoothschedule/smoothschedule/
├── __init__.py
├── identity/ # User & Tenant Management
│ ├── __init__.py
│ ├── core/ # Multi-tenancy, permissions, OAuth
│ │ └── (moved from smoothschedule/core/)
│ └── users/ # User model, auth, invitations
│ └── (keep at current location, just move parent)
├── scheduling/ # Core Business Logic
│ ├── __init__.py
│ ├── schedule/ # Resources, Events, Services, Plugins
│ │ └── (moved from smoothschedule/schedule/)
│ ├── contracts/ # E-signatures, legal documents
│ │ └── (moved from smoothschedule/contracts/)
│ └── analytics/ # Reporting, dashboards
│ └── (moved from smoothschedule/analytics/)
├── communication/ # Messaging & Notifications
│ ├── __init__.py
│ ├── notifications/ # In-app notifications
│ │ └── (moved from smoothschedule/notifications/)
│ ├── credits/ # SMS/voice credits (renamed from comms_credits)
│ │ └── (moved from smoothschedule/smoothschedule/comms_credits/)
│ ├── mobile/ # Field employee app (renamed from field_mobile)
│ │ └── (moved from smoothschedule/smoothschedule/field_mobile/)
│ └── messaging/ # Twilio conversations (renamed from communication)
│ └── (moved from smoothschedule/communication/)
├── commerce/ # Payments & Support
│ ├── __init__.py
│ ├── payments/ # Stripe Connect, transactions
│ │ └── (moved from smoothschedule/payments/)
│ └── tickets/ # Support tickets, email integration
│ └── (moved from smoothschedule/tickets/)
└── platform/ # Platform Administration
├── __init__.py
├── admin/ # Platform settings, subscriptions (renamed)
│ └── (moved from smoothschedule/platform_admin/)
└── api/ # Public API v1 (renamed from public_api)
└── (moved from smoothschedule/smoothschedule/public_api/)
```
---
## Critical Constraints
### 1. Migration History Preservation
Django migrations contain the app label in their `dependencies` and `app_label` references. We MUST:
- **Keep `app_label` unchanged** in each app's `Meta` class
- Update `AppConfig.name` to the new dotted path
- Django will use the `app_label` (not the path) for migration tracking
### 2. Foreign Key String References
Models use string references like `'users.User'` and `'core.Tenant'`. These reference `app_label`, not the module path, so they remain valid.
### 3. Import Path Updates
All imports across the codebase must be updated:
- `from core.models import Tenant``from smoothschedule.identity.core.models import Tenant`
- `from schedule.models import Event``from smoothschedule.scheduling.schedule.models import Event`
### 4. URL Configuration
`config/urls.py` imports views directly - all import paths must be updated.
### 5. Settings Files
- `config/settings/base.py` - `LOCAL_APPS`
- `config/settings/multitenancy.py` - `SHARED_APPS`, `TENANT_APPS`
---
## Implementation Phases
### Phase 1: Preparation (Serial)
**Agent 1: Setup & Verification**
1. Create all domain package directories with `__init__.py` files
2. Verify Docker is running and database is accessible
3. Run existing tests to establish baseline
4. Create backup of current migration state
```bash
# Create domain packages
mkdir -p smoothschedule/smoothschedule/identity
mkdir -p smoothschedule/smoothschedule/scheduling
mkdir -p smoothschedule/smoothschedule/communication
mkdir -p smoothschedule/smoothschedule/commerce
mkdir -p smoothschedule/smoothschedule/platform
# Create __init__.py files
touch smoothschedule/smoothschedule/identity/__init__.py
touch smoothschedule/smoothschedule/scheduling/__init__.py
touch smoothschedule/smoothschedule/communication/__init__.py
touch smoothschedule/smoothschedule/commerce/__init__.py
touch smoothschedule/smoothschedule/platform/__init__.py
```
---
### Phase 2: Move Apps (Parallel - 5 Agents)
Each agent handles one domain. For each app move:
1. **Move directory** to new location
2. **Update `apps.py`** - change `name` to new dotted path, keep `label` same
3. **Update internal imports** within the app
4. **Add explicit `app_label`** to all model Meta classes (if not present)
#### Agent 2: Identity Domain
Move and update:
- `smoothschedule/core/``smoothschedule/smoothschedule/identity/core/`
- `smoothschedule/smoothschedule/users/``smoothschedule/smoothschedule/identity/users/`
**apps.py changes:**
```python
# identity/core/apps.py
class CoreConfig(AppConfig):
name = "smoothschedule.identity.core" # NEW
label = "core" # KEEP SAME
verbose_name = "Core"
# identity/users/apps.py
class UsersConfig(AppConfig):
name = "smoothschedule.identity.users" # NEW
label = "users" # KEEP SAME
```
#### Agent 3: Scheduling Domain
Move and update:
- `smoothschedule/schedule/``smoothschedule/smoothschedule/scheduling/schedule/`
- `smoothschedule/contracts/``smoothschedule/smoothschedule/scheduling/contracts/`
- `smoothschedule/analytics/``smoothschedule/smoothschedule/scheduling/analytics/`
#### Agent 4: Communication Domain
Move and update:
- `smoothschedule/notifications/``smoothschedule/smoothschedule/communication/notifications/`
- `smoothschedule/smoothschedule/comms_credits/``smoothschedule/smoothschedule/communication/credits/`
- `smoothschedule/smoothschedule/field_mobile/``smoothschedule/smoothschedule/communication/mobile/`
- `smoothschedule/communication/``smoothschedule/smoothschedule/communication/messaging/`
**Note:** Rename apps for clarity:
- `comms_credits` label stays same, path changes
- `field_mobile` label stays same, path changes
- `communication` label stays same, path changes
#### Agent 5: Commerce Domain
Move and update:
- `smoothschedule/payments/``smoothschedule/smoothschedule/commerce/payments/`
- `smoothschedule/tickets/``smoothschedule/smoothschedule/commerce/tickets/`
#### Agent 6: Platform Domain
Move and update:
- `smoothschedule/platform_admin/``smoothschedule/smoothschedule/platform/admin/`
- `smoothschedule/smoothschedule/public_api/``smoothschedule/smoothschedule/platform/api/`
---
### Phase 3: Update Settings (Serial)
**Agent 7: Settings Configuration**
Update `config/settings/base.py`:
```python
LOCAL_APPS = [
# Identity
"smoothschedule.identity.users",
"smoothschedule.identity.core",
# Scheduling
"smoothschedule.scheduling.schedule",
"smoothschedule.scheduling.contracts",
"smoothschedule.scheduling.analytics",
# Communication
"smoothschedule.communication.notifications",
"smoothschedule.communication.credits",
"smoothschedule.communication.mobile",
"smoothschedule.communication.messaging",
# Commerce
"smoothschedule.commerce.payments",
"smoothschedule.commerce.tickets",
# Platform
"smoothschedule.platform.admin",
"smoothschedule.platform.api",
]
```
Update `config/settings/multitenancy.py`:
```python
SHARED_APPS = [
'django_tenants',
'smoothschedule.identity.core',
'smoothschedule.platform.admin',
# ... rest of shared apps with new paths
]
TENANT_APPS = [
'django.contrib.contenttypes',
'smoothschedule.scheduling.schedule',
'smoothschedule.commerce.payments',
'smoothschedule.scheduling.contracts',
]
```
---
### Phase 4: Update All Import Paths (Parallel - Multiple Agents)
**This is the largest task.** Each agent handles specific import patterns:
#### Agent 8: Core Imports
Find and replace across entire codebase:
- `from core.models import``from smoothschedule.identity.core.models import`
- `from core.``from smoothschedule.identity.core.`
- `import core``import smoothschedule.identity.core as core`
#### Agent 9: Schedule Imports
- `from schedule.models import``from smoothschedule.scheduling.schedule.models import`
- `from schedule.``from smoothschedule.scheduling.schedule.`
#### Agent 10: Users/Auth Imports
- `from smoothschedule.users.``from smoothschedule.identity.users.`
- `from users.``from smoothschedule.identity.users.`
#### Agent 11: Other App Imports
Handle remaining apps:
- payments, tickets, notifications, contracts, analytics
- platform_admin, public_api, comms_credits, field_mobile, communication
---
### Phase 5: URL Configuration Updates (Serial)
**Agent 12: URL Updates**
Update `config/urls.py` with new import paths:
```python
# Old
from schedule.views import ResourceViewSet, EventViewSet
from core.api_views import business_current
# New
from smoothschedule.scheduling.schedule.views import ResourceViewSet, EventViewSet
from smoothschedule.identity.core.api_views import business_current
```
---
### Phase 6: Cleanup & Verification (Serial)
**Agent 13: Cleanup**
1. Remove old empty directories at top level
2. Remove deprecated `smoothschedule/smoothschedule/schedule/` directory
3. Update `CLAUDE.md` documentation
4. Update any remaining references
**Agent 14: Verification**
1. Run `docker compose exec django python manage.py check`
2. Run `docker compose exec django python manage.py makemigrations --check`
3. Run `docker compose exec django python manage.py migrate --check`
4. Run test suite
5. Manual smoke test of key endpoints
---
## App Label Mapping Reference
| Old Import Path | New Import Path | app_label (unchanged) |
|----------------|-----------------|----------------------|
| `core` | `smoothschedule.identity.core` | `core` |
| `smoothschedule.users` | `smoothschedule.identity.users` | `users` |
| `schedule` | `smoothschedule.scheduling.schedule` | `schedule` |
| `contracts` | `smoothschedule.scheduling.contracts` | `contracts` |
| `analytics` | `smoothschedule.scheduling.analytics` | `analytics` |
| `notifications` | `smoothschedule.communication.notifications` | `notifications` |
| `smoothschedule.comms_credits` | `smoothschedule.communication.credits` | `comms_credits` |
| `smoothschedule.field_mobile` | `smoothschedule.communication.mobile` | `field_mobile` |
| `communication` | `smoothschedule.communication.messaging` | `communication` |
| `payments` | `smoothschedule.commerce.payments` | `payments` |
| `tickets` | `smoothschedule.commerce.tickets` | `tickets` |
| `platform_admin` | `smoothschedule.platform.admin` | `platform_admin` |
| `smoothschedule.public_api` | `smoothschedule.platform.api` | `public_api` |
---
## Rollback Plan
If issues are encountered:
1. **Git Reset:** `git checkout main` and delete branch
2. **Database:** No migration changes, database remains intact
3. **Docker:** Rebuild containers if needed
---
## Success Criteria
- [ ] All apps moved to domain-based structure
- [ ] `python manage.py check` passes
- [ ] `python manage.py makemigrations --check` shows no changes
- [ ] All existing tests pass
- [ ] Frontend can communicate with API
- [ ] Mobile app can communicate with API
- [ ] CLAUDE.md updated with new structure
---
## Execution Order
```
Phase 1 (Serial): Agent 1 - Setup
Phase 2 (Parallel): Agents 2-6 - Move apps by domain
Phase 3 (Serial): Agent 7 - Update settings
Phase 4 (Parallel): Agents 8-11 - Update imports
Phase 5 (Serial): Agent 12 - URL updates
Phase 6 (Serial): Agents 13-14 - Cleanup & verify
```
**Total Agents:** 14 (8 can run in parallel at peak)

655
README.md
View File

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

View File

@@ -1,7 +1,12 @@
#!/bin/bash
# SmoothSchedule Production Deployment Script
# Usage: ./deploy.sh [server_user@server_host]
# Example: ./deploy.sh poduck@smoothschedule.com
# Usage: ./deploy.sh [server_user@server_host] [services...]
# Example: ./deploy.sh poduck@smoothschedule.com # Build all
# Example: ./deploy.sh poduck@smoothschedule.com traefik # Build only traefik
# Example: ./deploy.sh poduck@smoothschedule.com django nginx # Build django and nginx
#
# Available services: django, traefik, nginx, postgres, celeryworker, celerybeat, flower, awscli
# Use --no-migrate to skip migrations (useful for config-only changes like traefik)
#
# This script deploys from git repository, not local files.
# Changes must be committed and pushed before deploying.
@@ -14,7 +19,23 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
SERVER=${1:-"poduck@smoothschedule.com"}
# Parse arguments
SERVER=""
SERVICES=""
SKIP_MIGRATE=false
for arg in "$@"; do
if [[ "$arg" == "--no-migrate" ]]; then
SKIP_MIGRATE=true
elif [[ -z "$SERVER" ]]; then
SERVER="$arg"
else
SERVICES="$SERVICES $arg"
fi
done
SERVER=${SERVER:-"poduck@smoothschedule.com"}
SERVICES=$(echo "$SERVICES" | xargs) # Trim whitespace
REPO_URL="https://git.talova.net/poduck/smoothschedule.git"
REMOTE_DIR="/home/poduck/smoothschedule"
@@ -22,6 +43,14 @@ echo -e "${GREEN}==================================="
echo "SmoothSchedule Deployment"
echo "===================================${NC}"
echo "Target server: $SERVER"
if [[ -n "$SERVICES" ]]; then
echo "Services to rebuild: $SERVICES"
else
echo "Services to rebuild: ALL"
fi
if [[ "$SKIP_MIGRATE" == "true" ]]; then
echo "Migrations: SKIPPED"
fi
echo ""
# Function to print status
@@ -128,35 +157,46 @@ fi
echo ">>> Current commit:"
git log -1 --oneline
echo ">>> Building Docker images..."
cd smoothschedule
docker compose -f docker-compose.production.yml build
# Build images (all or specific services)
if [[ -n "$SERVICES" ]]; then
echo ">>> Building Docker images: $SERVICES..."
docker compose -f docker-compose.production.yml build $SERVICES
else
echo ">>> Building all Docker images..."
docker compose -f docker-compose.production.yml build
fi
echo ">>> Starting containers..."
docker compose -f docker-compose.production.yml up -d
echo ">>> Waiting for containers to start..."
sleep 10
sleep 5
echo ">>> Running database migrations..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} && python manage.py migrate'
# Run migrations unless skipped
if [[ "$SKIP_MIGRATE" != "true" ]]; then
echo ">>> Running database migrations..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} && python manage.py migrate'
echo ">>> Collecting static files..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} && python manage.py collectstatic --noinput'
echo ">>> Collecting static files..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} && python manage.py collectstatic --noinput'
echo ">>> Seeding/updating platform plugins for all tenants..."
docker compose -f docker-compose.production.yml exec -T django python -c "
echo ">>> Seeding/updating platform plugins for all tenants..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} && python -c "
import django
django.setup()
from django_tenants.utils import get_tenant_model
from django.core.management import call_command
Tenant = get_tenant_model()
for tenant in Tenant.objects.exclude(schema_name='public'):
print(f' Seeding plugins for {tenant.schema_name}...')
call_command('tenant_command', 'seed_platform_plugins', schema=tenant.schema_name, verbosity=0)
print(' Done!')
"
echo ">>> Checking container status..."
docker compose -f docker-compose.production.yml ps
for tenant in Tenant.objects.exclude(schema_name=\"public\"):
print(f\" Seeding plugins for {tenant.schema_name}...\")
call_command(\"tenant_command\", \"seed_platform_plugins\", schema=tenant.schema_name, verbosity=0)
print(\" Done!\")
"'
else
echo ">>> Skipping migrations (--no-migrate flag used)"
fi
echo ">>> Deployment complete!"
ENDSSH

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

@@ -1,3 +1,4 @@
VITE_DEV_MODE=true
VITE_API_URL=http://api.lvh.me:8000
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51Sa2i4G4IkZ6cJFI77f9dXf1ljmDPAInxbjLCJRRJk4ng1qmJKtWEqkFcDuoVcAdQsxcMH1L1UiQFfPwy8OmLSaz008GsGQ63y
VITE_GOOGLE_MAPS_API_KEY=

View File

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

View File

@@ -2,10 +2,31 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="120x120" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- CSP: Disabled in development due to browser extension conflicts. Enable in production via server headers. -->
<title>Smooth Schedule - Multi-Tenant Scheduling</title>
<title>Smooth Schedule | Online Appointment Scheduling Software</title>
<meta name="description" content="The all-in-one scheduling platform for service businesses. Manage appointments, staff, and payments effortlessly. Start free today." />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Smooth Schedule | Online Appointment Scheduling Software" />
<meta property="og:description" content="The all-in-one scheduling platform for service businesses. Manage appointments, staff, and payments effortlessly." />
<meta property="og:image" content="https://smoothschedule.com/og-image.png" />
<meta property="og:url" content="https://smoothschedule.com" />
<meta property="og:site_name" content="Smooth Schedule" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Smooth Schedule | Online Appointment Scheduling Software" />
<meta name="twitter:description" content="The all-in-one scheduling platform for service businesses. Manage appointments, staff, and payments effortlessly." />
<meta name="twitter:image" content="https://smoothschedule.com/og-image.png" />
<!-- Additional SEO -->
<meta name="robots" content="noindex, nofollow" />
<meta name="author" content="Smooth Schedule Inc." />
<link rel="canonical" href="https://smoothschedule.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* Ensure full height for the app */

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@measured/puck": "^0.20.2",
"@react-google-maps/api": "^2.20.7",
"@stripe/connect-js": "^3.3.31",
"@stripe/react-connect-js": "^3.3.31",
"@stripe/react-stripe-js": "^5.4.1",
@@ -34,27 +36,38 @@
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.48.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.15",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^27.2.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^4.0.15"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed"
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,5 +1,12 @@
# robots.txt - SmoothSchedule
# Deny all robots while in development
# Currently blocking all crawlers - site not yet live
User-agent: *
Disallow: /
# When ready to go live, replace above with:
# User-agent: *
# Allow: /
# Disallow: /api/
# Disallow: /admin/
# Sitemap: https://smoothschedule.com/sitemap.xml

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://smoothschedule.com/</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://smoothschedule.com/features</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://smoothschedule.com/pricing</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://smoothschedule.com/about</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://smoothschedule.com/contact</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://smoothschedule.com/signup</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://smoothschedule.com/privacy</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://smoothschedule.com/terms</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>

View File

@@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth';
import { useCurrentBusiness } from './hooks/useBusiness';
import { useUpdateBusiness } from './hooks/useBusiness';
import { usePlanFeatures } from './hooks/usePlanFeatures';
import { setCookie } from './utils/cookies';
// Import Login Page
@@ -34,21 +35,28 @@ const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfSer
// Import pages
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const StaffDashboard = React.lazy(() => import('./pages/StaffDashboard'));
const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule'));
const Scheduler = React.lazy(() => import('./pages/Scheduler'));
const Customers = React.lazy(() => import('./pages/Customers'));
const Settings = React.lazy(() => import('./pages/Settings'));
const Payments = React.lazy(() => import('./pages/Payments'));
const Messages = React.lazy(() => import('./pages/Messages'));
const Resources = React.lazy(() => import('./pages/Resources'));
const Services = React.lazy(() => import('./pages/Services'));
const Staff = React.lazy(() => import('./pages/Staff'));
const TimeBlocks = React.lazy(() => import('./pages/TimeBlocks'));
const MyAvailability = React.lazy(() => import('./pages/MyAvailability'));
const CustomerDashboard = React.lazy(() => import('./pages/customer/CustomerDashboard'));
const CustomerSupport = React.lazy(() => import('./pages/customer/CustomerSupport'));
const ResourceDashboard = React.lazy(() => import('./pages/resource/ResourceDashboard'));
const BookingPage = React.lazy(() => import('./pages/customer/BookingPage'));
const CustomerBilling = React.lazy(() => import('./pages/customer/CustomerBilling'));
const TrialExpired = React.lazy(() => import('./pages/TrialExpired'));
const Upgrade = React.lazy(() => import('./pages/Upgrade'));
// Import platform pages
const PlatformLoginPage = React.lazy(() => import('./pages/platform/PlatformLoginPage'));
const PlatformDashboard = React.lazy(() => import('./pages/platform/PlatformDashboard'));
const PlatformBusinesses = React.lazy(() => import('./pages/platform/PlatformBusinesses'));
const PlatformSupportPage = React.lazy(() => import('./pages/platform/PlatformSupport'));
@@ -56,11 +64,13 @@ const PlatformEmailAddresses = React.lazy(() => import('./pages/platform/Platfor
const PlatformUsers = React.lazy(() => import('./pages/platform/PlatformUsers'));
const 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'));
const AcceptInvitePage = React.lazy(() => import('./pages/AcceptInvitePage'));
const TenantOnboardPage = React.lazy(() => import('./pages/TenantOnboardPage'));
const TenantLandingPage = React.lazy(() => import('./pages/TenantLandingPage'));
const Tickets = React.lazy(() => import('./pages/Tickets')); // Import Tickets page
const HelpGuide = React.lazy(() => import('./pages/HelpGuide')); // Import Platform Guide page
const HelpTicketing = React.lazy(() => import('./pages/HelpTicketing')); // Import Help page for ticketing
@@ -76,8 +86,10 @@ const HelpCustomers = React.lazy(() => import('./pages/help/HelpCustomers'));
const HelpServices = React.lazy(() => import('./pages/help/HelpServices'));
const HelpResources = React.lazy(() => import('./pages/help/HelpResources'));
const HelpStaff = React.lazy(() => import('./pages/help/HelpStaff'));
const HelpTimeBlocks = React.lazy(() => import('./pages/HelpTimeBlocks'));
const HelpMessages = React.lazy(() => import('./pages/help/HelpMessages'));
const HelpPayments = React.lazy(() => import('./pages/help/HelpPayments'));
const HelpContracts = React.lazy(() => import('./pages/help/HelpContracts'));
const HelpPlugins = React.lazy(() => import('./pages/help/HelpPlugins'));
const HelpSettingsGeneral = React.lazy(() => import('./pages/help/HelpSettingsGeneral'));
const HelpSettingsResourceTypes = React.lazy(() => import('./pages/help/HelpSettingsResourceTypes'));
@@ -90,12 +102,21 @@ const HelpSettingsAuth = React.lazy(() => import('./pages/help/HelpSettingsAuth'
const HelpSettingsBilling = React.lazy(() => import('./pages/help/HelpSettingsBilling'));
const HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuota'));
const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive'));
const StaffHelp = React.lazy(() => import('./pages/help/StaffHelp'));
const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule)
const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin')); // Import Create Plugin page
const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled plugin executions
const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Import Email Templates page
const Contracts = React.lazy(() => import('./pages/Contracts')); // Import Contracts page
const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates')); // Import Contract Templates page
const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public)
const PageEditor = React.lazy(() => import('./pages/PageEditor')); // Import PageEditor
const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import PublicPage
const BookingFlow = React.lazy(() => import('./pages/BookingFlow')); // Import Booking Flow
const Locations = React.lazy(() => import('./pages/Locations')); // Import Locations management page
const MediaGalleryPage = React.lazy(() => import('./pages/MediaGalleryPage')); // Import Media Gallery page
// Settings pages
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
@@ -110,6 +131,7 @@ const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'))
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
const 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
@@ -183,6 +205,7 @@ const AppContent: React.FC = () => {
const updateBusinessMutation = useUpdateBusiness();
const masqueradeMutation = useMasquerade();
const logoutMutation = useLogout();
const { canUse } = usePlanFeatures();
// Apply dark mode class and persist to localStorage
React.useEffect(() => {
@@ -190,6 +213,30 @@ const AppContent: React.FC = () => {
localStorage.setItem('darkMode', JSON.stringify(darkMode));
}, [darkMode]);
// Set noindex/nofollow for app subdomains (platform, business subdomains)
// Only the root domain marketing pages should be indexed
React.useEffect(() => {
const hostname = window.location.hostname;
const parts = hostname.split('.');
const hasSubdomain = parts.length > 2 || (parts.length === 2 && parts[0] !== 'localhost');
// Check if we're on a subdomain (platform.*, demo.*, etc.)
const isSubdomain = hostname !== 'localhost' && hostname !== '127.0.0.1' && parts.length > 2;
if (isSubdomain) {
// Always noindex/nofollow on subdomains (app areas)
let metaRobots = document.querySelector('meta[name="robots"]');
if (metaRobots) {
metaRobots.setAttribute('content', 'noindex, nofollow');
} else {
metaRobots = document.createElement('meta');
metaRobots.setAttribute('name', 'robots');
metaRobots.setAttribute('content', 'noindex, nofollow');
document.head.appendChild(metaRobots);
}
}
}, []);
// Handle tokens in URL (from login or masquerade redirect)
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
@@ -281,35 +328,70 @@ const AppContent: React.FC = () => {
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
);
}
// Not authenticated - redirect to root domain for login if on subdomain
// Not authenticated - show appropriate page based on subdomain
if (!user) {
// If on a subdomain, redirect to root domain login page
const currentHostname = window.location.hostname;
const hostnameParts = currentHostname.split('.');
const baseDomain = hostnameParts.length >= 2
? hostnameParts.slice(-2).join('.')
: currentHostname;
const isRootDomainForUnauthUser = currentHostname === baseDomain || currentHostname === 'localhost';
const isPlatformSubdomain = hostnameParts[0] === 'platform';
const currentSubdomain = hostnameParts[0];
// Don't redirect for certain public paths that should work on any subdomain
const publicPaths = ['/accept-invite', '/verify-email', '/tenant-onboard'];
const currentPath = window.location.pathname;
const isPublicPath = publicPaths.some(path => currentPath.startsWith(path));
// Check if we're on a business subdomain (not root, not platform, not api)
const isBusinessSubdomain = !isRootDomainForUnauthUser && !isPlatformSubdomain && currentSubdomain !== 'api';
if (!isRootDomainForUnauthUser && !isPublicPath) {
// Redirect to root domain login (preserve port)
const protocol = window.location.protocol;
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `${protocol}//${baseDomain}${port}/login`;
return <LoadingScreen />;
// For business subdomains, show the tenant landing page with login option
if (isBusinessSubdomain) {
return (
<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 />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
);
}
// For platform subdomain, only /platform/login exists - everything else renders nothing
if (isPlatformSubdomain) {
const path = window.location.pathname;
const allowedPaths = ['/platform/login', '/mfa-verify', '/verify-email'];
// If not an allowed path, render nothing
if (!allowedPaths.includes(path)) {
return null;
}
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/platform/login" element={<PlatformLoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/verify-email" element={<VerifyEmail />} />
</Routes>
</Suspense>
);
}
// For root domain, show marketing site with business user login
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
@@ -330,6 +412,7 @@ const AppContent: React.FC = () => {
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
@@ -437,7 +520,10 @@ const AppContent: React.FC = () => {
<Route path="/help/plugins" element={<HelpPluginDocs />} />
<Route path="/help/email" element={<HelpEmailSettings />} />
{user.role === 'superuser' && (
<Route path="/platform/settings" element={<PlatformSettings />} />
<>
<Route path="/platform/settings" element={<PlatformSettings />} />
<Route path="/platform/billing" element={<BillingManagement />} />
</>
)}
<Route path="/platform/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
@@ -501,7 +587,7 @@ const AppContent: React.FC = () => {
>
<Route path="/" element={<CustomerDashboard />} />
<Route path="/book" element={<BookingPage />} />
<Route path="/payments" element={<Payments />} />
<Route path="/payments" element={<CustomerBilling />} />
<Route path="/support" element={<CustomerSupport />} />
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
@@ -571,7 +657,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));
@@ -580,15 +666,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>
);
@@ -597,6 +683,13 @@ const AppContent: React.FC = () => {
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
{/* Public routes outside BusinessLayout */}
<Route path="/" element={<PublicPage />} />
<Route path="/book" element={<BookingFlow />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
{/* Dashboard routes inside BusinessLayout */}
<Route
element={
<BusinessLayout
@@ -610,161 +703,251 @@ const AppContent: React.FC = () => {
}
>
{/* 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="/"
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
path="/dashboard"
element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
/>
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/tickets" element={<Tickets />} />
<Route path="/help" element={<HelpComprehensive />} />
<Route path="/help/guide" element={<HelpGuide />} />
<Route path="/help/ticketing" element={<HelpTicketing />} />
<Route path="/help/api" element={<HelpApiDocs />} />
<Route path="/help/plugins/docs" element={<HelpPluginDocs />} />
<Route path="/help/email" element={<HelpEmailSettings />} />
{/* New help pages */}
<Route path="/help/dashboard" element={<HelpDashboard />} />
<Route path="/help/scheduler" element={<HelpScheduler />} />
<Route path="/help/tasks" element={<HelpTasks />} />
<Route path="/help/customers" element={<HelpCustomers />} />
<Route path="/help/services" element={<HelpServices />} />
<Route path="/help/resources" element={<HelpResources />} />
<Route path="/help/staff" element={<HelpStaff />} />
<Route path="/help/messages" element={<HelpMessages />} />
<Route path="/help/payments" element={<HelpPayments />} />
<Route path="/help/plugins" element={<HelpPlugins />} />
<Route path="/help/settings/general" element={<HelpSettingsGeneral />} />
<Route path="/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
<Route path="/help/settings/booking" element={<HelpSettingsBooking />} />
<Route path="/help/settings/appearance" element={<HelpSettingsAppearance />} />
<Route path="/help/settings/email" element={<HelpSettingsEmail />} />
<Route path="/help/settings/domains" element={<HelpSettingsDomains />} />
<Route path="/help/settings/api" element={<HelpSettingsApi />} />
<Route path="/help/settings/auth" element={<HelpSettingsAuth />} />
<Route path="/help/settings/billing" element={<HelpSettingsBilling />} />
<Route path="/help/settings/quota" element={<HelpSettingsQuota />} />
{/* Staff Schedule - vertical timeline view */}
<Route
path="/plugins/marketplace"
path="/dashboard/my-schedule"
element={
hasAccess(['staff']) ? (
<StaffSchedule user={user} />
) : (
<Navigate to="/dashboard" />
)
}
/>
<Route path="/dashboard/scheduler" element={<Scheduler />} />
<Route path="/dashboard/tickets" element={<Tickets />} />
<Route
path="/dashboard/help"
element={
user.role === 'staff' ? (
<StaffHelp user={user} />
) : (
<HelpComprehensive />
)
}
/>
<Route path="/dashboard/help/guide" element={<HelpGuide />} />
<Route path="/dashboard/help/ticketing" element={<HelpTicketing />} />
<Route path="/dashboard/help/api" element={<HelpApiDocs />} />
<Route path="/dashboard/help/plugins/docs" element={<HelpPluginDocs />} />
<Route path="/dashboard/help/email" element={<HelpEmailSettings />} />
{/* New help pages */}
<Route path="/dashboard/help/dashboard" element={<HelpDashboard />} />
<Route path="/dashboard/help/scheduler" element={<HelpScheduler />} />
<Route path="/dashboard/help/tasks" element={<HelpTasks />} />
<Route path="/dashboard/help/customers" element={<HelpCustomers />} />
<Route path="/dashboard/help/services" element={<HelpServices />} />
<Route path="/dashboard/help/resources" element={<HelpResources />} />
<Route path="/dashboard/help/staff" element={<HelpStaff />} />
<Route path="/dashboard/help/time-blocks" element={<HelpTimeBlocks />} />
<Route path="/dashboard/help/messages" element={<HelpMessages />} />
<Route path="/dashboard/help/payments" element={<HelpPayments />} />
<Route path="/dashboard/help/contracts" element={<HelpContracts />} />
<Route path="/dashboard/help/plugins" element={<HelpPlugins />} />
<Route path="/dashboard/help/settings/general" element={<HelpSettingsGeneral />} />
<Route path="/dashboard/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
<Route path="/dashboard/help/settings/booking" element={<HelpSettingsBooking />} />
<Route path="/dashboard/help/settings/appearance" element={<HelpSettingsAppearance />} />
<Route path="/dashboard/help/settings/email" element={<HelpSettingsEmail />} />
<Route path="/dashboard/help/settings/domains" element={<HelpSettingsDomains />} />
<Route path="/dashboard/help/settings/api" element={<HelpSettingsApi />} />
<Route path="/dashboard/help/settings/auth" element={<HelpSettingsAuth />} />
<Route path="/dashboard/help/settings/billing" element={<HelpSettingsBilling />} />
<Route path="/dashboard/help/settings/quota" element={<HelpSettingsQuota />} />
<Route
path="/dashboard/plugins/marketplace"
element={
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', 'staff']) ? (
hasAccess(['owner', 'manager']) ? (
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/services"
path="/dashboard/services"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
hasAccess(['owner', 'manager']) ? (
<Services />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/resources"
path="/dashboard/resources"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
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="/payments"
path="/dashboard/time-blocks"
element={
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/" />
hasAccess(['owner', 'manager']) ? (
<TimeBlocks />
) : (
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/messages"
path="/dashboard/locations"
element={
hasAccess(['owner', 'manager']) ? (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Messages</h1>
<p className="text-gray-600">Messages feature coming soon...</p>
</div>
<Locations />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/dashboard/my-availability"
element={
hasAccess(['staff', 'resource']) ? (
<MyAvailability user={user} />
) : (
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/dashboard/contracts"
element={
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
<Contracts />
) : (
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/dashboard/contracts/templates"
element={
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
<ContractTemplates />
) : (
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/dashboard/payments"
element={
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/dashboard" />
}
/>
<Route
path="/dashboard/messages"
element={
hasAccess(['owner', 'manager']) && user?.can_send_messages ? (
<Messages />
) : (
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/dashboard/site-editor"
element={
hasAccess(['owner', 'manager']) ? (
<PageEditor />
) : (
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/dashboard/gallery"
element={
hasAccess(['owner', 'manager']) ? (
<MediaGalleryPage />
) : (
<Navigate to="/dashboard" />
)
}
/>
{/* Settings Routes with Nested Layout */}
{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 />} />
@@ -775,12 +958,14 @@ 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>
{/* Catch-all redirects to home */}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Suspense>
);

View File

@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
post: vi.fn(),
get: vi.fn(),
},
}));
import {
login,
logout,
getCurrentUser,
refreshToken,
masquerade,
stopMasquerade,
} from '../auth';
import apiClient from '../client';
describe('auth API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('login', () => {
it('sends credentials to login endpoint', async () => {
const mockResponse = {
data: {
access: 'access-token',
refresh: 'refresh-token',
user: { id: 1, email: 'test@example.com' },
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await login({ email: 'test@example.com', password: 'password' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/login/', {
email: 'test@example.com',
password: 'password',
});
expect(result).toEqual(mockResponse.data);
});
it('returns MFA required response', async () => {
const mockResponse = {
data: {
mfa_required: true,
user_id: 1,
mfa_methods: ['TOTP', 'SMS'],
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await login({ email: 'test@example.com', password: 'password' });
expect(result.mfa_required).toBe(true);
expect(result.mfa_methods).toContain('TOTP');
});
});
describe('logout', () => {
it('calls logout endpoint', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await logout();
expect(apiClient.post).toHaveBeenCalledWith('/auth/logout/');
});
});
describe('getCurrentUser', () => {
it('fetches current user from API', async () => {
const mockUser = {
id: 1,
email: 'test@example.com',
name: 'Test User',
role: 'owner',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUser });
const result = await getCurrentUser();
expect(apiClient.get).toHaveBeenCalledWith('/auth/me/');
expect(result).toEqual(mockUser);
});
});
describe('refreshToken', () => {
it('sends refresh token to API', async () => {
const mockResponse = { data: { access: 'new-access-token' } };
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await refreshToken('old-refresh-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/refresh/', {
refresh: 'old-refresh-token',
});
expect(result.access).toBe('new-access-token');
});
});
describe('masquerade', () => {
it('sends masquerade request with user_pk', async () => {
const mockResponse = {
data: {
access: 'masq-access',
refresh: 'masq-refresh',
user: { id: 2, email: 'other@example.com' },
masquerade_stack: [{ user_id: 1, username: 'admin', role: 'superuser' }],
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await masquerade(2);
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/acquire/', {
user_pk: 2,
hijack_history: undefined,
});
expect(result.masquerade_stack).toHaveLength(1);
});
it('sends masquerade request with history', async () => {
const history = [{ user_id: 1, username: 'admin', role: 'superuser' as const }];
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
await masquerade(2, history);
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/acquire/', {
user_pk: 2,
hijack_history: history,
});
});
});
describe('stopMasquerade', () => {
it('sends release request with masquerade stack', async () => {
const stack = [{ user_id: 1, username: 'admin', role: 'superuser' as const }];
const mockResponse = {
data: {
access: 'orig-access',
refresh: 'orig-refresh',
user: { id: 1 },
masquerade_stack: [],
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await stopMasquerade(stack);
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/release/', {
masquerade_stack: stack,
});
expect(result.masquerade_stack).toHaveLength(0);
});
});
});

View File

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

View File

@@ -0,0 +1,632 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getResources,
getBusinessUsers,
getBusinessOAuthSettings,
updateBusinessOAuthSettings,
getBusinessOAuthCredentials,
updateBusinessOAuthCredentials,
} from '../business';
import apiClient from '../client';
describe('business API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getResources', () => {
it('fetches all resources from API', async () => {
const mockResources = [
{
id: '1',
name: 'Resource 1',
type: 'STAFF',
maxConcurrentEvents: 1,
},
{
id: '2',
name: 'Resource 2',
type: 'EQUIPMENT',
maxConcurrentEvents: 3,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResources });
const result = await getResources();
expect(apiClient.get).toHaveBeenCalledWith('/resources/');
expect(result).toEqual(mockResources);
});
it('returns empty array when no resources exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getResources();
expect(result).toEqual([]);
});
});
describe('getBusinessUsers', () => {
it('fetches all business users from API', async () => {
const mockUsers = [
{
id: '1',
email: 'owner@example.com',
name: 'Business Owner',
role: 'owner',
},
{
id: '2',
email: 'staff@example.com',
name: 'Staff Member',
role: 'staff',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
const result = await getBusinessUsers();
expect(apiClient.get).toHaveBeenCalledWith('/business/users/');
expect(result).toEqual(mockUsers);
});
it('returns empty array when no users exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getBusinessUsers();
expect(result).toEqual([]);
});
});
describe('getBusinessOAuthSettings', () => {
it('fetches OAuth settings and transforms snake_case to camelCase', async () => {
const mockBackendResponse = {
settings: {
enabled_providers: ['google', 'microsoft'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: true,
},
available_providers: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: 'microsoft-icon',
description: 'Sign in with Microsoft',
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(apiClient.get).toHaveBeenCalledWith('/business/oauth-settings/');
expect(result).toEqual({
settings: {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: true,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: 'microsoft-icon',
description: 'Sign in with Microsoft',
},
],
});
});
it('handles empty enabled providers array', async () => {
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(result.settings.enabledProviders).toEqual([]);
expect(result.availableProviders).toEqual([]);
});
it('handles undefined enabled_providers by using empty array', async () => {
const mockBackendResponse = {
settings: {
allow_registration: true,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Google OAuth',
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(result.settings.enabledProviders).toEqual([]);
});
it('handles undefined available_providers by using empty array', async () => {
const mockBackendResponse = {
settings: {
enabled_providers: ['google'],
allow_registration: true,
auto_link_by_email: true,
use_custom_credentials: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(result.availableProviders).toEqual([]);
});
});
describe('updateBusinessOAuthSettings', () => {
it('updates OAuth settings and transforms camelCase to snake_case', async () => {
const frontendSettings = {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: true,
};
const mockBackendResponse = {
settings: {
enabled_providers: ['google', 'microsoft'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: true,
},
available_providers: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Google OAuth',
},
],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthSettings(frontendSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
enabled_providers: ['google', 'microsoft'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: true,
});
expect(result).toEqual({
settings: {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: true,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Google OAuth',
},
],
});
});
it('sends only provided fields to backend', async () => {
const partialSettings = {
enabledProviders: ['google'],
};
const mockBackendResponse = {
settings: {
enabled_providers: ['google'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
enabled_providers: ['google'],
});
});
it('handles updating only allowRegistration', async () => {
const partialSettings = {
allowRegistration: false,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
allow_registration: false,
});
});
it('handles updating only autoLinkByEmail', async () => {
const partialSettings = {
autoLinkByEmail: true,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
auto_link_by_email: true,
});
});
it('handles updating only useCustomCredentials', async () => {
const partialSettings = {
useCustomCredentials: true,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: true,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
use_custom_credentials: true,
});
});
it('handles boolean false values correctly', async () => {
const settings = {
allowRegistration: false,
autoLinkByEmail: false,
useCustomCredentials: false,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(settings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: false,
});
});
it('does not send undefined fields', async () => {
const settings = {};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: true,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(settings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {});
});
});
describe('getBusinessOAuthCredentials', () => {
it('fetches OAuth credentials from API', async () => {
const mockBackendResponse = {
credentials: {
google: {
client_id: 'google-client-id',
client_secret: 'google-secret',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-client-id',
client_secret: '',
has_secret: false,
},
},
use_custom_credentials: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthCredentials();
expect(apiClient.get).toHaveBeenCalledWith('/business/oauth-credentials/');
expect(result).toEqual({
credentials: {
google: {
client_id: 'google-client-id',
client_secret: 'google-secret',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-client-id',
client_secret: '',
has_secret: false,
},
},
useCustomCredentials: true,
});
});
it('handles empty credentials object', async () => {
const mockBackendResponse = {
credentials: {},
use_custom_credentials: false,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthCredentials();
expect(result.credentials).toEqual({});
expect(result.useCustomCredentials).toBe(false);
});
it('handles undefined credentials by using empty object', async () => {
const mockBackendResponse = {
use_custom_credentials: false,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthCredentials();
expect(result.credentials).toEqual({});
});
});
describe('updateBusinessOAuthCredentials', () => {
it('updates OAuth credentials', async () => {
const credentials = {
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
},
},
useCustomCredentials: true,
};
const mockBackendResponse = {
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
has_secret: true,
},
},
use_custom_credentials: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthCredentials(credentials);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
},
},
use_custom_credentials: true,
});
expect(result).toEqual({
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
});
});
it('updates only credentials without useCustomCredentials', async () => {
const data = {
credentials: {
microsoft: {
client_id: 'microsoft-id',
},
},
};
const mockBackendResponse = {
credentials: {
microsoft: {
client_id: 'microsoft-id',
client_secret: '',
has_secret: false,
},
},
use_custom_credentials: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
credentials: {
microsoft: {
client_id: 'microsoft-id',
},
},
});
});
it('updates only useCustomCredentials without credentials', async () => {
const data = {
useCustomCredentials: false,
};
const mockBackendResponse = {
credentials: {},
use_custom_credentials: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
use_custom_credentials: false,
});
});
it('handles partial credential updates', async () => {
const data = {
credentials: {
google: {
client_id: 'updated-id',
},
microsoft: {
client_secret: 'updated-secret',
},
},
};
const mockBackendResponse = {
credentials: {
google: {
client_id: 'updated-id',
client_secret: 'existing-secret',
has_secret: true,
},
microsoft: {
client_id: 'existing-id',
client_secret: 'updated-secret',
has_secret: true,
},
},
use_custom_credentials: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
credentials: {
google: {
client_id: 'updated-id',
},
microsoft: {
client_secret: 'updated-secret',
},
},
});
expect(result.credentials.google.client_id).toBe('updated-id');
expect(result.credentials.microsoft.client_secret).toBe('updated-secret');
});
it('handles empty data object', async () => {
const data = {};
const mockBackendResponse = {
credentials: {},
use_custom_credentials: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {});
});
it('handles undefined credentials in response by using empty object', async () => {
const data = {
useCustomCredentials: true,
};
const mockBackendResponse = {
use_custom_credentials: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthCredentials(data);
expect(result.credentials).toEqual({});
});
});
});

View File

@@ -0,0 +1,183 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import axios from 'axios';
// Mock dependencies
vi.mock('../../utils/cookies', () => ({
getCookie: vi.fn(),
setCookie: vi.fn(),
deleteCookie: vi.fn(),
}));
vi.mock('../../utils/domain', () => ({
getBaseDomain: vi.fn(() => 'lvh.me'),
}));
vi.mock('../config', () => ({
API_BASE_URL: 'http://api.lvh.me:8000',
getSubdomain: vi.fn(),
}));
describe('api/client', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
describe('request interceptor', () => {
it('adds auth token from cookie when available', async () => {
const cookies = await import('../../utils/cookies');
const config = await import('../config');
vi.mocked(cookies.getCookie).mockReturnValue('test-token-123');
vi.mocked(config.getSubdomain).mockReturnValue(null);
// Re-import client to apply mocks
vi.resetModules();
// Mock the interceptors
const mockConfig = {
headers: {} as Record<string, string>,
};
// Simulate what the request interceptor does
const token = cookies.getCookie('access_token');
if (token) {
mockConfig.headers['Authorization'] = `Token ${token}`;
}
expect(mockConfig.headers['Authorization']).toBe('Token test-token-123');
});
it('does not add auth header when no token', async () => {
const cookies = await import('../../utils/cookies');
vi.mocked(cookies.getCookie).mockReturnValue(null);
const mockConfig = {
headers: {} as Record<string, string>,
};
const token = cookies.getCookie('access_token');
if (token) {
mockConfig.headers['Authorization'] = `Token ${token}`;
}
expect(mockConfig.headers['Authorization']).toBeUndefined();
});
it('adds business subdomain header when on business site', async () => {
const config = await import('../config');
vi.mocked(config.getSubdomain).mockReturnValue('demo');
const mockConfig = {
headers: {} as Record<string, string>,
};
const subdomain = config.getSubdomain();
if (subdomain && subdomain !== 'platform') {
mockConfig.headers['X-Business-Subdomain'] = subdomain;
}
expect(mockConfig.headers['X-Business-Subdomain']).toBe('demo');
});
it('does not add subdomain header on platform site', async () => {
const config = await import('../config');
vi.mocked(config.getSubdomain).mockReturnValue('platform');
const mockConfig = {
headers: {} as Record<string, string>,
};
const subdomain = config.getSubdomain();
if (subdomain && subdomain !== 'platform') {
mockConfig.headers['X-Business-Subdomain'] = subdomain;
}
expect(mockConfig.headers['X-Business-Subdomain']).toBeUndefined();
});
it('adds sandbox mode header when in test mode', async () => {
// Set sandbox mode in localStorage
window.localStorage.setItem('sandbox_mode', 'true');
const mockConfig = {
headers: {} as Record<string, string>,
};
// Simulate the getSandboxMode logic
let isSandbox = false;
try {
isSandbox = window.localStorage.getItem('sandbox_mode') === 'true';
} catch {
isSandbox = false;
}
if (isSandbox) {
mockConfig.headers['X-Sandbox-Mode'] = 'true';
}
expect(mockConfig.headers['X-Sandbox-Mode']).toBe('true');
});
it('does not add sandbox header when not in test mode', async () => {
localStorage.removeItem('sandbox_mode');
const mockConfig = {
headers: {} as Record<string, string>,
};
const isSandbox = localStorage.getItem('sandbox_mode') === 'true';
if (isSandbox) {
mockConfig.headers['X-Sandbox-Mode'] = 'true';
}
expect(mockConfig.headers['X-Sandbox-Mode']).toBeUndefined();
});
});
describe('getSandboxMode', () => {
it('returns false when localStorage throws', () => {
// Simulate localStorage throwing (e.g., in private browsing)
const originalGetItem = localStorage.getItem;
localStorage.getItem = () => {
throw new Error('Access denied');
};
// Function should return false on error
let result = false;
try {
result = localStorage.getItem('sandbox_mode') === 'true';
} catch {
result = false;
}
expect(result).toBe(false);
localStorage.getItem = originalGetItem;
});
it('returns false when sandbox_mode is not set', () => {
localStorage.removeItem('sandbox_mode');
const result = localStorage.getItem('sandbox_mode') === 'true';
expect(result).toBe(false);
});
it('returns true when sandbox_mode is "true"', () => {
window.localStorage.setItem('sandbox_mode', 'true');
const result = window.localStorage.getItem('sandbox_mode') === 'true';
expect(result).toBe(true);
});
it('returns false when sandbox_mode is "false"', () => {
localStorage.setItem('sandbox_mode', 'false');
const result = localStorage.getItem('sandbox_mode') === 'true';
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock the domain module before importing config
vi.mock('../../utils/domain', () => ({
getBaseDomain: vi.fn(),
isRootDomain: vi.fn(),
}));
// Helper to mock window.location
const mockLocation = (hostname: string, protocol = 'https:', port = '') => {
Object.defineProperty(window, 'location', {
value: {
hostname,
protocol,
port,
},
writable: true,
});
};
describe('api/config', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
// Clear any env vars
delete (import.meta as unknown as { env: Record<string, unknown> }).env.VITE_API_URL;
});
describe('getSubdomain', () => {
it('returns null for root domain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(true);
mockLocation('lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBeNull();
});
it('returns subdomain for business site', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('demo.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('demo');
});
it('returns null for platform subdomain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('platform.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBeNull();
});
it('returns subdomain for www', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('www.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('www');
});
it('returns subdomain for api', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('api.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('api');
});
it('handles production business subdomain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('acme-corp.smoothschedule.com');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('acme-corp');
});
});
describe('isPlatformSite', () => {
it('returns true for platform subdomain', async () => {
mockLocation('platform.lvh.me');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(true);
});
it('returns true for platform in production', async () => {
mockLocation('platform.smoothschedule.com');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(true);
});
it('returns false for business subdomain', async () => {
mockLocation('demo.lvh.me');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(false);
});
it('returns false for root domain', async () => {
mockLocation('lvh.me');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(false);
});
});
describe('isBusinessSite', () => {
it('returns true for business subdomain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('demo.lvh.me');
const { isBusinessSite } = await import('../config');
expect(isBusinessSite()).toBe(true);
});
it('returns false for platform site', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('platform.lvh.me');
const { isBusinessSite } = await import('../config');
expect(isBusinessSite()).toBe(false);
});
it('returns false for root domain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(true);
mockLocation('lvh.me');
const { isBusinessSite } = await import('../config');
expect(isBusinessSite()).toBe(false);
});
});
});

View File

@@ -0,0 +1,267 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getCustomDomains,
addCustomDomain,
deleteCustomDomain,
verifyCustomDomain,
setPrimaryDomain,
} from '../customDomains';
import apiClient from '../client';
describe('customDomains API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getCustomDomains', () => {
it('fetches all custom domains for the current business', async () => {
const mockDomains = [
{
id: 1,
domain: 'example.com',
is_verified: true,
ssl_provisioned: true,
is_primary: true,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.example.com',
created_at: '2024-01-01T00:00:00Z',
verified_at: '2024-01-02T00:00:00Z',
},
{
id: 2,
domain: 'custom.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token456',
dns_txt_record: 'smoothschedule-verify=token456',
dns_txt_record_name: '_smoothschedule.custom.com',
created_at: '2024-01-03T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomains });
const result = await getCustomDomains();
expect(apiClient.get).toHaveBeenCalledWith('/business/domains/');
expect(result).toEqual(mockDomains);
expect(result).toHaveLength(2);
});
it('returns empty array when no domains exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getCustomDomains();
expect(apiClient.get).toHaveBeenCalledWith('/business/domains/');
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
describe('addCustomDomain', () => {
it('adds a new custom domain with lowercase and trimmed domain', async () => {
const mockDomain = {
id: 1,
domain: 'example.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.example.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
const result = await addCustomDomain('Example.com');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'example.com',
});
expect(result).toEqual(mockDomain);
});
it('transforms domain to lowercase before sending', async () => {
const mockDomain = {
id: 1,
domain: 'uppercase.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.uppercase.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
await addCustomDomain('UPPERCASE.COM');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'uppercase.com',
});
});
it('trims whitespace from domain before sending', async () => {
const mockDomain = {
id: 1,
domain: 'trimmed.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.trimmed.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
await addCustomDomain(' trimmed.com ');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'trimmed.com',
});
});
it('transforms domain with both uppercase and whitespace', async () => {
const mockDomain = {
id: 1,
domain: 'mixed.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.mixed.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
await addCustomDomain(' MiXeD.COM ');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'mixed.com',
});
});
});
describe('deleteCustomDomain', () => {
it('deletes a custom domain by ID', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteCustomDomain(1);
expect(apiClient.delete).toHaveBeenCalledWith('/business/domains/1/');
});
it('returns void on successful deletion', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const result = await deleteCustomDomain(42);
expect(result).toBeUndefined();
});
});
describe('verifyCustomDomain', () => {
it('verifies a custom domain and returns verification status', async () => {
const mockResponse = {
verified: true,
message: 'Domain verified successfully',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await verifyCustomDomain(1);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/1/verify/');
expect(result).toEqual(mockResponse);
expect(result.verified).toBe(true);
expect(result.message).toBe('Domain verified successfully');
});
it('returns failure status when verification fails', async () => {
const mockResponse = {
verified: false,
message: 'DNS records not found. Please check your configuration.',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await verifyCustomDomain(2);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/2/verify/');
expect(result.verified).toBe(false);
expect(result.message).toContain('DNS records not found');
});
it('handles different domain IDs correctly', async () => {
const mockResponse = {
verified: true,
message: 'Success',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await verifyCustomDomain(999);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/999/verify/');
});
});
describe('setPrimaryDomain', () => {
it('sets a custom domain as primary', async () => {
const mockDomain = {
id: 1,
domain: 'example.com',
is_verified: true,
ssl_provisioned: true,
is_primary: true,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.example.com',
created_at: '2024-01-01T00:00:00Z',
verified_at: '2024-01-02T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
const result = await setPrimaryDomain(1);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/1/set-primary/');
expect(result).toEqual(mockDomain);
expect(result.is_primary).toBe(true);
});
it('returns updated domain with is_primary flag', async () => {
const mockDomain = {
id: 5,
domain: 'newprimary.com',
is_verified: true,
ssl_provisioned: true,
is_primary: true,
verification_token: 'token789',
dns_txt_record: 'smoothschedule-verify=token789',
dns_txt_record_name: '_smoothschedule.newprimary.com',
created_at: '2024-01-05T00:00:00Z',
verified_at: '2024-01-06T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
const result = await setPrimaryDomain(5);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/5/set-primary/');
expect(result.id).toBe(5);
expect(result.domain).toBe('newprimary.com');
expect(result.is_primary).toBe(true);
});
});
});

View File

@@ -0,0 +1,649 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
searchDomains,
getDomainPrices,
registerDomain,
getRegisteredDomains,
getDomainRegistration,
updateNameservers,
toggleAutoRenew,
renewDomain,
syncDomain,
getSearchHistory,
DomainAvailability,
DomainPrice,
DomainRegisterRequest,
DomainRegistration,
DomainSearchHistory,
} from '../domains';
import apiClient from '../client';
describe('domains API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('searchDomains', () => {
it('searches for domains with default TLDs', async () => {
const mockResults: DomainAvailability[] = [
{
domain: 'example.com',
available: true,
price: 12.99,
premium: false,
premium_price: null,
},
{
domain: 'example.net',
available: false,
price: null,
premium: false,
premium_price: null,
},
{
domain: 'example.org',
available: true,
price: 14.99,
premium: false,
premium_price: null,
},
];
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
const result = await searchDomains('example');
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/search/', {
query: 'example',
tlds: ['.com', '.net', '.org'],
});
expect(result).toEqual(mockResults);
expect(result).toHaveLength(3);
});
it('searches for domains with custom TLDs', async () => {
const mockResults: DomainAvailability[] = [
{
domain: 'mybusiness.io',
available: true,
price: 39.99,
premium: false,
premium_price: null,
},
{
domain: 'mybusiness.dev',
available: true,
price: 12.99,
premium: false,
premium_price: null,
},
];
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
const result = await searchDomains('mybusiness', ['.io', '.dev']);
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/search/', {
query: 'mybusiness',
tlds: ['.io', '.dev'],
});
expect(result).toEqual(mockResults);
});
it('handles premium domain results', async () => {
const mockResults: DomainAvailability[] = [
{
domain: 'premium.com',
available: true,
price: 12.99,
premium: true,
premium_price: 5000.0,
},
];
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
const result = await searchDomains('premium');
expect(result[0].premium).toBe(true);
expect(result[0].premium_price).toBe(5000.0);
});
});
describe('getDomainPrices', () => {
it('fetches domain prices for all TLDs', async () => {
const mockPrices: DomainPrice[] = [
{
tld: '.com',
registration: 12.99,
renewal: 14.99,
transfer: 12.99,
},
{
tld: '.net',
registration: 14.99,
renewal: 16.99,
transfer: 14.99,
},
{
tld: '.org',
registration: 14.99,
renewal: 16.99,
transfer: 14.99,
},
{
tld: '.io',
registration: 39.99,
renewal: 39.99,
transfer: 39.99,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPrices });
const result = await getDomainPrices();
expect(apiClient.get).toHaveBeenCalledWith('/domains/search/prices/');
expect(result).toEqual(mockPrices);
expect(result).toHaveLength(4);
});
});
describe('registerDomain', () => {
it('registers a new domain with full contact information', async () => {
const registerRequest: DomainRegisterRequest = {
domain: 'newbusiness.com',
years: 2,
whois_privacy: true,
auto_renew: true,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
contact: {
first_name: 'John',
last_name: 'Doe',
email: 'john@example.com',
phone: '+1.5551234567',
address: '123 Main St',
city: 'New York',
state: 'NY',
zip_code: '10001',
country: 'US',
},
auto_configure: true,
};
const mockRegistration: DomainRegistration = {
id: 1,
domain: 'newbusiness.com',
status: 'pending',
registered_at: null,
expires_at: null,
auto_renew: true,
whois_privacy: true,
purchase_price: 25.98,
renewal_price: null,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: null,
is_expiring_soon: false,
created_at: '2024-01-15T10:00:00Z',
registrant_first_name: 'John',
registrant_last_name: 'Doe',
registrant_email: 'john@example.com',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRegistration });
const result = await registerDomain(registerRequest);
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/register/', registerRequest);
expect(result).toEqual(mockRegistration);
expect(result.status).toBe('pending');
});
it('registers domain without optional nameservers', async () => {
const registerRequest: DomainRegisterRequest = {
domain: 'simple.com',
years: 1,
whois_privacy: false,
auto_renew: false,
contact: {
first_name: 'Jane',
last_name: 'Smith',
email: 'jane@example.com',
phone: '+1.5559876543',
address: '456 Oak Ave',
city: 'Boston',
state: 'MA',
zip_code: '02101',
country: 'US',
},
auto_configure: false,
};
const mockRegistration: DomainRegistration = {
id: 2,
domain: 'simple.com',
status: 'pending',
registered_at: null,
expires_at: null,
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: null,
nameservers: [],
days_until_expiry: null,
is_expiring_soon: false,
created_at: '2024-01-15T10:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRegistration });
const result = await registerDomain(registerRequest);
expect(result.whois_privacy).toBe(false);
expect(result.auto_renew).toBe(false);
expect(result.nameservers).toEqual([]);
});
});
describe('getRegisteredDomains', () => {
it('fetches all registered domains for current business', async () => {
const mockDomains: DomainRegistration[] = [
{
id: 1,
domain: 'business1.com',
status: 'active',
registered_at: '2023-01-15T10:00:00Z',
expires_at: '2025-01-15T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 365,
is_expiring_soon: false,
created_at: '2023-01-15T09:00:00Z',
},
{
id: 2,
domain: 'business2.net',
status: 'active',
registered_at: '2024-01-01T10:00:00Z',
expires_at: '2024-03-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 14.99,
renewal_price: 16.99,
nameservers: ['ns1.example.com', 'ns2.example.com'],
days_until_expiry: 30,
is_expiring_soon: true,
created_at: '2024-01-01T09:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomains });
const result = await getRegisteredDomains();
expect(apiClient.get).toHaveBeenCalledWith('/domains/registrations/');
expect(result).toEqual(mockDomains);
expect(result).toHaveLength(2);
expect(result[1].is_expiring_soon).toBe(true);
});
it('handles empty domain list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getRegisteredDomains();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
describe('getDomainRegistration', () => {
it('fetches a single domain registration by ID', async () => {
const mockDomain: DomainRegistration = {
id: 5,
domain: 'example.com',
status: 'active',
registered_at: '2023-06-01T10:00:00Z',
expires_at: '2025-06-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com', 'ns3.digitalocean.com'],
days_until_expiry: 500,
is_expiring_soon: false,
created_at: '2023-06-01T09:30:00Z',
registrant_first_name: 'Alice',
registrant_last_name: 'Johnson',
registrant_email: 'alice@example.com',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomain });
const result = await getDomainRegistration(5);
expect(apiClient.get).toHaveBeenCalledWith('/domains/registrations/5/');
expect(result).toEqual(mockDomain);
expect(result.registrant_email).toBe('alice@example.com');
});
it('fetches domain with failed status', async () => {
const mockDomain: DomainRegistration = {
id: 10,
domain: 'failed.com',
status: 'failed',
registered_at: null,
expires_at: null,
auto_renew: false,
whois_privacy: false,
purchase_price: null,
renewal_price: null,
nameservers: [],
days_until_expiry: null,
is_expiring_soon: false,
created_at: '2024-01-10T10:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomain });
const result = await getDomainRegistration(10);
expect(result.status).toBe('failed');
expect(result.registered_at).toBeNull();
});
});
describe('updateNameservers', () => {
it('updates nameservers for a domain', async () => {
const nameservers = [
'ns1.customdns.com',
'ns2.customdns.com',
'ns3.customdns.com',
'ns4.customdns.com',
];
const mockUpdated: DomainRegistration = {
id: 3,
domain: 'updated.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: nameservers,
days_until_expiry: 100,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await updateNameservers(3, nameservers);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/3/update_nameservers/', {
nameservers: nameservers,
});
expect(result.nameservers).toEqual(nameservers);
expect(result.nameservers).toHaveLength(4);
});
it('updates to default DigitalOcean nameservers', async () => {
const nameservers = ['ns1.digitalocean.com', 'ns2.digitalocean.com', 'ns3.digitalocean.com'];
const mockUpdated: DomainRegistration = {
id: 7,
domain: 'reset.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: nameservers,
days_until_expiry: 200,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await updateNameservers(7, nameservers);
expect(result.nameservers).toEqual(nameservers);
});
});
describe('toggleAutoRenew', () => {
it('enables auto-renewal for a domain', async () => {
const mockUpdated: DomainRegistration = {
id: 4,
domain: 'autorenew.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 150,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await toggleAutoRenew(4, true);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/4/toggle_auto_renew/', {
auto_renew: true,
});
expect(result.auto_renew).toBe(true);
});
it('disables auto-renewal for a domain', async () => {
const mockUpdated: DomainRegistration = {
id: 6,
domain: 'noautorenew.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.example.com', 'ns2.example.com'],
days_until_expiry: 60,
is_expiring_soon: true,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await toggleAutoRenew(6, false);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/6/toggle_auto_renew/', {
auto_renew: false,
});
expect(result.auto_renew).toBe(false);
});
});
describe('renewDomain', () => {
it('renews domain for 1 year (default)', async () => {
const mockRenewed: DomainRegistration = {
id: 8,
domain: 'renew.com',
status: 'active',
registered_at: '2022-01-01T10:00:00Z',
expires_at: '2025-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 365,
is_expiring_soon: false,
created_at: '2022-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
const result = await renewDomain(8);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/8/renew/', {
years: 1,
});
expect(result).toEqual(mockRenewed);
});
it('renews domain for multiple years', async () => {
const mockRenewed: DomainRegistration = {
id: 9,
domain: 'longterm.com',
status: 'active',
registered_at: '2022-01-01T10:00:00Z',
expires_at: '2027-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.example.com', 'ns2.example.com'],
days_until_expiry: 1095,
is_expiring_soon: false,
created_at: '2022-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
const result = await renewDomain(9, 5);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/9/renew/', {
years: 5,
});
expect(result).toEqual(mockRenewed);
});
it('renews domain for 2 years', async () => {
const mockRenewed: DomainRegistration = {
id: 11,
domain: 'twoyears.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2026-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 730,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
const result = await renewDomain(11, 2);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/11/renew/', {
years: 2,
});
expect(result.expires_at).toBe('2026-01-01T10:00:00Z');
});
});
describe('syncDomain', () => {
it('syncs domain information from NameSilo', async () => {
const mockSynced: DomainRegistration = {
id: 12,
domain: 'synced.com',
status: 'active',
registered_at: '2023-05-15T10:00:00Z',
expires_at: '2024-05-15T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.namesilo.com', 'ns2.namesilo.com'],
days_until_expiry: 120,
is_expiring_soon: false,
created_at: '2023-05-15T09:30:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSynced });
const result = await syncDomain(12);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/12/sync/');
expect(result).toEqual(mockSynced);
});
it('syncs domain and updates status', async () => {
const mockSynced: DomainRegistration = {
id: 13,
domain: 'expired.com',
status: 'expired',
registered_at: '2020-01-01T10:00:00Z',
expires_at: '2023-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: [],
days_until_expiry: -365,
is_expiring_soon: false,
created_at: '2020-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSynced });
const result = await syncDomain(13);
expect(result.status).toBe('expired');
expect(result.days_until_expiry).toBeLessThan(0);
});
});
describe('getSearchHistory', () => {
it('fetches domain search history', async () => {
const mockHistory: DomainSearchHistory[] = [
{
id: 1,
searched_domain: 'example.com',
was_available: true,
price: 12.99,
searched_at: '2024-01-15T10:00:00Z',
},
{
id: 2,
searched_domain: 'taken.com',
was_available: false,
price: null,
searched_at: '2024-01-15T10:05:00Z',
},
{
id: 3,
searched_domain: 'premium.com',
was_available: true,
price: 5000.0,
searched_at: '2024-01-15T10:10:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockHistory });
const result = await getSearchHistory();
expect(apiClient.get).toHaveBeenCalledWith('/domains/history/');
expect(result).toEqual(mockHistory);
expect(result).toHaveLength(3);
expect(result[1].was_available).toBe(false);
expect(result[2].price).toBe(5000.0);
});
it('handles empty search history', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getSearchHistory();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,877 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getMFAStatus,
sendPhoneVerification,
verifyPhone,
enableSMSMFA,
setupTOTP,
verifyTOTPSetup,
generateBackupCodes,
getBackupCodesStatus,
disableMFA,
sendMFALoginCode,
verifyMFALogin,
listTrustedDevices,
revokeTrustedDevice,
revokeAllTrustedDevices,
} from '../mfa';
import apiClient from '../client';
describe('MFA API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ============================================================================
// MFA Status
// ============================================================================
describe('getMFAStatus', () => {
it('fetches MFA status from API', async () => {
const mockStatus = {
mfa_enabled: true,
mfa_method: 'TOTP' as const,
methods: ['TOTP' as const, 'BACKUP' as const],
phone_last_4: '1234',
phone_verified: true,
totp_verified: true,
backup_codes_count: 8,
backup_codes_generated_at: '2024-01-01T00:00:00Z',
trusted_devices_count: 2,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getMFAStatus();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/status/');
expect(result).toEqual(mockStatus);
});
it('returns status when MFA is disabled', async () => {
const mockStatus = {
mfa_enabled: false,
mfa_method: 'NONE' as const,
methods: [],
phone_last_4: null,
phone_verified: false,
totp_verified: false,
backup_codes_count: 0,
backup_codes_generated_at: null,
trusted_devices_count: 0,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getMFAStatus();
expect(result.mfa_enabled).toBe(false);
expect(result.mfa_method).toBe('NONE');
expect(result.methods).toHaveLength(0);
});
it('returns status with both SMS and TOTP enabled', async () => {
const mockStatus = {
mfa_enabled: true,
mfa_method: 'BOTH' as const,
methods: ['SMS' as const, 'TOTP' as const, 'BACKUP' as const],
phone_last_4: '5678',
phone_verified: true,
totp_verified: true,
backup_codes_count: 10,
backup_codes_generated_at: '2024-01-15T12:00:00Z',
trusted_devices_count: 3,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getMFAStatus();
expect(result.mfa_method).toBe('BOTH');
expect(result.methods).toContain('SMS');
expect(result.methods).toContain('TOTP');
expect(result.methods).toContain('BACKUP');
});
});
// ============================================================================
// SMS Setup
// ============================================================================
describe('sendPhoneVerification', () => {
it('sends phone verification code', async () => {
const mockResponse = {
data: {
success: true,
message: 'Verification code sent to +1234567890',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await sendPhoneVerification('+1234567890');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/send/', {
phone: '+1234567890',
});
expect(result).toEqual(mockResponse.data);
expect(result.success).toBe(true);
});
it('handles different phone number formats', async () => {
const mockResponse = {
data: { success: true, message: 'Code sent' },
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await sendPhoneVerification('555-123-4567');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/send/', {
phone: '555-123-4567',
});
});
});
describe('verifyPhone', () => {
it('verifies phone with valid code', async () => {
const mockResponse = {
data: {
success: true,
message: 'Phone number verified successfully',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyPhone('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/verify/', {
code: '123456',
});
expect(result.success).toBe(true);
});
it('handles verification failure', async () => {
const mockResponse = {
data: {
success: false,
message: 'Invalid verification code',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyPhone('000000');
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid');
});
});
describe('enableSMSMFA', () => {
it('enables SMS MFA successfully', async () => {
const mockResponse = {
data: {
success: true,
message: 'SMS MFA enabled successfully',
mfa_method: 'SMS',
backup_codes: ['code1', 'code2', 'code3'],
backup_codes_message: 'Save these backup codes',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await enableSMSMFA();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/sms/enable/');
expect(result.success).toBe(true);
expect(result.mfa_method).toBe('SMS');
expect(result.backup_codes).toHaveLength(3);
});
it('enables SMS MFA without generating backup codes', async () => {
const mockResponse = {
data: {
success: true,
message: 'SMS MFA enabled',
mfa_method: 'SMS',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await enableSMSMFA();
expect(result.success).toBe(true);
expect(result.backup_codes).toBeUndefined();
});
});
// ============================================================================
// TOTP Setup (Authenticator App)
// ============================================================================
describe('setupTOTP', () => {
it('initializes TOTP setup with QR code', async () => {
const mockResponse = {
data: {
success: true,
secret: 'JBSWY3DPEHPK3PXP',
qr_code: 'data:image/png;base64,iVBORw0KGgoAAAANS...',
provisioning_uri: 'otpauth://totp/SmoothSchedule:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=SmoothSchedule',
message: 'Scan the QR code with your authenticator app',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await setupTOTP();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/setup/');
expect(result.success).toBe(true);
expect(result.secret).toBe('JBSWY3DPEHPK3PXP');
expect(result.qr_code).toContain('data:image/png');
expect(result.provisioning_uri).toContain('otpauth://totp/');
});
it('returns provisioning URI for manual entry', async () => {
const mockResponse = {
data: {
success: true,
secret: 'SECRETKEY123',
qr_code: 'data:image/png;base64,ABC...',
provisioning_uri: 'otpauth://totp/App:user@test.com?secret=SECRETKEY123',
message: 'Setup message',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await setupTOTP();
expect(result.provisioning_uri).toContain('SECRETKEY123');
});
});
describe('verifyTOTPSetup', () => {
it('verifies TOTP code and completes setup', async () => {
const mockResponse = {
data: {
success: true,
message: 'TOTP authentication enabled successfully',
mfa_method: 'TOTP',
backup_codes: ['backup1', 'backup2', 'backup3', 'backup4', 'backup5'],
backup_codes_message: 'Store these codes securely',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyTOTPSetup('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/verify/', {
code: '123456',
});
expect(result.success).toBe(true);
expect(result.mfa_method).toBe('TOTP');
expect(result.backup_codes).toHaveLength(5);
});
it('handles invalid TOTP code', async () => {
const mockResponse = {
data: {
success: false,
message: 'Invalid TOTP code',
mfa_method: '',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyTOTPSetup('000000');
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid');
});
});
// ============================================================================
// Backup Codes
// ============================================================================
describe('generateBackupCodes', () => {
it('generates new backup codes', async () => {
const mockResponse = {
data: {
success: true,
backup_codes: [
'AAAA-BBBB-CCCC',
'DDDD-EEEE-FFFF',
'GGGG-HHHH-IIII',
'JJJJ-KKKK-LLLL',
'MMMM-NNNN-OOOO',
'PPPP-QQQQ-RRRR',
'SSSS-TTTT-UUUU',
'VVVV-WWWW-XXXX',
'YYYY-ZZZZ-1111',
'2222-3333-4444',
],
message: 'Backup codes generated successfully',
warning: 'Previous backup codes have been invalidated',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await generateBackupCodes();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/backup-codes/');
expect(result.success).toBe(true);
expect(result.backup_codes).toHaveLength(10);
expect(result.warning).toContain('invalidated');
});
it('generates codes in correct format', async () => {
const mockResponse = {
data: {
success: true,
backup_codes: ['CODE-1234-ABCD', 'CODE-5678-EFGH'],
message: 'Generated',
warning: 'Old codes invalidated',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await generateBackupCodes();
result.backup_codes.forEach(code => {
expect(code).toMatch(/^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$/);
});
});
});
describe('getBackupCodesStatus', () => {
it('returns backup codes status', async () => {
const mockResponse = {
data: {
count: 8,
generated_at: '2024-01-15T10:30:00Z',
},
};
vi.mocked(apiClient.get).mockResolvedValue(mockResponse);
const result = await getBackupCodesStatus();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/backup-codes/status/');
expect(result.count).toBe(8);
expect(result.generated_at).toBe('2024-01-15T10:30:00Z');
});
it('returns status when no codes exist', async () => {
const mockResponse = {
data: {
count: 0,
generated_at: null,
},
};
vi.mocked(apiClient.get).mockResolvedValue(mockResponse);
const result = await getBackupCodesStatus();
expect(result.count).toBe(0);
expect(result.generated_at).toBeNull();
});
});
// ============================================================================
// Disable MFA
// ============================================================================
describe('disableMFA', () => {
it('disables MFA with password', async () => {
const mockResponse = {
data: {
success: true,
message: 'MFA has been disabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await disableMFA({ password: 'mypassword123' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
password: 'mypassword123',
});
expect(result.success).toBe(true);
expect(result.message).toContain('disabled');
});
it('disables MFA with valid MFA code', async () => {
const mockResponse = {
data: {
success: true,
message: 'MFA disabled successfully',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await disableMFA({ mfa_code: '123456' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
mfa_code: '123456',
});
expect(result.success).toBe(true);
});
it('handles both password and MFA code', async () => {
const mockResponse = {
data: {
success: true,
message: 'MFA disabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await disableMFA({ password: 'pass', mfa_code: '654321' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
password: 'pass',
mfa_code: '654321',
});
});
it('handles incorrect credentials', async () => {
const mockResponse = {
data: {
success: false,
message: 'Invalid password or MFA code',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await disableMFA({ password: 'wrongpass' });
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid');
});
});
// ============================================================================
// MFA Login Challenge
// ============================================================================
describe('sendMFALoginCode', () => {
it('sends SMS code for login', async () => {
const mockResponse = {
data: {
success: true,
message: 'Verification code sent to your phone',
method: 'SMS',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await sendMFALoginCode(42, 'SMS');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
user_id: 42,
method: 'SMS',
});
expect(result.success).toBe(true);
expect(result.method).toBe('SMS');
});
it('defaults to SMS method when not specified', async () => {
const mockResponse = {
data: {
success: true,
message: 'Code sent',
method: 'SMS',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await sendMFALoginCode(123);
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
user_id: 123,
method: 'SMS',
});
});
it('sends TOTP method (no actual code sent)', async () => {
const mockResponse = {
data: {
success: true,
message: 'Use your authenticator app',
method: 'TOTP',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await sendMFALoginCode(99, 'TOTP');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
user_id: 99,
method: 'TOTP',
});
expect(result.method).toBe('TOTP');
});
});
describe('verifyMFALogin', () => {
it('verifies MFA code and completes login', async () => {
const mockResponse = {
data: {
success: true,
access: 'access-token-xyz',
refresh: 'refresh-token-abc',
user: {
id: 42,
email: 'user@example.com',
username: 'john_doe',
first_name: 'John',
last_name: 'Doe',
full_name: 'John Doe',
role: 'owner',
business_subdomain: 'business1',
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(42, '123456', 'TOTP', false);
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 42,
code: '123456',
method: 'TOTP',
trust_device: false,
});
expect(result.success).toBe(true);
expect(result.access).toBe('access-token-xyz');
expect(result.user.email).toBe('user@example.com');
});
it('verifies SMS code', async () => {
const mockResponse = {
data: {
success: true,
access: 'token1',
refresh: 'token2',
user: {
id: 1,
email: 'test@test.com',
username: 'test',
first_name: 'Test',
last_name: 'User',
full_name: 'Test User',
role: 'staff',
business_subdomain: null,
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(1, '654321', 'SMS');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 1,
code: '654321',
method: 'SMS',
trust_device: false,
});
expect(result.success).toBe(true);
});
it('verifies backup code', async () => {
const mockResponse = {
data: {
success: true,
access: 'token-a',
refresh: 'token-b',
user: {
id: 5,
email: 'backup@test.com',
username: 'backup_user',
first_name: 'Backup',
last_name: 'Test',
full_name: 'Backup Test',
role: 'manager',
business_subdomain: 'company',
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(5, 'AAAA-BBBB-CCCC', 'BACKUP');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 5,
code: 'AAAA-BBBB-CCCC',
method: 'BACKUP',
trust_device: false,
});
expect(result.success).toBe(true);
});
it('trusts device after successful verification', async () => {
const mockResponse = {
data: {
success: true,
access: 'trusted-access',
refresh: 'trusted-refresh',
user: {
id: 10,
email: 'trusted@example.com',
username: 'trusted',
first_name: 'Trusted',
last_name: 'User',
full_name: 'Trusted User',
role: 'owner',
business_subdomain: 'trusted-biz',
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await verifyMFALogin(10, '999888', 'TOTP', true);
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 10,
code: '999888',
method: 'TOTP',
trust_device: true,
});
});
it('defaults trustDevice to false', async () => {
const mockResponse = {
data: {
success: true,
access: 'a',
refresh: 'b',
user: {
id: 1,
email: 'e@e.com',
username: 'u',
first_name: 'F',
last_name: 'L',
full_name: 'F L',
role: 'staff',
business_subdomain: null,
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await verifyMFALogin(1, '111111', 'SMS');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 1,
code: '111111',
method: 'SMS',
trust_device: false,
});
});
it('handles invalid MFA code', async () => {
const mockResponse = {
data: {
success: false,
access: '',
refresh: '',
user: {
id: 0,
email: '',
username: '',
first_name: '',
last_name: '',
full_name: '',
role: '',
business_subdomain: null,
mfa_enabled: false,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(1, 'invalid', 'TOTP');
expect(result.success).toBe(false);
});
});
// ============================================================================
// Trusted Devices
// ============================================================================
describe('listTrustedDevices', () => {
it('lists all trusted devices', async () => {
const mockDevices = {
devices: [
{
id: 1,
name: 'Chrome on Windows',
ip_address: '192.168.1.100',
created_at: '2024-01-01T10:00:00Z',
last_used_at: '2024-01-15T14:30:00Z',
expires_at: '2024-02-01T10:00:00Z',
is_current: true,
},
{
id: 2,
name: 'Safari on iPhone',
ip_address: '192.168.1.101',
created_at: '2024-01-05T12:00:00Z',
last_used_at: '2024-01-14T09:15:00Z',
expires_at: '2024-02-05T12:00:00Z',
is_current: false,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
const result = await listTrustedDevices();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/devices/');
expect(result.devices).toHaveLength(2);
expect(result.devices[0].is_current).toBe(true);
expect(result.devices[1].name).toBe('Safari on iPhone');
});
it('returns empty list when no devices', async () => {
const mockDevices = { devices: [] };
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
const result = await listTrustedDevices();
expect(result.devices).toHaveLength(0);
});
it('includes device metadata', async () => {
const mockDevices = {
devices: [
{
id: 99,
name: 'Firefox on Linux',
ip_address: '10.0.0.50',
created_at: '2024-01-10T08:00:00Z',
last_used_at: '2024-01-16T16:45:00Z',
expires_at: '2024-02-10T08:00:00Z',
is_current: false,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
const result = await listTrustedDevices();
const device = result.devices[0];
expect(device.id).toBe(99);
expect(device.name).toBe('Firefox on Linux');
expect(device.ip_address).toBe('10.0.0.50');
expect(device.created_at).toBeTruthy();
expect(device.last_used_at).toBeTruthy();
expect(device.expires_at).toBeTruthy();
});
});
describe('revokeTrustedDevice', () => {
it('revokes a specific device', async () => {
const mockResponse = {
data: {
success: true,
message: 'Device revoked successfully',
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeTrustedDevice(42);
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/42/');
expect(result.success).toBe(true);
expect(result.message).toContain('revoked');
});
it('handles different device IDs', async () => {
const mockResponse = {
data: { success: true, message: 'Revoked' },
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
await revokeTrustedDevice(999);
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/999/');
});
it('handles device not found', async () => {
const mockResponse = {
data: {
success: false,
message: 'Device not found',
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeTrustedDevice(0);
expect(result.success).toBe(false);
expect(result.message).toContain('not found');
});
});
describe('revokeAllTrustedDevices', () => {
it('revokes all trusted devices', async () => {
const mockResponse = {
data: {
success: true,
message: 'All devices revoked successfully',
count: 5,
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeAllTrustedDevices();
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/revoke-all/');
expect(result.success).toBe(true);
expect(result.count).toBe(5);
expect(result.message).toContain('All devices revoked');
});
it('returns zero count when no devices to revoke', async () => {
const mockResponse = {
data: {
success: true,
message: 'No devices to revoke',
count: 0,
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeAllTrustedDevices();
expect(result.count).toBe(0);
});
it('includes count of revoked devices', async () => {
const mockResponse = {
data: {
success: true,
message: 'Devices revoked',
count: 12,
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeAllTrustedDevices();
expect(result.count).toBe(12);
expect(result.success).toBe(true);
});
});
});

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getNotifications,
getUnreadCount,
markNotificationRead,
markAllNotificationsRead,
clearAllNotifications,
} from '../notifications';
import apiClient from '../client';
describe('notifications API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getNotifications', () => {
it('fetches all notifications without params', async () => {
const mockNotifications = [
{ id: 1, verb: 'created', read: false, timestamp: '2024-01-01T00:00:00Z' },
{ id: 2, verb: 'updated', read: true, timestamp: '2024-01-02T00:00:00Z' },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockNotifications });
const result = await getNotifications();
expect(apiClient.get).toHaveBeenCalledWith('/notifications/');
expect(result).toEqual(mockNotifications);
});
it('applies read filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getNotifications({ read: false });
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?read=false');
});
it('applies limit parameter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getNotifications({ limit: 10 });
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?limit=10');
});
it('applies multiple parameters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getNotifications({ read: true, limit: 5 });
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?read=true&limit=5');
});
});
describe('getUnreadCount', () => {
it('returns unread count', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { count: 5 } });
const result = await getUnreadCount();
expect(apiClient.get).toHaveBeenCalledWith('/notifications/unread_count/');
expect(result).toBe(5);
});
it('returns 0 when no unread notifications', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { count: 0 } });
const result = await getUnreadCount();
expect(result).toBe(0);
});
});
describe('markNotificationRead', () => {
it('marks single notification as read', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await markNotificationRead(42);
expect(apiClient.post).toHaveBeenCalledWith('/notifications/42/mark_read/');
});
});
describe('markAllNotificationsRead', () => {
it('marks all notifications as read', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await markAllNotificationsRead();
expect(apiClient.post).toHaveBeenCalledWith('/notifications/mark_all_read/');
});
});
describe('clearAllNotifications', () => {
it('clears all read notifications', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await clearAllNotifications();
expect(apiClient.delete).toHaveBeenCalledWith('/notifications/clear_all/');
});
});
});

View File

@@ -0,0 +1,441 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getOAuthProviders,
initiateOAuth,
handleOAuthCallback,
getOAuthConnections,
disconnectOAuth,
} from '../oauth';
import apiClient from '../client';
describe('oauth API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getOAuthProviders', () => {
it('fetches list of enabled OAuth providers', async () => {
const mockProviders = [
{
name: 'google',
display_name: 'Google',
icon: 'google-icon.svg',
},
{
name: 'microsoft',
display_name: 'Microsoft',
icon: 'microsoft-icon.svg',
},
{
name: 'github',
display_name: 'GitHub',
icon: 'github-icon.svg',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { providers: mockProviders },
});
const result = await getOAuthProviders();
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/providers/');
expect(result).toEqual(mockProviders);
});
it('returns empty array when no providers enabled', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { providers: [] },
});
const result = await getOAuthProviders();
expect(result).toEqual([]);
});
it('extracts providers from nested response', async () => {
const mockProviders = [
{
name: 'google',
display_name: 'Google',
icon: 'google-icon.svg',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { providers: mockProviders },
});
const result = await getOAuthProviders();
// Verify it returns response.data.providers, not response.data
expect(result).toEqual(mockProviders);
expect(Array.isArray(result)).toBe(true);
});
});
describe('initiateOAuth', () => {
it('initiates OAuth flow for Google', async () => {
const mockResponse = {
authorization_url: 'https://accounts.google.com/o/oauth2/auth?client_id=123&redirect_uri=...',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('google');
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/google/authorize/');
expect(result).toEqual(mockResponse);
expect(result.authorization_url).toContain('accounts.google.com');
});
it('initiates OAuth flow for Microsoft', async () => {
const mockResponse = {
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=...',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('microsoft');
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/microsoft/authorize/');
expect(result).toEqual(mockResponse);
});
it('initiates OAuth flow for GitHub', async () => {
const mockResponse = {
authorization_url: 'https://github.com/login/oauth/authorize?client_id=xyz&scope=...',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('github');
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/github/authorize/');
expect(result.authorization_url).toContain('github.com');
});
it('includes state parameter in authorization URL', async () => {
const mockResponse = {
authorization_url: 'https://provider.com/auth?state=random-state-token',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('google');
expect(result.authorization_url).toContain('state=');
});
});
describe('handleOAuthCallback', () => {
it('exchanges authorization code for tokens', async () => {
const mockResponse = {
access: 'access-token-123',
refresh: 'refresh-token-456',
user: {
id: 1,
username: 'johndoe',
email: 'john@example.com',
name: 'John Doe',
role: 'owner',
is_staff: false,
is_superuser: false,
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('google', 'auth-code-xyz', 'state-token-abc');
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/google/callback/', {
code: 'auth-code-xyz',
state: 'state-token-abc',
});
expect(result).toEqual(mockResponse);
expect(result.access).toBe('access-token-123');
expect(result.refresh).toBe('refresh-token-456');
expect(result.user.email).toBe('john@example.com');
});
it('handles callback with business user', async () => {
const mockResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: {
id: 2,
username: 'staffmember',
email: 'staff@business.com',
name: 'Staff Member',
role: 'staff',
is_staff: true,
is_superuser: false,
business: 5,
business_name: 'My Business',
business_subdomain: 'mybiz',
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('microsoft', 'code-123', 'state-456');
expect(result.user.business).toBe(5);
expect(result.user.business_name).toBe('My Business');
expect(result.user.business_subdomain).toBe('mybiz');
});
it('handles callback with avatar URL', async () => {
const mockResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: {
id: 3,
username: 'user',
email: 'user@example.com',
name: 'User Name',
role: 'customer',
avatar_url: 'https://avatar.com/user.jpg',
is_staff: false,
is_superuser: false,
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('github', 'code-abc', 'state-def');
expect(result.user.avatar_url).toBe('https://avatar.com/user.jpg');
});
it('handles superuser login via OAuth', async () => {
const mockResponse = {
access: 'admin-access-token',
refresh: 'admin-refresh-token',
user: {
id: 1,
username: 'admin',
email: 'admin@platform.com',
name: 'Platform Admin',
role: 'superuser',
is_staff: true,
is_superuser: true,
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('google', 'admin-code', 'admin-state');
expect(result.user.is_superuser).toBe(true);
expect(result.user.role).toBe('superuser');
});
it('sends correct data for different providers', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: {
access: 'token',
refresh: 'token',
user: { id: 1, email: 'test@test.com', name: 'Test', role: 'owner', is_staff: false, is_superuser: false, username: 'test' },
},
});
await handleOAuthCallback('github', 'code-1', 'state-1');
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/github/callback/', {
code: 'code-1',
state: 'state-1',
});
await handleOAuthCallback('microsoft', 'code-2', 'state-2');
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/microsoft/callback/', {
code: 'code-2',
state: 'state-2',
});
});
});
describe('getOAuthConnections', () => {
it('fetches list of connected OAuth accounts', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'google',
provider_user_id: 'google-user-123',
email: 'user@gmail.com',
connected_at: '2024-01-15T10:30:00Z',
},
{
id: 'conn-2',
provider: 'microsoft',
provider_user_id: 'ms-user-456',
email: 'user@outlook.com',
connected_at: '2024-02-20T14:45:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/connections/');
expect(result).toEqual(mockConnections);
expect(result).toHaveLength(2);
});
it('returns empty array when no connections exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: [] },
});
const result = await getOAuthConnections();
expect(result).toEqual([]);
});
it('extracts connections from nested response', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'github',
provider_user_id: 'github-123',
connected_at: '2024-03-01T09:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
// Verify it returns response.data.connections, not response.data
expect(result).toEqual(mockConnections);
expect(Array.isArray(result)).toBe(true);
});
it('handles connections without email field', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'github',
provider_user_id: 'github-user-789',
connected_at: '2024-04-10T12:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
expect(result[0].email).toBeUndefined();
expect(result[0].provider).toBe('github');
});
it('handles multiple connections from same provider', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'google',
provider_user_id: 'google-user-1',
email: 'work@gmail.com',
connected_at: '2024-01-01T00:00:00Z',
},
{
id: 'conn-2',
provider: 'google',
provider_user_id: 'google-user-2',
email: 'personal@gmail.com',
connected_at: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
expect(result).toHaveLength(2);
expect(result.filter((c) => c.provider === 'google')).toHaveLength(2);
});
});
describe('disconnectOAuth', () => {
it('disconnects Google OAuth account', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('google');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/google/');
});
it('disconnects Microsoft OAuth account', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('microsoft');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/microsoft/');
});
it('disconnects GitHub OAuth account', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('github');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/github/');
});
it('returns void on successful disconnect', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const result = await disconnectOAuth('google');
expect(result).toBeUndefined();
});
it('handles disconnect for custom provider', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('custom-provider');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/custom-provider/');
});
});
describe('error handling', () => {
it('propagates errors from getOAuthProviders', async () => {
const error = new Error('Network error');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(getOAuthProviders()).rejects.toThrow('Network error');
});
it('propagates errors from initiateOAuth', async () => {
const error = new Error('Provider not configured');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(initiateOAuth('google')).rejects.toThrow('Provider not configured');
});
it('propagates errors from handleOAuthCallback', async () => {
const error = new Error('Invalid authorization code');
vi.mocked(apiClient.post).mockRejectedValue(error);
await expect(handleOAuthCallback('google', 'bad-code', 'state')).rejects.toThrow('Invalid authorization code');
});
it('propagates errors from getOAuthConnections', async () => {
const error = new Error('Unauthorized');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(getOAuthConnections()).rejects.toThrow('Unauthorized');
});
it('propagates errors from disconnectOAuth', async () => {
const error = new Error('Connection not found');
vi.mocked(apiClient.delete).mockRejectedValue(error);
await expect(disconnectOAuth('google')).rejects.toThrow('Connection not found');
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,989 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getBusinesses,
updateBusiness,
createBusiness,
deleteBusiness,
getUsers,
getBusinessUsers,
verifyUserEmail,
getTenantInvitations,
createTenantInvitation,
resendTenantInvitation,
cancelTenantInvitation,
getInvitationByToken,
acceptInvitation,
type PlatformBusiness,
type PlatformBusinessUpdate,
type PlatformBusinessCreate,
type PlatformUser,
type TenantInvitation,
type TenantInvitationCreate,
type TenantInvitationDetail,
type TenantInvitationAccept,
} from '../platform';
import apiClient from '../client';
describe('platform API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ============================================================================
// Business Management
// ============================================================================
describe('getBusinesses', () => {
it('fetches all businesses from API', async () => {
const mockBusinesses: PlatformBusiness[] = [
{
id: 1,
name: 'Acme Corp',
subdomain: 'acme',
tier: 'PROFESSIONAL',
is_active: true,
created_on: '2025-01-01T00:00:00Z',
user_count: 5,
owner: {
id: 10,
username: 'john',
full_name: 'John Doe',
email: 'john@acme.com',
role: 'owner',
email_verified: true,
},
max_users: 20,
max_resources: 50,
contact_email: 'contact@acme.com',
phone: '555-1234',
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
},
{
id: 2,
name: 'Beta LLC',
subdomain: 'beta',
tier: 'STARTER',
is_active: true,
created_on: '2025-01-02T00:00:00Z',
user_count: 2,
owner: null,
max_users: 5,
max_resources: 10,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusinesses });
const result = await getBusinesses();
expect(apiClient.get).toHaveBeenCalledWith('/platform/businesses/');
expect(result).toEqual(mockBusinesses);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('Acme Corp');
expect(result[1].owner).toBeNull();
});
it('returns empty array when no businesses exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getBusinesses();
expect(result).toEqual([]);
});
});
describe('updateBusiness', () => {
it('updates a business with full data', async () => {
const businessId = 1;
const updateData: PlatformBusinessUpdate = {
name: 'Updated Name',
is_active: false,
subscription_tier: 'ENTERPRISE',
max_users: 100,
max_resources: 500,
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
};
const mockResponse: PlatformBusiness = {
id: 1,
name: 'Updated Name',
subdomain: 'acme',
tier: 'ENTERPRISE',
is_active: false,
created_on: '2025-01-01T00:00:00Z',
user_count: 5,
owner: null,
max_users: 100,
max_resources: 500,
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateBusiness(businessId, updateData);
expect(apiClient.patch).toHaveBeenCalledWith(
'/platform/businesses/1/',
updateData
);
expect(result).toEqual(mockResponse);
expect(result.name).toBe('Updated Name');
expect(result.is_active).toBe(false);
});
it('updates a business with partial data', async () => {
const businessId = 2;
const updateData: PlatformBusinessUpdate = {
is_active: true,
};
const mockResponse: PlatformBusiness = {
id: 2,
name: 'Beta LLC',
subdomain: 'beta',
tier: 'STARTER',
is_active: true,
created_on: '2025-01-02T00:00:00Z',
user_count: 2,
owner: null,
max_users: 5,
max_resources: 10,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateBusiness(businessId, updateData);
expect(apiClient.patch).toHaveBeenCalledWith(
'/platform/businesses/2/',
updateData
);
expect(result.is_active).toBe(true);
});
it('updates only specific permissions', async () => {
const businessId = 3;
const updateData: PlatformBusinessUpdate = {
can_accept_payments: true,
can_api_access: true,
};
const mockResponse: PlatformBusiness = {
id: 3,
name: 'Gamma Inc',
subdomain: 'gamma',
tier: 'PROFESSIONAL',
is_active: true,
created_on: '2025-01-03T00:00:00Z',
user_count: 10,
owner: null,
max_users: 20,
max_resources: 50,
can_manage_oauth_credentials: false,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
await updateBusiness(businessId, updateData);
expect(apiClient.patch).toHaveBeenCalledWith(
'/platform/businesses/3/',
updateData
);
});
});
describe('createBusiness', () => {
it('creates a business with minimal data', async () => {
const createData: PlatformBusinessCreate = {
name: 'New Business',
subdomain: 'newbiz',
};
const mockResponse: PlatformBusiness = {
id: 10,
name: 'New Business',
subdomain: 'newbiz',
tier: 'FREE',
is_active: true,
created_on: '2025-01-15T00:00:00Z',
user_count: 0,
owner: null,
max_users: 3,
max_resources: 5,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createBusiness(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/businesses/',
createData
);
expect(result).toEqual(mockResponse);
expect(result.id).toBe(10);
expect(result.subdomain).toBe('newbiz');
});
it('creates a business with full data including owner', async () => {
const createData: PlatformBusinessCreate = {
name: 'Premium Business',
subdomain: 'premium',
subscription_tier: 'ENTERPRISE',
is_active: true,
max_users: 100,
max_resources: 500,
contact_email: 'contact@premium.com',
phone: '555-9999',
can_manage_oauth_credentials: true,
owner_email: 'owner@premium.com',
owner_name: 'Jane Smith',
owner_password: 'secure-password',
};
const mockResponse: PlatformBusiness = {
id: 11,
name: 'Premium Business',
subdomain: 'premium',
tier: 'ENTERPRISE',
is_active: true,
created_on: '2025-01-15T10:00:00Z',
user_count: 1,
owner: {
id: 20,
username: 'owner@premium.com',
full_name: 'Jane Smith',
email: 'owner@premium.com',
role: 'owner',
email_verified: false,
},
max_users: 100,
max_resources: 500,
contact_email: 'contact@premium.com',
phone: '555-9999',
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createBusiness(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/businesses/',
createData
);
expect(result.owner).not.toBeNull();
expect(result.owner?.email).toBe('owner@premium.com');
});
it('creates a business with custom tier and limits', async () => {
const createData: PlatformBusinessCreate = {
name: 'Custom Business',
subdomain: 'custom',
subscription_tier: 'PROFESSIONAL',
max_users: 50,
max_resources: 100,
};
const mockResponse: PlatformBusiness = {
id: 12,
name: 'Custom Business',
subdomain: 'custom',
tier: 'PROFESSIONAL',
is_active: true,
created_on: '2025-01-15T12:00:00Z',
user_count: 0,
owner: null,
max_users: 50,
max_resources: 100,
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createBusiness(createData);
expect(result.max_users).toBe(50);
expect(result.max_resources).toBe(100);
});
});
describe('deleteBusiness', () => {
it('deletes a business by ID', async () => {
const businessId = 5;
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteBusiness(businessId);
expect(apiClient.delete).toHaveBeenCalledWith('/platform/businesses/5/');
});
it('handles deletion with no response data', async () => {
const businessId = 10;
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
const result = await deleteBusiness(businessId);
expect(apiClient.delete).toHaveBeenCalledWith('/platform/businesses/10/');
expect(result).toBeUndefined();
});
});
// ============================================================================
// User Management
// ============================================================================
describe('getUsers', () => {
it('fetches all users from API', async () => {
const mockUsers: PlatformUser[] = [
{
id: 1,
email: 'admin@platform.com',
username: 'admin',
name: 'Platform Admin',
role: 'superuser',
is_active: true,
is_staff: true,
is_superuser: true,
email_verified: true,
business: null,
date_joined: '2024-01-01T00:00:00Z',
last_login: '2025-01-15T10:00:00Z',
},
{
id: 2,
email: 'user@acme.com',
username: 'user1',
name: 'Acme User',
role: 'staff',
is_active: true,
is_staff: false,
is_superuser: false,
email_verified: true,
business: 1,
business_name: 'Acme Corp',
business_subdomain: 'acme',
date_joined: '2024-06-01T00:00:00Z',
last_login: '2025-01-14T15:30:00Z',
},
{
id: 3,
email: 'inactive@example.com',
username: 'inactive',
is_active: false,
is_staff: false,
is_superuser: false,
email_verified: false,
business: null,
date_joined: '2024-03-15T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
const result = await getUsers();
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/');
expect(result).toEqual(mockUsers);
expect(result).toHaveLength(3);
expect(result[0].is_superuser).toBe(true);
expect(result[1].business_name).toBe('Acme Corp');
expect(result[2].is_active).toBe(false);
});
it('returns empty array when no users exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getUsers();
expect(result).toEqual([]);
});
});
describe('getBusinessUsers', () => {
it('fetches users for a specific business', async () => {
const businessId = 1;
const mockUsers: PlatformUser[] = [
{
id: 10,
email: 'owner@acme.com',
username: 'owner',
name: 'John Doe',
role: 'owner',
is_active: true,
is_staff: false,
is_superuser: false,
email_verified: true,
business: 1,
business_name: 'Acme Corp',
business_subdomain: 'acme',
date_joined: '2024-01-01T00:00:00Z',
last_login: '2025-01-15T09:00:00Z',
},
{
id: 11,
email: 'staff@acme.com',
username: 'staff1',
name: 'Jane Smith',
role: 'staff',
is_active: true,
is_staff: false,
is_superuser: false,
email_verified: true,
business: 1,
business_name: 'Acme Corp',
business_subdomain: 'acme',
date_joined: '2024-03-01T00:00:00Z',
last_login: '2025-01-14T16:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
const result = await getBusinessUsers(businessId);
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=1');
expect(result).toEqual(mockUsers);
expect(result).toHaveLength(2);
expect(result.every(u => u.business === 1)).toBe(true);
});
it('returns empty array when business has no users', async () => {
const businessId = 99;
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getBusinessUsers(businessId);
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=99');
expect(result).toEqual([]);
});
it('handles different business IDs correctly', async () => {
const businessId = 5;
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getBusinessUsers(businessId);
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=5');
});
});
describe('verifyUserEmail', () => {
it('verifies a user email by ID', async () => {
const userId = 10;
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyUserEmail(userId);
expect(apiClient.post).toHaveBeenCalledWith('/platform/users/10/verify_email/');
});
it('handles verification with no response data', async () => {
const userId = 25;
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
const result = await verifyUserEmail(userId);
expect(apiClient.post).toHaveBeenCalledWith('/platform/users/25/verify_email/');
expect(result).toBeUndefined();
});
});
// ============================================================================
// Tenant Invitations
// ============================================================================
describe('getTenantInvitations', () => {
it('fetches all tenant invitations from API', async () => {
const mockInvitations: TenantInvitation[] = [
{
id: 1,
email: 'newclient@example.com',
token: 'abc123token',
status: 'PENDING',
suggested_business_name: 'New Client Corp',
subscription_tier: 'PROFESSIONAL',
custom_max_users: 50,
custom_max_resources: 100,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
},
personal_message: 'Welcome to our platform!',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-10T10:00:00Z',
expires_at: '2025-01-24T10:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
},
{
id: 2,
email: 'accepted@example.com',
token: 'xyz789token',
status: 'ACCEPTED',
suggested_business_name: 'Accepted Business',
subscription_tier: 'STARTER',
custom_max_users: null,
custom_max_resources: null,
permissions: {},
personal_message: '',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-05T08:00:00Z',
expires_at: '2025-01-19T08:00:00Z',
accepted_at: '2025-01-06T12:00:00Z',
created_tenant: 5,
created_tenant_name: 'Accepted Business',
created_user: 15,
created_user_email: 'accepted@example.com',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitations });
const result = await getTenantInvitations();
expect(apiClient.get).toHaveBeenCalledWith('/platform/tenant-invitations/');
expect(result).toEqual(mockInvitations);
expect(result).toHaveLength(2);
expect(result[0].status).toBe('PENDING');
expect(result[1].status).toBe('ACCEPTED');
});
it('returns empty array when no invitations exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTenantInvitations();
expect(result).toEqual([]);
});
});
describe('createTenantInvitation', () => {
it('creates a tenant invitation with minimal data', async () => {
const createData: TenantInvitationCreate = {
email: 'client@example.com',
subscription_tier: 'STARTER',
};
const mockResponse: TenantInvitation = {
id: 10,
email: 'client@example.com',
token: 'generated-token-123',
status: 'PENDING',
suggested_business_name: '',
subscription_tier: 'STARTER',
custom_max_users: null,
custom_max_resources: null,
permissions: {},
personal_message: '',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-15T14:00:00Z',
expires_at: '2025-01-29T14:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTenantInvitation(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/',
createData
);
expect(result).toEqual(mockResponse);
expect(result.email).toBe('client@example.com');
expect(result.status).toBe('PENDING');
});
it('creates a tenant invitation with full data', async () => {
const createData: TenantInvitationCreate = {
email: 'vip@example.com',
suggested_business_name: 'VIP Corp',
subscription_tier: 'ENTERPRISE',
custom_max_users: 500,
custom_max_resources: 1000,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
},
personal_message: 'Welcome to our premium tier!',
};
const mockResponse: TenantInvitation = {
id: 11,
email: 'vip@example.com',
token: 'vip-token-456',
status: 'PENDING',
suggested_business_name: 'VIP Corp',
subscription_tier: 'ENTERPRISE',
custom_max_users: 500,
custom_max_resources: 1000,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
},
personal_message: 'Welcome to our premium tier!',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-15T15:00:00Z',
expires_at: '2025-01-29T15:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTenantInvitation(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/',
createData
);
expect(result.suggested_business_name).toBe('VIP Corp');
expect(result.custom_max_users).toBe(500);
expect(result.permissions.can_white_label).toBe(true);
});
it('creates invitation with partial permissions', async () => {
const createData: TenantInvitationCreate = {
email: 'partial@example.com',
subscription_tier: 'PROFESSIONAL',
permissions: {
can_accept_payments: true,
},
};
const mockResponse: TenantInvitation = {
id: 12,
email: 'partial@example.com',
token: 'partial-token',
status: 'PENDING',
suggested_business_name: '',
subscription_tier: 'PROFESSIONAL',
custom_max_users: null,
custom_max_resources: null,
permissions: {
can_accept_payments: true,
},
personal_message: '',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-15T16:00:00Z',
expires_at: '2025-01-29T16:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTenantInvitation(createData);
expect(result.permissions.can_accept_payments).toBe(true);
});
});
describe('resendTenantInvitation', () => {
it('resends a tenant invitation by ID', async () => {
const invitationId = 5;
vi.mocked(apiClient.post).mockResolvedValue({});
await resendTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/5/resend/'
);
});
it('handles resend with no response data', async () => {
const invitationId = 10;
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
const result = await resendTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/10/resend/'
);
expect(result).toBeUndefined();
});
});
describe('cancelTenantInvitation', () => {
it('cancels a tenant invitation by ID', async () => {
const invitationId = 7;
vi.mocked(apiClient.post).mockResolvedValue({});
await cancelTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/7/cancel/'
);
});
it('handles cancellation with no response data', async () => {
const invitationId = 15;
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
const result = await cancelTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/15/cancel/'
);
expect(result).toBeUndefined();
});
});
describe('getInvitationByToken', () => {
it('fetches invitation details by token', async () => {
const token = 'abc123token';
const mockInvitation: TenantInvitationDetail = {
email: 'invited@example.com',
suggested_business_name: 'Invited Corp',
subscription_tier: 'PROFESSIONAL',
effective_max_users: 20,
effective_max_resources: 50,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
},
expires_at: '2025-01-30T12:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
const result = await getInvitationByToken(token);
expect(apiClient.get).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/abc123token/'
);
expect(result).toEqual(mockInvitation);
expect(result.email).toBe('invited@example.com');
expect(result.effective_max_users).toBe(20);
});
it('handles tokens with special characters', async () => {
const token = 'token-with-dashes_and_underscores';
const mockInvitation: TenantInvitationDetail = {
email: 'test@example.com',
suggested_business_name: 'Test',
subscription_tier: 'FREE',
effective_max_users: 3,
effective_max_resources: 5,
permissions: {},
expires_at: '2025-02-01T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
await getInvitationByToken(token);
expect(apiClient.get).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/token-with-dashes_and_underscores/'
);
});
it('fetches invitation with custom limits', async () => {
const token = 'custom-limits-token';
const mockInvitation: TenantInvitationDetail = {
email: 'custom@example.com',
suggested_business_name: 'Custom Business',
subscription_tier: 'ENTERPRISE',
effective_max_users: 1000,
effective_max_resources: 5000,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
},
expires_at: '2025-03-01T12:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
const result = await getInvitationByToken(token);
expect(result.effective_max_users).toBe(1000);
expect(result.effective_max_resources).toBe(5000);
});
});
describe('acceptInvitation', () => {
it('accepts an invitation with full data', async () => {
const token = 'accept-token-123';
const acceptData: TenantInvitationAccept = {
email: 'newowner@example.com',
password: 'secure-password',
first_name: 'John',
last_name: 'Doe',
business_name: 'New Business LLC',
subdomain: 'newbiz',
contact_email: 'contact@newbiz.com',
phone: '555-1234',
};
const mockResponse = {
detail: 'Invitation accepted successfully. Your account has been created.',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/accept-token-123/accept/',
acceptData
);
expect(result).toEqual(mockResponse);
expect(result.detail).toContain('successfully');
});
it('accepts an invitation with minimal data', async () => {
const token = 'minimal-token';
const acceptData: TenantInvitationAccept = {
email: 'minimal@example.com',
password: 'password123',
first_name: 'Jane',
last_name: 'Smith',
business_name: 'Minimal Business',
subdomain: 'minimal',
};
const mockResponse = {
detail: 'Account created successfully.',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/minimal-token/accept/',
acceptData
);
expect(result.detail).toBe('Account created successfully.');
});
it('handles acceptance with optional contact fields', async () => {
const token = 'optional-fields-token';
const acceptData: TenantInvitationAccept = {
email: 'test@example.com',
password: 'testpass',
first_name: 'Test',
last_name: 'User',
business_name: 'Test Business',
subdomain: 'testbiz',
contact_email: 'info@testbiz.com',
};
const mockResponse = {
detail: 'Welcome to the platform!',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/optional-fields-token/accept/',
expect.objectContaining({
contact_email: 'info@testbiz.com',
})
);
});
it('preserves all required fields in request', async () => {
const token = 'complete-token';
const acceptData: TenantInvitationAccept = {
email: 'complete@example.com',
password: 'strong-password-123',
first_name: 'Complete',
last_name: 'User',
business_name: 'Complete Business Corp',
subdomain: 'complete',
contact_email: 'support@complete.com',
phone: '555-9876',
};
vi.mocked(apiClient.post).mockResolvedValue({
data: { detail: 'Success' },
});
await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/complete-token/accept/',
expect.objectContaining({
email: 'complete@example.com',
password: 'strong-password-123',
first_name: 'Complete',
last_name: 'User',
business_name: 'Complete Business Corp',
subdomain: 'complete',
contact_email: 'support@complete.com',
phone: '555-9876',
})
);
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,335 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getProfile,
updateProfile,
uploadAvatar,
deleteAvatar,
sendVerificationEmail,
verifyEmail,
requestEmailChange,
confirmEmailChange,
changePassword,
setupTOTP,
verifyTOTP,
disableTOTP,
getRecoveryCodes,
regenerateRecoveryCodes,
getSessions,
revokeSession,
revokeOtherSessions,
getLoginHistory,
sendPhoneVerification,
verifyPhoneCode,
getUserEmails,
addUserEmail,
deleteUserEmail,
sendUserEmailVerification,
verifyUserEmail,
setPrimaryEmail,
} from '../profile';
import apiClient from '../client';
describe('profile API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getProfile', () => {
it('fetches user profile', async () => {
const mockProfile = {
id: 1,
email: 'test@example.com',
name: 'Test User',
email_verified: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockProfile });
const result = await getProfile();
expect(apiClient.get).toHaveBeenCalledWith('/auth/profile/');
expect(result).toEqual(mockProfile);
});
});
describe('updateProfile', () => {
it('updates profile with provided data', async () => {
const mockUpdated = { id: 1, name: 'Updated Name' };
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockUpdated });
const result = await updateProfile({ name: 'Updated Name' });
expect(apiClient.patch).toHaveBeenCalledWith('/auth/profile/', { name: 'Updated Name' });
expect(result).toEqual(mockUpdated);
});
});
describe('uploadAvatar', () => {
it('uploads avatar file', async () => {
const mockResponse = { avatar_url: 'https://example.com/avatar.jpg' };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const file = new File(['test'], 'avatar.jpg', { type: 'image/jpeg' });
const result = await uploadAvatar(file);
expect(apiClient.post).toHaveBeenCalledWith(
'/auth/profile/avatar/',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
expect(result.avatar_url).toBe('https://example.com/avatar.jpg');
});
});
describe('deleteAvatar', () => {
it('deletes user avatar', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteAvatar();
expect(apiClient.delete).toHaveBeenCalledWith('/auth/profile/avatar/');
});
});
describe('email verification', () => {
it('sends verification email', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await sendVerificationEmail();
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/verify/send/');
});
it('verifies email with token', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyEmail('verification-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/verify/confirm/', {
token: 'verification-token',
});
});
});
describe('email change', () => {
it('requests email change', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await requestEmailChange('new@example.com');
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/change/', {
new_email: 'new@example.com',
});
});
it('confirms email change', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await confirmEmailChange('change-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/change/confirm/', {
token: 'change-token',
});
});
});
describe('changePassword', () => {
it('changes password with current and new', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await changePassword('oldPassword', 'newPassword');
expect(apiClient.post).toHaveBeenCalledWith('/auth/password/change/', {
current_password: 'oldPassword',
new_password: 'newPassword',
});
});
});
describe('2FA / TOTP', () => {
it('sets up TOTP', async () => {
const mockSetup = {
secret: 'ABCD1234',
qr_code: 'base64...',
provisioning_uri: 'otpauth://...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSetup });
const result = await setupTOTP();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/setup/');
expect(result.secret).toBe('ABCD1234');
});
it('verifies TOTP code', async () => {
const mockResponse = { success: true, backup_codes: ['code1', 'code2'] };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await verifyTOTP('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/verify/', { code: '123456' });
expect(result.success).toBe(true);
expect(result.recovery_codes).toEqual(['code1', 'code2']);
});
it('disables TOTP', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await disableTOTP('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', { mfa_code: '123456' });
});
it('gets recovery codes status', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: {} });
const result = await getRecoveryCodes();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/backup-codes/status/');
expect(result).toEqual([]);
});
it('regenerates recovery codes', async () => {
const mockCodes = ['code1', 'code2', 'code3'];
vi.mocked(apiClient.post).mockResolvedValue({ data: { backup_codes: mockCodes } });
const result = await regenerateRecoveryCodes();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/backup-codes/');
expect(result).toEqual(mockCodes);
});
});
describe('sessions', () => {
it('gets sessions', async () => {
const mockSessions = [
{ id: '1', device_info: 'Chrome', ip_address: '1.1.1.1', is_current: true },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockSessions });
const result = await getSessions();
expect(apiClient.get).toHaveBeenCalledWith('/auth/sessions/');
expect(result).toEqual(mockSessions);
});
it('revokes session', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await revokeSession('session-123');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/sessions/session-123/');
});
it('revokes other sessions', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await revokeOtherSessions();
expect(apiClient.post).toHaveBeenCalledWith('/auth/sessions/revoke-others/');
});
it('gets login history', async () => {
const mockHistory = [
{ id: '1', timestamp: '2024-01-01', success: true },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockHistory });
const result = await getLoginHistory();
expect(apiClient.get).toHaveBeenCalledWith('/auth/login-history/');
expect(result).toEqual(mockHistory);
});
});
describe('phone verification', () => {
it('sends phone verification', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await sendPhoneVerification('555-1234');
expect(apiClient.post).toHaveBeenCalledWith('/auth/phone/verify/send/', {
phone: '555-1234',
});
});
it('verifies phone code', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyPhoneCode('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/phone/verify/confirm/', {
code: '123456',
});
});
});
describe('multiple emails', () => {
it('gets user emails', async () => {
const mockEmails = [
{ id: 1, email: 'primary@example.com', is_primary: true, verified: true },
{ id: 2, email: 'secondary@example.com', is_primary: false, verified: false },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getUserEmails();
expect(apiClient.get).toHaveBeenCalledWith('/auth/emails/');
expect(result).toEqual(mockEmails);
});
it('adds user email', async () => {
const mockEmail = { id: 3, email: 'new@example.com', is_primary: false, verified: false };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockEmail });
const result = await addUserEmail('new@example.com');
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/', { email: 'new@example.com' });
expect(result).toEqual(mockEmail);
});
it('deletes user email', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteUserEmail(2);
expect(apiClient.delete).toHaveBeenCalledWith('/auth/emails/2/');
});
it('sends user email verification', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await sendUserEmailVerification(2);
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/send-verification/');
});
it('verifies user email', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyUserEmail(2, 'verify-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/verify/', {
token: 'verify-token',
});
});
it('sets primary email', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await setPrimaryEmail(2);
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/set-primary/');
});
});
});

View File

@@ -0,0 +1,609 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
import {
getQuotaStatus,
getQuotaResources,
archiveResources,
unarchiveResource,
getOverageDetail,
QuotaStatus,
QuotaResourcesResponse,
ArchiveResponse,
QuotaOverageDetail,
} from '../quota';
import apiClient from '../client';
describe('quota API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getQuotaStatus', () => {
it('fetches quota status from API', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [
{
id: 1,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 7,
grace_period_ends_at: '2025-12-14T00:00:00Z',
},
],
usage: {
resources: {
current: 15,
limit: 10,
display_name: 'Resources',
},
staff: {
current: 3,
limit: 5,
display_name: 'Staff Members',
},
services: {
current: 8,
limit: 20,
display_name: 'Services',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(apiClient.get).toHaveBeenCalledWith('/quota/status/');
expect(result).toEqual(mockQuotaStatus);
expect(result.active_overages).toHaveLength(1);
expect(result.usage.resources.current).toBe(15);
});
it('returns empty active_overages when no overages exist', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [],
usage: {
resources: {
current: 5,
limit: 10,
display_name: 'Resources',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(result.active_overages).toHaveLength(0);
expect(result.usage.resources.current).toBeLessThan(result.usage.resources.limit);
});
it('handles multiple quota types in usage', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [],
usage: {
resources: {
current: 5,
limit: 10,
display_name: 'Resources',
},
staff: {
current: 2,
limit: 5,
display_name: 'Staff Members',
},
services: {
current: 15,
limit: 20,
display_name: 'Services',
},
customers: {
current: 100,
limit: 500,
display_name: 'Customers',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(Object.keys(result.usage)).toHaveLength(4);
expect(result.usage).toHaveProperty('resources');
expect(result.usage).toHaveProperty('staff');
expect(result.usage).toHaveProperty('services');
expect(result.usage).toHaveProperty('customers');
});
});
describe('getQuotaResources', () => {
it('fetches resources for a specific quota type', async () => {
const mockResourcesResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [
{
id: 1,
name: 'Conference Room A',
type: 'room',
created_at: '2025-01-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 2,
name: 'Conference Room B',
type: 'room',
created_at: '2025-01-02T11:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourcesResponse });
const result = await getQuotaResources('resources');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/resources/');
expect(result).toEqual(mockResourcesResponse);
expect(result.quota_type).toBe('resources');
expect(result.resources).toHaveLength(2);
});
it('fetches staff members for staff quota type', async () => {
const mockStaffResponse: QuotaResourcesResponse = {
quota_type: 'staff',
resources: [
{
id: 10,
name: 'John Doe',
email: 'john@example.com',
role: 'staff',
created_at: '2025-01-15T09:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 11,
name: 'Jane Smith',
email: 'jane@example.com',
role: 'manager',
created_at: '2025-01-16T09:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaffResponse });
const result = await getQuotaResources('staff');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/staff/');
expect(result.quota_type).toBe('staff');
expect(result.resources[0]).toHaveProperty('email');
expect(result.resources[0]).toHaveProperty('role');
});
it('fetches services for services quota type', async () => {
const mockServicesResponse: QuotaResourcesResponse = {
quota_type: 'services',
resources: [
{
id: 20,
name: 'Haircut',
duration: 30,
price: '25.00',
created_at: '2025-02-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 21,
name: 'Color Treatment',
duration: 90,
price: '75.00',
created_at: '2025-02-02T10:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockServicesResponse });
const result = await getQuotaResources('services');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/services/');
expect(result.quota_type).toBe('services');
expect(result.resources[0]).toHaveProperty('duration');
expect(result.resources[0]).toHaveProperty('price');
});
it('includes archived resources', async () => {
const mockResourcesResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [
{
id: 1,
name: 'Active Resource',
created_at: '2025-01-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 2,
name: 'Archived Resource',
created_at: '2024-12-01T10:00:00Z',
is_archived: true,
archived_at: '2025-12-01T15:30:00Z',
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourcesResponse });
const result = await getQuotaResources('resources');
expect(result.resources).toHaveLength(2);
expect(result.resources[0].is_archived).toBe(false);
expect(result.resources[1].is_archived).toBe(true);
expect(result.resources[1].archived_at).toBe('2025-12-01T15:30:00Z');
});
it('handles empty resources list', async () => {
const mockEmptyResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmptyResponse });
const result = await getQuotaResources('resources');
expect(result.resources).toHaveLength(0);
expect(result.quota_type).toBe('resources');
});
});
describe('archiveResources', () => {
it('archives multiple resources successfully', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 3,
current_usage: 7,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', [1, 2, 3]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'resources',
resource_ids: [1, 2, 3],
});
expect(result).toEqual(mockArchiveResponse);
expect(result.archived_count).toBe(3);
expect(result.is_resolved).toBe(true);
});
it('archives single resource', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 1,
current_usage: 9,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('staff', [5]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'staff',
resource_ids: [5],
});
expect(result.archived_count).toBe(1);
});
it('indicates overage is still not resolved after archiving', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 2,
current_usage: 12,
limit: 10,
is_resolved: false,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', [1, 2]);
expect(result.is_resolved).toBe(false);
expect(result.current_usage).toBeGreaterThan(result.limit);
});
it('handles archiving with different quota types', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 5,
current_usage: 15,
limit: 20,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
await archiveResources('services', [10, 11, 12, 13, 14]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'services',
resource_ids: [10, 11, 12, 13, 14],
});
});
it('handles empty resource_ids array', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 0,
current_usage: 10,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', []);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'resources',
resource_ids: [],
});
expect(result.archived_count).toBe(0);
});
});
describe('unarchiveResource', () => {
it('unarchives a resource successfully', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 5,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('resources', 5);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'resources',
resource_id: 5,
});
expect(result).toEqual(mockUnarchiveResponse);
expect(result.success).toBe(true);
expect(result.resource_id).toBe(5);
});
it('unarchives staff member', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 10,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('staff', 10);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'staff',
resource_id: 10,
});
expect(result.success).toBe(true);
});
it('unarchives service', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 20,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('services', 20);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'services',
resource_id: 20,
});
expect(result.resource_id).toBe(20);
});
it('handles unsuccessful unarchive', async () => {
const mockUnarchiveResponse = {
success: false,
resource_id: 5,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('resources', 5);
expect(result.success).toBe(false);
});
});
describe('getOverageDetail', () => {
it('fetches detailed overage information', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 1,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 7,
grace_period_ends_at: '2025-12-14T00:00:00Z',
status: 'active',
created_at: '2025-12-07T10:00:00Z',
initial_email_sent_at: '2025-12-07T10:05:00Z',
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(1);
expect(apiClient.get).toHaveBeenCalledWith('/quota/overages/1/');
expect(result).toEqual(mockOverageDetail);
expect(result.status).toBe('active');
expect(result.overage_amount).toBe(5);
});
it('includes sent email timestamps', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 2,
quota_type: 'staff',
display_name: 'Staff Members',
current_usage: 8,
allowed_limit: 5,
overage_amount: 3,
days_remaining: 3,
grace_period_ends_at: '2025-12-10T00:00:00Z',
status: 'active',
created_at: '2025-11-30T10:00:00Z',
initial_email_sent_at: '2025-11-30T10:05:00Z',
week_reminder_sent_at: '2025-12-03T09:00:00Z',
day_reminder_sent_at: '2025-12-06T09:00:00Z',
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(2);
expect(result.initial_email_sent_at).toBe('2025-11-30T10:05:00Z');
expect(result.week_reminder_sent_at).toBe('2025-12-03T09:00:00Z');
expect(result.day_reminder_sent_at).toBe('2025-12-06T09:00:00Z');
});
it('includes archived resource IDs', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 3,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 10,
allowed_limit: 10,
overage_amount: 0,
days_remaining: 5,
grace_period_ends_at: '2025-12-12T00:00:00Z',
status: 'resolved',
created_at: '2025-12-01T10:00:00Z',
initial_email_sent_at: '2025-12-01T10:05:00Z',
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [1, 3, 5, 7],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(3);
expect(result.archived_resource_ids).toHaveLength(4);
expect(result.archived_resource_ids).toEqual([1, 3, 5, 7]);
expect(result.status).toBe('resolved');
});
it('handles resolved overage with zero overage_amount', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 4,
quota_type: 'services',
display_name: 'Services',
current_usage: 18,
allowed_limit: 20,
overage_amount: 0,
days_remaining: 0,
grace_period_ends_at: '2025-12-05T00:00:00Z',
status: 'resolved',
created_at: '2025-11-25T10:00:00Z',
initial_email_sent_at: '2025-11-25T10:05:00Z',
week_reminder_sent_at: '2025-11-28T09:00:00Z',
day_reminder_sent_at: null,
archived_resource_ids: [20, 21],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(4);
expect(result.overage_amount).toBe(0);
expect(result.status).toBe('resolved');
expect(result.current_usage).toBeLessThanOrEqual(result.allowed_limit);
});
it('handles expired overage', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 5,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 0,
grace_period_ends_at: '2025-12-06T00:00:00Z',
status: 'expired',
created_at: '2025-11-20T10:00:00Z',
initial_email_sent_at: '2025-11-20T10:05:00Z',
week_reminder_sent_at: '2025-11-27T09:00:00Z',
day_reminder_sent_at: '2025-12-05T09:00:00Z',
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(5);
expect(result.status).toBe('expired');
expect(result.days_remaining).toBe(0);
expect(result.overage_amount).toBeGreaterThan(0);
});
it('handles null email timestamps when no reminders sent', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 6,
quota_type: 'staff',
display_name: 'Staff Members',
current_usage: 6,
allowed_limit: 5,
overage_amount: 1,
days_remaining: 14,
grace_period_ends_at: '2025-12-21T00:00:00Z',
status: 'active',
created_at: '2025-12-07T10:00:00Z',
initial_email_sent_at: null,
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(6);
expect(result.initial_email_sent_at).toBeNull();
expect(result.week_reminder_sent_at).toBeNull();
expect(result.day_reminder_sent_at).toBeNull();
});
});
});

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
import {
getSandboxStatus,
toggleSandboxMode,
resetSandboxData,
} from '../sandbox';
import apiClient from '../client';
describe('sandbox API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getSandboxStatus', () => {
it('fetches sandbox status from API', async () => {
const mockStatus = {
sandbox_mode: true,
sandbox_enabled: true,
sandbox_schema: 'test_business_sandbox',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getSandboxStatus();
expect(apiClient.get).toHaveBeenCalledWith('/sandbox/status/');
expect(result).toEqual(mockStatus);
});
it('returns sandbox disabled status', async () => {
const mockStatus = {
sandbox_mode: false,
sandbox_enabled: false,
sandbox_schema: null,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getSandboxStatus();
expect(result.sandbox_mode).toBe(false);
expect(result.sandbox_enabled).toBe(false);
expect(result.sandbox_schema).toBeNull();
});
it('returns sandbox enabled but not active', async () => {
const mockStatus = {
sandbox_mode: false,
sandbox_enabled: true,
sandbox_schema: 'test_business_sandbox',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getSandboxStatus();
expect(result.sandbox_mode).toBe(false);
expect(result.sandbox_enabled).toBe(true);
expect(result.sandbox_schema).toBe('test_business_sandbox');
});
});
describe('toggleSandboxMode', () => {
it('enables sandbox mode', async () => {
const mockResponse = {
data: {
sandbox_mode: true,
message: 'Sandbox mode enabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await toggleSandboxMode(true);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: true,
});
expect(result.sandbox_mode).toBe(true);
expect(result.message).toBe('Sandbox mode enabled');
});
it('disables sandbox mode', async () => {
const mockResponse = {
data: {
sandbox_mode: false,
message: 'Sandbox mode disabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await toggleSandboxMode(false);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: false,
});
expect(result.sandbox_mode).toBe(false);
expect(result.message).toBe('Sandbox mode disabled');
});
it('handles toggle with true parameter', async () => {
const mockResponse = {
data: {
sandbox_mode: true,
message: 'Switched to test data',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await toggleSandboxMode(true);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: true,
});
});
it('handles toggle with false parameter', async () => {
const mockResponse = {
data: {
sandbox_mode: false,
message: 'Switched to live data',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await toggleSandboxMode(false);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: false,
});
});
});
describe('resetSandboxData', () => {
it('resets sandbox data successfully', async () => {
const mockResponse = {
data: {
message: 'Sandbox data reset successfully',
sandbox_schema: 'test_business_sandbox',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await resetSandboxData();
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/reset/');
expect(result.message).toBe('Sandbox data reset successfully');
expect(result.sandbox_schema).toBe('test_business_sandbox');
});
it('returns schema name after reset', async () => {
const mockResponse = {
data: {
message: 'Data reset complete',
sandbox_schema: 'my_company_sandbox',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await resetSandboxData();
expect(result.sandbox_schema).toBe('my_company_sandbox');
});
it('calls reset endpoint without parameters', async () => {
const mockResponse = {
data: {
message: 'Reset successful',
sandbox_schema: 'test_sandbox',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await resetSandboxData();
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/reset/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
});
});
describe('error handling', () => {
it('propagates errors from getSandboxStatus', async () => {
const error = new Error('Network error');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(getSandboxStatus()).rejects.toThrow('Network error');
});
it('propagates errors from toggleSandboxMode', async () => {
const error = new Error('Unauthorized');
vi.mocked(apiClient.post).mockRejectedValue(error);
await expect(toggleSandboxMode(true)).rejects.toThrow('Unauthorized');
});
it('propagates errors from resetSandboxData', async () => {
const error = new Error('Forbidden');
vi.mocked(apiClient.post).mockRejectedValue(error);
await expect(resetSandboxData()).rejects.toThrow('Forbidden');
});
});
});

View File

@@ -0,0 +1,793 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getTicketEmailAddresses,
getTicketEmailAddress,
createTicketEmailAddress,
updateTicketEmailAddress,
deleteTicketEmailAddress,
testImapConnection,
testSmtpConnection,
fetchEmailsNow,
setAsDefault,
TicketEmailAddressListItem,
TicketEmailAddress,
TicketEmailAddressCreate,
TestConnectionResponse,
FetchEmailsResponse,
} from '../ticketEmailAddresses';
import apiClient from '../client';
describe('ticketEmailAddresses API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getTicketEmailAddresses', () => {
it('should fetch all ticket email addresses', async () => {
const mockAddresses: TicketEmailAddressListItem[] = [
{
id: 1,
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
is_active: true,
is_default: true,
last_check_at: '2025-12-07T10:00:00Z',
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
},
{
id: 2,
display_name: 'Sales',
email_address: 'sales@example.com',
color: '#3357FF',
is_active: true,
is_default: false,
emails_processed_count: 15,
created_at: '2025-12-02T10:00:00Z',
updated_at: '2025-12-05T10:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockAddresses });
const result = await getTicketEmailAddresses();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
expect(result).toEqual(mockAddresses);
expect(result).toHaveLength(2);
});
it('should return empty array when no addresses exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTicketEmailAddresses();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
it('should throw error when API call fails', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
await expect(getTicketEmailAddresses()).rejects.toThrow('Network error');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
});
});
describe('getTicketEmailAddress', () => {
it('should fetch a specific ticket email address by ID', async () => {
const mockAddress: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
last_check_at: '2025-12-07T10:00:00Z',
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockAddress });
const result = await getTicketEmailAddress(1);
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/1/');
expect(result).toEqual(mockAddress);
expect(result.id).toBe(1);
expect(result.email_address).toBe('support@example.com');
});
it('should handle fetching with different IDs', async () => {
const mockAddress: TicketEmailAddress = {
id: 999,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Sales',
email_address: 'sales@example.com',
color: '#3357FF',
imap_host: 'imap.example.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'sales@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.example.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'sales@example.com',
is_active: true,
is_default: false,
emails_processed_count: 0,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-01T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockAddress });
const result = await getTicketEmailAddress(999);
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/999/');
expect(result.id).toBe(999);
});
it('should throw error when address not found', async () => {
const mockError = new Error('Not found');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
await expect(getTicketEmailAddress(999)).rejects.toThrow('Not found');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/999/');
});
});
describe('createTicketEmailAddress', () => {
it('should create a new ticket email address', async () => {
const createData: TicketEmailAddressCreate = {
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_password: 'secure_password',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
smtp_password: 'secure_password',
is_active: true,
is_default: false,
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
...createData,
imap_password: undefined, // Passwords are not returned in response
smtp_password: undefined,
last_check_at: undefined,
last_error: undefined,
emails_processed_count: 0,
created_at: '2025-12-07T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTicketEmailAddress(createData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', createData);
expect(result).toEqual(mockResponse);
expect(result.id).toBe(1);
expect(result.display_name).toBe('Support');
});
it('should handle creating with minimal required fields', async () => {
const createData: TicketEmailAddressCreate = {
display_name: 'Minimal',
email_address: 'minimal@example.com',
color: '#000000',
imap_host: 'imap.example.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'minimal@example.com',
imap_password: 'password',
imap_folder: 'INBOX',
smtp_host: 'smtp.example.com',
smtp_port: 587,
smtp_use_tls: false,
smtp_use_ssl: false,
smtp_username: 'minimal@example.com',
smtp_password: 'password',
is_active: false,
is_default: false,
};
const mockResponse: TicketEmailAddress = {
id: 2,
tenant: 100,
tenant_name: 'Test Business',
...createData,
imap_password: undefined,
smtp_password: undefined,
emails_processed_count: 0,
created_at: '2025-12-07T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTicketEmailAddress(createData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', createData);
expect(result.id).toBe(2);
});
it('should throw error when validation fails', async () => {
const invalidData: TicketEmailAddressCreate = {
display_name: '',
email_address: 'invalid-email',
color: '#FF5733',
imap_host: '',
imap_port: 993,
imap_use_ssl: true,
imap_username: '',
imap_password: '',
imap_folder: 'INBOX',
smtp_host: '',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: '',
smtp_password: '',
is_active: true,
is_default: false,
};
const mockError = new Error('Validation error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(createTicketEmailAddress(invalidData)).rejects.toThrow('Validation error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', invalidData);
});
});
describe('updateTicketEmailAddress', () => {
it('should update an existing ticket email address', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
display_name: 'Updated Support',
color: '#00FF00',
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Updated Support',
email_address: 'support@example.com',
color: '#00FF00',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result).toEqual(mockResponse);
expect(result.display_name).toBe('Updated Support');
expect(result.color).toBe('#00FF00');
});
it('should update IMAP configuration', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
imap_host: 'imap.newserver.com',
imap_port: 993,
imap_password: 'new_password',
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.newserver.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result.imap_host).toBe('imap.newserver.com');
});
it('should update SMTP configuration', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
smtp_host: 'smtp.newserver.com',
smtp_port: 465,
smtp_use_tls: false,
smtp_use_ssl: true,
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.newserver.com',
smtp_port: 465,
smtp_use_tls: false,
smtp_use_ssl: true,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result.smtp_host).toBe('smtp.newserver.com');
expect(result.smtp_port).toBe(465);
expect(result.smtp_use_ssl).toBe(true);
});
it('should toggle is_active status', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
is_active: false,
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
is_active: false,
is_default: true,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result.is_active).toBe(false);
});
it('should throw error when update fails', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
display_name: 'Invalid',
};
const mockError = new Error('Update failed');
vi.mocked(apiClient.patch).mockRejectedValue(mockError);
await expect(updateTicketEmailAddress(1, updateData)).rejects.toThrow('Update failed');
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
});
});
describe('deleteTicketEmailAddress', () => {
it('should delete a ticket email address', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
await deleteTicketEmailAddress(1);
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/1/');
});
it('should handle deletion of different IDs', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
await deleteTicketEmailAddress(999);
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/999/');
});
it('should throw error when deletion fails', async () => {
const mockError = new Error('Cannot delete default address');
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
await expect(deleteTicketEmailAddress(1)).rejects.toThrow('Cannot delete default address');
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/1/');
});
it('should throw error when address not found', async () => {
const mockError = new Error('Not found');
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
await expect(deleteTicketEmailAddress(999)).rejects.toThrow('Not found');
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/999/');
});
});
describe('testImapConnection', () => {
it('should test IMAP connection successfully', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'IMAP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testImapConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.message).toBe('IMAP connection successful');
});
it('should handle failed IMAP connection', async () => {
const mockResponse: TestConnectionResponse = {
success: false,
message: 'Authentication failed: Invalid credentials',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testImapConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid credentials');
});
it('should handle network errors during IMAP test', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(testImapConnection(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
});
it('should test IMAP connection for different addresses', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'IMAP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await testImapConnection(42);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/42/test_imap/');
});
});
describe('testSmtpConnection', () => {
it('should test SMTP connection successfully', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'SMTP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testSmtpConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.message).toBe('SMTP connection successful');
});
it('should handle failed SMTP connection', async () => {
const mockResponse: TestConnectionResponse = {
success: false,
message: 'Connection refused: Unable to connect to SMTP server',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testSmtpConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
expect(result.success).toBe(false);
expect(result.message).toContain('Connection refused');
});
it('should handle TLS/SSL errors during SMTP test', async () => {
const mockResponse: TestConnectionResponse = {
success: false,
message: 'SSL certificate verification failed',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testSmtpConnection(1);
expect(result.success).toBe(false);
expect(result.message).toContain('SSL certificate');
});
it('should handle network errors during SMTP test', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(testSmtpConnection(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
});
it('should test SMTP connection for different addresses', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'SMTP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await testSmtpConnection(99);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/99/test_smtp/');
});
});
describe('fetchEmailsNow', () => {
it('should fetch emails successfully', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'Successfully processed 5 emails',
processed: 5,
errors: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.processed).toBe(5);
expect(result.errors).toBe(0);
});
it('should handle fetching with no new emails', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'No new emails to process',
processed: 0,
errors: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
expect(result.success).toBe(true);
expect(result.processed).toBe(0);
});
it('should handle errors during email processing', async () => {
const mockResponse: FetchEmailsResponse = {
success: false,
message: 'Failed to connect to IMAP server',
processed: 0,
errors: 1,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(result.success).toBe(false);
expect(result.errors).toBe(1);
expect(result.message).toContain('Failed to connect');
});
it('should handle partial processing with errors', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'Processed 8 emails with 2 errors',
processed: 8,
errors: 2,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(result.success).toBe(true);
expect(result.processed).toBe(8);
expect(result.errors).toBe(2);
});
it('should handle network errors during fetch', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(fetchEmailsNow(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
});
it('should fetch emails for different addresses', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'Successfully processed 3 emails',
processed: 3,
errors: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await fetchEmailsNow(42);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/42/fetch_now/');
});
});
describe('setAsDefault', () => {
it('should set email address as default successfully', async () => {
const mockResponse = {
success: true,
message: 'Email address set as default',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await setAsDefault(2);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/2/set_as_default/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.message).toBe('Email address set as default');
});
it('should handle setting default for different addresses', async () => {
const mockResponse = {
success: true,
message: 'Email address set as default',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await setAsDefault(99);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/99/set_as_default/');
});
it('should handle failure to set as default', async () => {
const mockResponse = {
success: false,
message: 'Cannot set inactive email as default',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await setAsDefault(1);
expect(result.success).toBe(false);
expect(result.message).toContain('Cannot set inactive');
});
it('should handle network errors when setting default', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(setAsDefault(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/set_as_default/');
});
it('should handle not found errors', async () => {
const mockError = new Error('Email address not found');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(setAsDefault(999)).rejects.toThrow('Email address not found');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/999/set_as_default/');
});
});
});

View File

@@ -0,0 +1,703 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getTicketEmailSettings,
updateTicketEmailSettings,
testImapConnection,
testSmtpConnection,
testEmailConnection,
fetchEmailsNow,
getIncomingEmails,
reprocessIncomingEmail,
detectEmailProvider,
getOAuthStatus,
initiateGoogleOAuth,
initiateMicrosoftOAuth,
getOAuthCredentials,
deleteOAuthCredential,
type TicketEmailSettings,
type TicketEmailSettingsUpdate,
type TestConnectionResult,
type FetchNowResult,
type IncomingTicketEmail,
type EmailProviderDetectResult,
type OAuthStatusResult,
type OAuthInitiateResult,
type OAuthCredential,
} from '../ticketEmailSettings';
import apiClient from '../client';
describe('ticketEmailSettings API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getTicketEmailSettings', () => {
it('should call GET /tickets/email-settings/', async () => {
const mockSettings: TicketEmailSettings = {
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_password_masked: '***',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
smtp_password_masked: '***',
smtp_from_email: 'support@example.com',
smtp_from_name: 'Support Team',
support_email_address: 'support@example.com',
support_email_domain: 'example.com',
is_enabled: true,
delete_after_processing: false,
check_interval_seconds: 300,
max_attachment_size_mb: 10,
allowed_attachment_types: ['pdf', 'jpg', 'png'],
last_check_at: '2025-12-07T10:00:00Z',
last_error: '',
emails_processed_count: 42,
is_configured: true,
is_imap_configured: true,
is_smtp_configured: true,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockSettings });
const result = await getTicketEmailSettings();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-settings/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockSettings);
});
});
describe('updateTicketEmailSettings', () => {
it('should call PATCH /tickets/email-settings/ with update data', async () => {
const updateData: TicketEmailSettingsUpdate = {
imap_host: 'imap.outlook.com',
imap_port: 993,
is_enabled: true,
};
const mockResponse: TicketEmailSettings = {
imap_host: 'imap.outlook.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_password_masked: '***',
imap_folder: 'INBOX',
smtp_host: 'smtp.outlook.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
smtp_password_masked: '***',
smtp_from_email: 'support@example.com',
smtp_from_name: 'Support Team',
support_email_address: 'support@example.com',
support_email_domain: 'example.com',
is_enabled: true,
delete_after_processing: false,
check_interval_seconds: 300,
max_attachment_size_mb: 10,
allowed_attachment_types: ['pdf', 'jpg', 'png'],
last_check_at: null,
last_error: '',
emails_processed_count: 0,
is_configured: true,
is_imap_configured: true,
is_smtp_configured: true,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailSettings(updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-settings/', updateData);
expect(apiClient.patch).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResponse);
});
it('should handle password updates', async () => {
const updateData: TicketEmailSettingsUpdate = {
imap_password: 'newpassword123',
smtp_password: 'newsmtppass456',
};
const mockResponse: TicketEmailSettings = {
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_password_masked: '***',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
smtp_password_masked: '***',
smtp_from_email: 'support@example.com',
smtp_from_name: 'Support Team',
support_email_address: 'support@example.com',
support_email_domain: 'example.com',
is_enabled: true,
delete_after_processing: false,
check_interval_seconds: 300,
max_attachment_size_mb: 10,
allowed_attachment_types: ['pdf', 'jpg', 'png'],
last_check_at: null,
last_error: '',
emails_processed_count: 0,
is_configured: true,
is_imap_configured: true,
is_smtp_configured: true,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailSettings(updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-settings/', updateData);
expect(result).toEqual(mockResponse);
});
});
describe('testImapConnection', () => {
it('should call POST /tickets/email-settings/test-imap/', async () => {
const mockResult: TestConnectionResult = {
success: true,
message: 'IMAP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testImapConnection();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-imap/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should handle connection failures', async () => {
const mockResult: TestConnectionResult = {
success: false,
message: 'Failed to connect: Invalid credentials',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testImapConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Failed to connect');
});
});
describe('testSmtpConnection', () => {
it('should call POST /tickets/email-settings/test-smtp/', async () => {
const mockResult: TestConnectionResult = {
success: true,
message: 'SMTP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testSmtpConnection();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-smtp/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should handle SMTP connection failures', async () => {
const mockResult: TestConnectionResult = {
success: false,
message: 'SMTP error: Connection refused',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testSmtpConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Connection refused');
});
});
describe('testEmailConnection (legacy alias)', () => {
it('should be an alias for testImapConnection', async () => {
const mockResult: TestConnectionResult = {
success: true,
message: 'Connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testEmailConnection();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-imap/');
expect(result).toEqual(mockResult);
});
});
describe('fetchEmailsNow', () => {
it('should call POST /tickets/email-settings/fetch-now/', async () => {
const mockResult: FetchNowResult = {
success: true,
message: 'Successfully processed 5 emails',
processed: 5,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await fetchEmailsNow();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/fetch-now/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should handle no new emails', async () => {
const mockResult: FetchNowResult = {
success: true,
message: 'No new emails found',
processed: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await fetchEmailsNow();
expect(result.processed).toBe(0);
expect(result.success).toBe(true);
});
});
describe('getIncomingEmails', () => {
it('should call GET /tickets/incoming-emails/ without params', async () => {
const mockEmails: IncomingTicketEmail[] = [
{
id: 1,
message_id: '<msg1@example.com>',
from_address: 'customer@example.com',
from_name: 'John Doe',
to_address: 'support@example.com',
subject: 'Help needed',
body_text: 'I need assistance with...',
extracted_reply: 'I need assistance with...',
ticket: 123,
ticket_subject: 'Help needed',
matched_user: 456,
ticket_id_from_email: '#123',
processing_status: 'PROCESSED',
processing_status_display: 'Processed',
error_message: '',
email_date: '2025-12-07T09:00:00Z',
received_at: '2025-12-07T09:01:00Z',
processed_at: '2025-12-07T09:02:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', { params: undefined });
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockEmails);
});
it('should call GET /tickets/incoming-emails/ with status filter', async () => {
const mockEmails: IncomingTicketEmail[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails({ status: 'FAILED' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
params: { status: 'FAILED' },
});
expect(result).toEqual(mockEmails);
});
it('should call GET /tickets/incoming-emails/ with ticket filter', async () => {
const mockEmails: IncomingTicketEmail[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails({ ticket: 123 });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
params: { ticket: 123 },
});
expect(result).toEqual(mockEmails);
});
it('should call GET /tickets/incoming-emails/ with multiple filters', async () => {
const mockEmails: IncomingTicketEmail[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails({ status: 'PROCESSED', ticket: 123 });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
params: { status: 'PROCESSED', ticket: 123 },
});
expect(result).toEqual(mockEmails);
});
});
describe('reprocessIncomingEmail', () => {
it('should call POST /tickets/incoming-emails/:id/reprocess/', async () => {
const mockResponse = {
success: true,
message: 'Email reprocessed successfully',
comment_id: 789,
ticket_id: 123,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await reprocessIncomingEmail(456);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/incoming-emails/456/reprocess/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResponse);
});
it('should handle reprocessing failures', async () => {
const mockResponse = {
success: false,
message: 'Failed to reprocess: Invalid email format',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await reprocessIncomingEmail(999);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/incoming-emails/999/reprocess/');
expect(result.success).toBe(false);
expect(result.message).toContain('Failed to reprocess');
});
});
describe('detectEmailProvider', () => {
it('should call POST /tickets/email-settings/detect/ with email', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'user@gmail.com',
domain: 'gmail.com',
detected: true,
detected_via: 'domain_lookup',
provider: 'google',
display_name: 'Gmail',
imap_host: 'imap.gmail.com',
imap_port: 993,
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
oauth_supported: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('user@gmail.com');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/detect/', {
email: 'user@gmail.com',
});
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should detect Microsoft provider', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'user@outlook.com',
domain: 'outlook.com',
detected: true,
detected_via: 'domain_lookup',
provider: 'microsoft',
display_name: 'Outlook.com',
imap_host: 'outlook.office365.com',
imap_port: 993,
smtp_host: 'smtp.office365.com',
smtp_port: 587,
oauth_supported: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('user@outlook.com');
expect(result.provider).toBe('microsoft');
expect(result.oauth_supported).toBe(true);
});
it('should detect custom domain via MX records', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'admin@company.com',
domain: 'company.com',
detected: true,
detected_via: 'mx_record',
provider: 'google',
display_name: 'Google Workspace',
oauth_supported: true,
message: 'Detected Google Workspace via MX records',
notes: 'Use OAuth for best security',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('admin@company.com');
expect(result.detected_via).toBe('mx_record');
expect(result.provider).toBe('google');
});
it('should handle unknown provider', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'user@custom-server.com',
domain: 'custom-server.com',
detected: false,
provider: 'unknown',
display_name: 'Unknown Provider',
oauth_supported: false,
message: 'Could not auto-detect email provider',
suggested_imap_port: 993,
suggested_smtp_port: 587,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('user@custom-server.com');
expect(result.detected).toBe(false);
expect(result.provider).toBe('unknown');
expect(result.oauth_supported).toBe(false);
});
});
describe('getOAuthStatus', () => {
it('should call GET /oauth/status/', async () => {
const mockStatus: OAuthStatusResult = {
google: { configured: true },
microsoft: { configured: false },
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getOAuthStatus();
expect(apiClient.get).toHaveBeenCalledWith('/oauth/status/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockStatus);
});
it('should handle no OAuth configured', async () => {
const mockStatus: OAuthStatusResult = {
google: { configured: false },
microsoft: { configured: false },
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getOAuthStatus();
expect(result.google.configured).toBe(false);
expect(result.microsoft.configured).toBe(false);
});
});
describe('initiateGoogleOAuth', () => {
it('should call POST /oauth/google/initiate/ with default purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://accounts.google.com/o/oauth2/auth?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateGoogleOAuth();
expect(apiClient.post).toHaveBeenCalledWith('/oauth/google/initiate/', { purpose: 'email' });
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should call POST /oauth/google/initiate/ with custom purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://accounts.google.com/o/oauth2/auth?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateGoogleOAuth('calendar');
expect(apiClient.post).toHaveBeenCalledWith('/oauth/google/initiate/', { purpose: 'calendar' });
expect(result).toEqual(mockResult);
});
it('should handle OAuth initiation errors', async () => {
const mockResult: OAuthInitiateResult = {
success: false,
error: 'OAuth client credentials not configured',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateGoogleOAuth();
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('initiateMicrosoftOAuth', () => {
it('should call POST /oauth/microsoft/initiate/ with default purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateMicrosoftOAuth();
expect(apiClient.post).toHaveBeenCalledWith('/oauth/microsoft/initiate/', { purpose: 'email' });
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should call POST /oauth/microsoft/initiate/ with custom purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateMicrosoftOAuth('calendar');
expect(apiClient.post).toHaveBeenCalledWith('/oauth/microsoft/initiate/', {
purpose: 'calendar',
});
expect(result).toEqual(mockResult);
});
it('should handle Microsoft OAuth errors', async () => {
const mockResult: OAuthInitiateResult = {
success: false,
error: 'Microsoft OAuth not configured',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateMicrosoftOAuth();
expect(result.success).toBe(false);
expect(result.error).toBe('Microsoft OAuth not configured');
});
});
describe('getOAuthCredentials', () => {
it('should call GET /oauth/credentials/', async () => {
const mockCredentials: OAuthCredential[] = [
{
id: 1,
provider: 'google',
email: 'support@example.com',
purpose: 'email',
is_valid: true,
is_expired: false,
last_used_at: '2025-12-07T09:00:00Z',
last_error: '',
created_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
provider: 'microsoft',
email: 'admin@example.com',
purpose: 'email',
is_valid: false,
is_expired: true,
last_used_at: '2025-11-01T10:00:00Z',
last_error: 'Token expired',
created_at: '2025-01-15T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockCredentials });
const result = await getOAuthCredentials();
expect(apiClient.get).toHaveBeenCalledWith('/oauth/credentials/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockCredentials);
expect(result).toHaveLength(2);
});
it('should handle empty credentials list', async () => {
const mockCredentials: OAuthCredential[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockCredentials });
const result = await getOAuthCredentials();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
describe('deleteOAuthCredential', () => {
it('should call DELETE /oauth/credentials/:id/', async () => {
const mockResponse = {
success: true,
message: 'OAuth credential deleted successfully',
};
vi.mocked(apiClient.delete).mockResolvedValue({ data: mockResponse });
const result = await deleteOAuthCredential(123);
expect(apiClient.delete).toHaveBeenCalledWith('/oauth/credentials/123/');
expect(apiClient.delete).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResponse);
});
it('should handle deletion of non-existent credential', async () => {
const mockResponse = {
success: false,
message: 'Credential not found',
};
vi.mocked(apiClient.delete).mockResolvedValue({ data: mockResponse });
const result = await deleteOAuthCredential(999);
expect(apiClient.delete).toHaveBeenCalledWith('/oauth/credentials/999/');
expect(result.success).toBe(false);
});
});
});

View File

@@ -0,0 +1,577 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getTickets,
getTicket,
createTicket,
updateTicket,
deleteTicket,
getTicketComments,
createTicketComment,
getTicketTemplates,
getTicketTemplate,
getCannedResponses,
refreshTicketEmails,
} from '../tickets';
import apiClient from '../client';
import type { Ticket, TicketComment, TicketTemplate, CannedResponse } from '../../types';
describe('tickets API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getTickets', () => {
it('fetches all tickets without filters', async () => {
const mockTickets: Ticket[] = [
{
id: '1',
creator: 'user1',
creatorEmail: 'user1@example.com',
creatorFullName: 'User One',
ticketType: 'CUSTOMER',
status: 'OPEN',
priority: 'HIGH',
subject: 'Test Ticket',
description: 'Test description',
category: 'TECHNICAL',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
{
id: '2',
creator: 'user2',
creatorEmail: 'user2@example.com',
creatorFullName: 'User Two',
ticketType: 'PLATFORM',
status: 'IN_PROGRESS',
priority: 'MEDIUM',
subject: 'Another Ticket',
description: 'Another description',
category: 'BILLING',
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTickets });
const result = await getTickets();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/');
expect(result).toEqual(mockTickets);
});
it('applies status filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ status: 'OPEN' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?status=OPEN');
});
it('applies priority filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ priority: 'HIGH' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?priority=HIGH');
});
it('applies category filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ category: 'TECHNICAL' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?category=TECHNICAL');
});
it('applies ticketType filter with snake_case conversion', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ ticketType: 'CUSTOMER' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?ticket_type=CUSTOMER');
});
it('applies assignee filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ assignee: 'user123' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?assignee=user123');
});
it('applies multiple filters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({
status: 'OPEN',
priority: 'HIGH',
category: 'BILLING',
ticketType: 'CUSTOMER',
assignee: 'user456',
});
expect(apiClient.get).toHaveBeenCalledWith(
'/tickets/?status=OPEN&priority=HIGH&category=BILLING&ticket_type=CUSTOMER&assignee=user456'
);
});
it('applies partial filters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ status: 'CLOSED', priority: 'LOW' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?status=CLOSED&priority=LOW');
});
it('handles empty filters object', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({});
expect(apiClient.get).toHaveBeenCalledWith('/tickets/');
});
});
describe('getTicket', () => {
it('fetches a single ticket by ID', async () => {
const mockTicket: Ticket = {
id: '123',
creator: 'user1',
creatorEmail: 'user1@example.com',
creatorFullName: 'User One',
assignee: 'user2',
assigneeEmail: 'user2@example.com',
assigneeFullName: 'User Two',
ticketType: 'CUSTOMER',
status: 'IN_PROGRESS',
priority: 'HIGH',
subject: 'Important Ticket',
description: 'This needs attention',
category: 'TECHNICAL',
relatedAppointmentId: 'appt-456',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTicket });
const result = await getTicket('123');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/123/');
expect(result).toEqual(mockTicket);
});
});
describe('createTicket', () => {
it('creates a new ticket', async () => {
const newTicketData: Partial<Ticket> = {
subject: 'New Ticket',
description: 'New ticket description',
ticketType: 'CUSTOMER',
priority: 'MEDIUM',
category: 'GENERAL_INQUIRY',
};
const createdTicket: Ticket = {
id: '789',
creator: 'current-user',
creatorEmail: 'current@example.com',
creatorFullName: 'Current User',
status: 'OPEN',
createdAt: '2024-01-03T00:00:00Z',
updatedAt: '2024-01-03T00:00:00Z',
...newTicketData,
} as Ticket;
vi.mocked(apiClient.post).mockResolvedValue({ data: createdTicket });
const result = await createTicket(newTicketData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/', newTicketData);
expect(result).toEqual(createdTicket);
});
it('creates a ticket with all optional fields', async () => {
const newTicketData: Partial<Ticket> = {
subject: 'Complex Ticket',
description: 'Complex description',
ticketType: 'STAFF_REQUEST',
priority: 'URGENT',
category: 'TIME_OFF',
assignee: 'manager-123',
relatedAppointmentId: 'appt-999',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
await createTicket(newTicketData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/', newTicketData);
});
});
describe('updateTicket', () => {
it('updates a ticket', async () => {
const updateData: Partial<Ticket> = {
status: 'RESOLVED',
priority: 'LOW',
};
const updatedTicket: Ticket = {
id: '123',
creator: 'user1',
creatorEmail: 'user1@example.com',
creatorFullName: 'User One',
ticketType: 'CUSTOMER',
subject: 'Existing Ticket',
description: 'Existing description',
category: 'TECHNICAL',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-05T00:00:00Z',
...updateData,
} as Ticket;
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedTicket });
const result = await updateTicket('123', updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/123/', updateData);
expect(result).toEqual(updatedTicket);
});
it('updates ticket assignee', async () => {
const updateData = { assignee: 'new-assignee-456' };
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
await updateTicket('123', updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/123/', updateData);
});
it('updates multiple ticket fields', async () => {
const updateData: Partial<Ticket> = {
status: 'CLOSED',
priority: 'LOW',
assignee: 'user789',
category: 'RESOLVED',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
await updateTicket('456', updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/456/', updateData);
});
});
describe('deleteTicket', () => {
it('deletes a ticket', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteTicket('123');
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/123/');
});
it('returns void', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const result = await deleteTicket('456');
expect(result).toBeUndefined();
});
});
describe('getTicketComments', () => {
it('fetches all comments for a ticket', async () => {
const mockComments: TicketComment[] = [
{
id: 'c1',
ticket: 't1',
author: 'user1',
authorEmail: 'user1@example.com',
authorFullName: 'User One',
commentText: 'First comment',
createdAt: '2024-01-01T00:00:00Z',
isInternal: false,
},
{
id: 'c2',
ticket: 't1',
author: 'user2',
authorEmail: 'user2@example.com',
authorFullName: 'User Two',
commentText: 'Second comment',
createdAt: '2024-01-02T00:00:00Z',
isInternal: true,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockComments });
const result = await getTicketComments('t1');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/t1/comments/');
expect(result).toEqual(mockComments);
});
it('handles ticket with no comments', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTicketComments('t999');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/t999/comments/');
expect(result).toEqual([]);
});
});
describe('createTicketComment', () => {
it('creates a new comment on a ticket', async () => {
const commentData: Partial<TicketComment> = {
commentText: 'This is a new comment',
isInternal: false,
};
const createdComment: TicketComment = {
id: 'c123',
ticket: 't1',
author: 'current-user',
authorEmail: 'current@example.com',
authorFullName: 'Current User',
createdAt: '2024-01-03T00:00:00Z',
...commentData,
} as TicketComment;
vi.mocked(apiClient.post).mockResolvedValue({ data: createdComment });
const result = await createTicketComment('t1', commentData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/t1/comments/', commentData);
expect(result).toEqual(createdComment);
});
it('creates an internal comment', async () => {
const commentData: Partial<TicketComment> = {
commentText: 'Internal note',
isInternal: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
await createTicketComment('t2', commentData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/t2/comments/', commentData);
});
});
describe('getTicketTemplates', () => {
it('fetches all ticket templates', async () => {
const mockTemplates: TicketTemplate[] = [
{
id: 'tmpl1',
name: 'Bug Report Template',
description: 'Template for bug reports',
ticketType: 'CUSTOMER',
category: 'TECHNICAL',
defaultPriority: 'HIGH',
subjectTemplate: 'Bug: {{title}}',
descriptionTemplate: 'Steps to reproduce:\n{{steps}}',
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 'tmpl2',
tenant: 'tenant123',
name: 'Time Off Request',
description: 'Staff time off template',
ticketType: 'STAFF_REQUEST',
category: 'TIME_OFF',
defaultPriority: 'MEDIUM',
subjectTemplate: 'Time Off: {{dates}}',
descriptionTemplate: 'Reason:\n{{reason}}',
isActive: true,
createdAt: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTemplates });
const result = await getTicketTemplates();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/templates/');
expect(result).toEqual(mockTemplates);
});
it('handles empty template list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTicketTemplates();
expect(result).toEqual([]);
});
});
describe('getTicketTemplate', () => {
it('fetches a single ticket template by ID', async () => {
const mockTemplate: TicketTemplate = {
id: 'tmpl123',
name: 'Feature Request Template',
description: 'Template for feature requests',
ticketType: 'CUSTOMER',
category: 'FEATURE_REQUEST',
defaultPriority: 'LOW',
subjectTemplate: 'Feature Request: {{feature}}',
descriptionTemplate: 'Description:\n{{description}}\n\nBenefit:\n{{benefit}}',
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTemplate });
const result = await getTicketTemplate('tmpl123');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/templates/tmpl123/');
expect(result).toEqual(mockTemplate);
});
});
describe('getCannedResponses', () => {
it('fetches all canned responses', async () => {
const mockResponses: CannedResponse[] = [
{
id: 'cr1',
title: 'Thank You Response',
content: 'Thank you for contacting us. We will get back to you soon.',
category: 'GENERAL_INQUIRY',
isActive: true,
useCount: 42,
createdBy: 'admin',
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 'cr2',
tenant: 'tenant456',
title: 'Billing Issue',
content: 'We have received your billing inquiry and are investigating.',
category: 'BILLING',
isActive: true,
useCount: 18,
createdBy: 'manager',
createdAt: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponses });
const result = await getCannedResponses();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/canned-responses/');
expect(result).toEqual(mockResponses);
});
it('handles empty canned responses list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getCannedResponses();
expect(result).toEqual([]);
});
});
describe('refreshTicketEmails', () => {
it('successfully refreshes ticket emails', async () => {
const mockResult = {
success: true,
processed: 5,
results: [
{
address: 'support@example.com',
display_name: 'Support',
processed: 3,
status: 'success',
last_check_at: '2024-01-05T12:00:00Z',
},
{
address: 'help@example.com',
display_name: 'Help Desk',
processed: 2,
status: 'success',
last_check_at: '2024-01-05T12:00:00Z',
},
],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/refresh-emails/');
expect(result.success).toBe(true);
expect(result.processed).toBe(5);
expect(result.results).toHaveLength(2);
});
it('handles refresh with errors', async () => {
const mockResult = {
success: false,
processed: 0,
results: [
{
address: 'invalid@example.com',
display_name: 'Invalid Email',
status: 'error',
error: 'Connection timeout',
},
],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(result.success).toBe(false);
expect(result.processed).toBe(0);
expect(result.results[0].status).toBe('error');
expect(result.results[0].error).toBe('Connection timeout');
});
it('handles partial success', async () => {
const mockResult = {
success: true,
processed: 2,
results: [
{
address: 'working@example.com',
processed: 2,
status: 'success',
},
{
address: null,
status: 'skipped',
message: 'No email address configured',
},
],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(result.success).toBe(true);
expect(result.processed).toBe(2);
expect(result.results).toHaveLength(2);
expect(result.results[0].status).toBe('success');
expect(result.results[1].status).toBe('skipped');
});
it('handles no configured email addresses', async () => {
const mockResult = {
success: false,
processed: 0,
results: [],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(result.success).toBe(false);
expect(result.processed).toBe(0);
expect(result.results).toHaveLength(0);
});
});
});

View File

@@ -5,7 +5,7 @@
import apiClient from './client';
export interface LoginCredentials {
username: string;
email: string;
password: string;
}
@@ -47,6 +47,7 @@ export interface LoginResponse {
business?: number;
business_name?: string;
business_subdomain?: string;
can_send_messages?: boolean;
};
masquerade_stack?: MasqueradeStackEntry[];
// MFA challenge response
@@ -72,6 +73,9 @@ export interface User {
permissions?: Record<string, boolean>;
can_invite_staff?: boolean;
can_access_tickets?: boolean;
can_edit_schedule?: boolean;
can_send_messages?: boolean;
linked_resource_id?: number;
quota_overages?: QuotaOverage[];
}
@@ -132,3 +136,11 @@ export const stopMasquerade = async (
);
return response.data;
};
/**
* Request password reset email
*/
export const forgotPassword = async (email: string): Promise<{ message: string }> => {
const response = await apiClient.post<{ message: string }>('/auth/password-reset/', { email });
return response.data;
};

188
frontend/src/api/billing.ts Normal file
View File

@@ -0,0 +1,188 @@
/**
* Billing API
*
* API client functions for the billing/subscription system.
*/
import apiClient from './client';
// ============================================================================
// Types
// ============================================================================
/**
* Entitlements - a map of feature codes to their values.
* Boolean features indicate permission (true/false).
* Integer features indicate limits.
*/
export interface Entitlements {
[key: string]: boolean | number | null;
}
/**
* Plan information (nested in PlanVersion)
*/
export interface Plan {
code: string;
name: string;
description?: string;
}
/**
* Plan version with pricing and features
*/
export interface PlanVersion {
id: number;
name: string;
is_legacy: boolean;
is_public?: boolean;
plan: Plan;
price_monthly_cents: number;
price_yearly_cents: number;
features?: PlanFeature[];
}
/**
* Feature attached to a plan version
*/
export interface PlanFeature {
feature_code: string;
feature_name: string;
feature_type: 'boolean' | 'integer';
bool_value?: boolean;
int_value?: number;
}
/**
* Current subscription
*/
export interface Subscription {
id: number;
status: 'active' | 'canceled' | 'past_due' | 'trialing';
plan_version: PlanVersion;
current_period_start: string;
current_period_end: string;
canceled_at?: string;
stripe_subscription_id?: string;
}
/**
* Add-on product
*/
export interface AddOnProduct {
id: number;
code: string;
name: string;
description?: string;
price_monthly_cents: number;
price_one_time_cents: number;
stripe_product_id?: string;
stripe_price_id?: string;
is_stackable: boolean;
is_active: boolean;
}
/**
* Invoice line item
*/
export interface InvoiceLine {
id: number;
line_type: 'plan' | 'addon' | 'adjustment' | 'credit';
description: string;
quantity: number;
unit_amount: number;
total_amount: number;
}
/**
* Invoice
*/
export interface Invoice {
id: number;
status: 'draft' | 'pending' | 'paid' | 'void' | 'uncollectible';
period_start: string;
period_end: string;
subtotal_amount: number;
total_amount: number;
plan_name_at_billing: string;
plan_code_at_billing?: string;
created_at: string;
paid_at?: string;
lines?: InvoiceLine[];
}
// ============================================================================
// API Functions
// ============================================================================
/**
* Get effective entitlements for the current business.
* Returns a map of feature codes to their values.
*/
export const getEntitlements = async (): Promise<Entitlements> => {
try {
const response = await apiClient.get<Entitlements>('/me/entitlements/');
return response.data;
} catch (error) {
console.error('Failed to fetch entitlements:', error);
return {};
}
};
/**
* Get the current subscription for the business.
* Returns null if no subscription exists.
*/
export const getCurrentSubscription = async (): Promise<Subscription | null> => {
try {
const response = await apiClient.get<Subscription>('/me/subscription/');
return response.data;
} catch (error: any) {
if (error?.response?.status === 404) {
return null;
}
console.error('Failed to fetch subscription:', error);
throw error;
}
};
/**
* Get available plans (public, non-legacy plans).
*/
export const getPlans = async (): Promise<PlanVersion[]> => {
const response = await apiClient.get<PlanVersion[]>('/billing/plans/');
return response.data;
};
/**
* Get available add-on products.
*/
export const getAddOns = async (): Promise<AddOnProduct[]> => {
const response = await apiClient.get<AddOnProduct[]>('/billing/addons/');
return response.data;
};
/**
* Get invoices for the current business.
*/
export const getInvoices = async (): Promise<Invoice[]> => {
const response = await apiClient.get<Invoice[]>('/billing/invoices/');
return response.data;
};
/**
* Get a single invoice by ID.
* Returns null if not found.
*/
export const getInvoice = async (invoiceId: number): Promise<Invoice | null> => {
try {
const response = await apiClient.get<Invoice>(`/billing/invoices/${invoiceId}/`);
return response.data;
} catch (error: any) {
if (error?.response?.status === 404) {
return null;
}
console.error('Failed to fetch invoice:', error);
throw error;
}
};

260
frontend/src/api/media.ts Normal file
View File

@@ -0,0 +1,260 @@
/**
* Media Gallery API
*
* API client functions for managing media files and albums.
*/
import apiClient from './client';
// ============================================================================
// Types
// ============================================================================
/**
* Album for organizing media files
*/
export interface Album {
id: number;
name: string;
description: string;
cover_image: number | null;
file_count: number;
cover_url: string | null;
created_at: string;
updated_at: string;
}
/**
* Media file (uploaded image)
*/
export interface MediaFile {
id: number;
url: string;
filename: string;
alt_text: string;
file_size: number;
width: number | null;
height: number | null;
mime_type: string;
album: number | null;
album_name: string | null;
created_at: string;
}
/**
* Storage usage statistics
*/
export interface StorageUsage {
bytes_used: number;
bytes_total: number;
file_count: number;
percent_used: number;
used_display: string;
total_display: string;
}
/**
* Album creation/update payload
*/
export interface AlbumPayload {
name: string;
description?: string;
cover_image?: number | null;
}
/**
* Media file update payload (can't change the actual file)
*/
export interface MediaFileUpdatePayload {
alt_text?: string;
album?: number | null;
}
// ============================================================================
// Album API
// ============================================================================
/**
* List all albums
*/
export async function listAlbums(): Promise<Album[]> {
const response = await apiClient.get('/albums/');
return response.data;
}
/**
* Get a single album
*/
export async function getAlbum(id: number): Promise<Album> {
const response = await apiClient.get(`/albums/${id}/`);
return response.data;
}
/**
* Create a new album
*/
export async function createAlbum(data: AlbumPayload): Promise<Album> {
const response = await apiClient.post('/albums/', data);
return response.data;
}
/**
* Update an album
*/
export async function updateAlbum(id: number, data: Partial<AlbumPayload>): Promise<Album> {
const response = await apiClient.patch(`/albums/${id}/`, data);
return response.data;
}
/**
* Delete an album (files are moved to uncategorized)
*/
export async function deleteAlbum(id: number): Promise<void> {
await apiClient.delete(`/albums/${id}/`);
}
// ============================================================================
// Media File API
// ============================================================================
/**
* List media files
* @param albumId - Filter by album ID, 'null' for uncategorized, undefined for all
*/
export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFile[]> {
const params = albumId !== undefined ? { album: albumId } : {};
const response = await apiClient.get('/media/', { params });
return response.data;
}
/**
* Get a single media file
*/
export async function getMediaFile(id: number): Promise<MediaFile> {
const response = await apiClient.get(`/media/${id}/`);
return response.data;
}
/**
* Upload a new media file
*/
export async function uploadMediaFile(
file: File,
albumId?: number | null,
altText?: string
): Promise<MediaFile> {
const formData = new FormData();
formData.append('file', file);
if (albumId !== undefined && albumId !== null) {
formData.append('album', albumId.toString());
}
if (altText) {
formData.append('alt_text', altText);
}
const response = await apiClient.post('/media/', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
}
/**
* Update a media file (alt text, album assignment)
*/
export async function updateMediaFile(
id: number,
data: MediaFileUpdatePayload
): Promise<MediaFile> {
const response = await apiClient.patch(`/media/${id}/`, data);
return response.data;
}
/**
* Delete a media file
*/
export async function deleteMediaFile(id: number): Promise<void> {
await apiClient.delete(`/media/${id}/`);
}
/**
* Move multiple files to an album
*/
export async function bulkMoveFiles(
fileIds: number[],
albumId: number | null
): Promise<{ updated: number }> {
const response = await apiClient.post('/media/bulk_move/', {
file_ids: fileIds,
album_id: albumId,
});
return response.data;
}
/**
* Delete multiple files
*/
export async function bulkDeleteFiles(fileIds: number[]): Promise<{ deleted: number }> {
const response = await apiClient.post('/media/bulk_delete/', {
file_ids: fileIds,
});
return response.data;
}
// ============================================================================
// Storage Usage API
// ============================================================================
/**
* Get storage usage statistics
*/
export async function getStorageUsage(): Promise<StorageUsage> {
const response = await apiClient.get('/storage-usage/');
return response.data;
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Format file size in human-readable format
*/
export function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
} else if (bytes >= 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} else if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${bytes} B`;
}
/**
* Check if a file type is allowed
*/
export function isAllowedFileType(file: File): boolean {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
return allowedTypes.includes(file.type);
}
/**
* Get allowed file types for input accept attribute
*/
export function getAllowedFileTypes(): string {
return 'image/jpeg,image/png,image/gif,image/webp';
}
/**
* Maximum file size in bytes (10 MB)
*/
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
/**
* Check if file size is within limits
*/
export function isFileSizeAllowed(file: File): boolean {
return file.size <= MAX_FILE_SIZE;
}

View File

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

View File

@@ -25,6 +25,7 @@ export interface PlatformBusiness {
owner: PlatformBusinessOwner | null;
max_users: number;
max_resources: number;
max_pages: number;
contact_email?: string;
phone?: string;
// Platform permissions
@@ -33,6 +34,25 @@ export interface PlatformBusiness {
can_use_custom_domain: boolean;
can_white_label: boolean;
can_api_access: boolean;
// Feature permissions (optional - returned by API but may not always be present in tests)
can_add_video_conferencing?: boolean;
can_connect_to_api?: boolean;
can_book_repeated_events?: boolean;
can_require_2fa?: boolean;
can_download_logs?: boolean;
can_delete_data?: boolean;
can_use_sms_reminders?: boolean;
can_use_masked_phone_numbers?: boolean;
can_use_pos?: boolean;
can_use_mobile_app?: boolean;
can_export_data?: boolean;
can_use_plugins?: boolean;
can_use_tasks?: boolean;
can_create_plugins?: boolean;
can_use_webhooks?: boolean;
can_use_calendar_sync?: boolean;
can_use_contracts?: boolean;
can_customize_booking_page?: boolean;
}
export interface PlatformBusinessUpdate {
@@ -41,11 +61,39 @@ export interface PlatformBusinessUpdate {
subscription_tier?: string;
max_users?: number;
max_resources?: number;
max_pages?: number;
// Platform permissions
can_manage_oauth_credentials?: boolean;
can_accept_payments?: boolean;
can_use_custom_domain?: boolean;
can_white_label?: boolean;
can_api_access?: boolean;
// Feature permissions
can_add_video_conferencing?: boolean;
can_connect_to_api?: boolean;
can_book_repeated_events?: boolean;
can_require_2fa?: boolean;
can_download_logs?: boolean;
can_delete_data?: boolean;
can_use_sms_reminders?: boolean;
can_use_masked_phone_numbers?: boolean;
can_use_pos?: boolean;
can_use_mobile_app?: boolean;
can_export_data?: boolean;
can_use_plugins?: boolean;
can_use_tasks?: boolean;
can_create_plugins?: boolean;
can_use_webhooks?: boolean;
can_use_calendar_sync?: boolean;
can_use_contracts?: boolean;
can_customize_booking_page?: boolean;
can_process_refunds?: boolean;
can_create_packages?: boolean;
can_use_email_templates?: boolean;
advanced_reporting?: boolean;
priority_support?: boolean;
dedicated_support?: boolean;
sso_enabled?: boolean;
}
export interface PlatformBusinessCreate {
@@ -55,6 +103,7 @@ export interface PlatformBusinessCreate {
is_active?: boolean;
max_users?: number;
max_resources?: number;
max_pages?: number;
contact_email?: string;
phone?: string;
can_manage_oauth_credentials?: boolean;
@@ -103,6 +152,27 @@ export const updateBusiness = async (
return response.data;
};
/**
* Change a business's subscription plan (platform admin only)
*/
export interface ChangePlanResponse {
detail: string;
plan_code: string;
plan_name: string;
version: number;
}
export const changeBusinessPlan = async (
businessId: number,
planCode: string
): Promise<ChangePlanResponse> => {
const response = await apiClient.post<ChangePlanResponse>(
`/platform/businesses/${businessId}/change_plan/`,
{ plan_code: planCode }
);
return response.data;
};
/**
* Create a new business (platform admin only)
*/
@@ -116,6 +186,14 @@ export const createBusiness = async (
return response.data;
};
/**
* Delete a business/tenant (platform admin only)
* This permanently deletes the tenant and all associated data
*/
export const deleteBusiness = async (businessId: number): Promise<void> => {
await apiClient.delete(`/platform/businesses/${businessId}/`);
};
/**
* Get all users (platform admin only)
*/
@@ -272,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,446 @@
/**
* Add Payment Method Modal Component
*
* Uses Stripe Elements with SetupIntent to securely save card details
* without charging the customer.
*
* For Stripe Connect, we must initialize Stripe with the connected account ID
* so the SetupIntent (created on the connected account) can be confirmed.
*/
import React, { useState, useEffect } from 'react';
import { loadStripe, Stripe } from '@stripe/stripe-js';
import {
Elements,
CardElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
import { CreditCard, Loader2, X, CheckCircle, AlertCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useCreateSetupIntent, useSetDefaultPaymentMethod, useCustomerPaymentMethods } from '../hooks/useCustomerBilling';
import { useQueryClient } from '@tanstack/react-query';
// Cache for Stripe instances per connected account
// Note: Module-level cache persists across component re-renders but not page reloads
const stripeInstanceCache: Record<string, Promise<Stripe | null>> = {};
// Clear cache entry (useful for debugging)
export const clearStripeCache = (key?: string) => {
if (key) {
delete stripeInstanceCache[key];
} else {
Object.keys(stripeInstanceCache).forEach(k => delete stripeInstanceCache[k]);
}
};
// Get or create Stripe instance for a connected account (or platform account if empty)
// For direct_api mode, customPublishableKey will be the tenant's key
// For connect mode, we use the platform's key with stripeAccount
const getStripeInstance = (
stripeAccount: string,
customPublishableKey?: string
): Promise<Stripe | null> => {
// Use custom key for direct_api mode, platform key for connect mode
const publishableKey = customPublishableKey || import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '';
// Use 'platform' as cache key for direct_api mode (empty stripeAccount)
// For direct_api with custom key, include key in cache to avoid conflicts
const cacheKey = customPublishableKey
? `direct_${customPublishableKey.substring(0, 20)}`
: (stripeAccount || 'platform');
console.log('[AddPaymentMethodModal] getStripeInstance called with:', {
stripeAccount: stripeAccount || '(empty - direct_api mode)',
cacheKey,
publishableKey: publishableKey.substring(0, 20) + '...',
isDirectApi: !!customPublishableKey,
});
if (!stripeInstanceCache[cacheKey]) {
console.log('[AddPaymentMethodModal] Creating new Stripe instance for:', cacheKey);
// Only pass stripeAccount option if it's not empty (connect mode)
// For direct_api mode, we use the tenant's own API keys (no connected account needed)
stripeInstanceCache[cacheKey] = stripeAccount
? loadStripe(publishableKey, { stripeAccount })
: loadStripe(publishableKey);
} else {
console.log('[AddPaymentMethodModal] Using cached Stripe instance for:', cacheKey);
}
return stripeInstanceCache[cacheKey];
};
interface CardFormProps {
clientSecret: string;
onSuccess: () => void;
onCancel: () => void;
}
const CardFormInner: React.FC<CardFormProps> = ({
clientSecret,
onSuccess,
onCancel,
}) => {
const { t } = useTranslation();
const stripe = useStripe();
const elements = useElements();
const queryClient = useQueryClient();
const [isProcessing, setIsProcessing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isComplete, setIsComplete] = useState(false);
const [cardComplete, setCardComplete] = useState(false);
// Get current payment methods to check if this is the first one
const { data: paymentMethodsData } = useCustomerPaymentMethods();
const setDefaultPaymentMethod = useSetDefaultPaymentMethod();
// Detect dark mode for Stripe CardElement styling
const [isDarkMode, setIsDarkMode] = useState(() =>
document.documentElement.classList.contains('dark')
);
useEffect(() => {
// Watch for dark mode changes
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains('dark'));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) {
return;
}
const cardElement = elements.getElement(CardElement);
if (!cardElement) {
return;
}
setIsProcessing(true);
setErrorMessage(null);
try {
// Confirm the SetupIntent with Stripe
const { error, setupIntent } = await stripe.confirmCardSetup(clientSecret, {
payment_method: {
card: cardElement,
},
});
if (error) {
setErrorMessage(error.message || t('billing.addCardFailed', 'Failed to add card. Please try again.'));
setIsProcessing(false);
return;
}
if (setupIntent && setupIntent.status === 'succeeded') {
// Get the payment method ID from the setup intent
const paymentMethodId = typeof setupIntent.payment_method === 'string'
? setupIntent.payment_method
: setupIntent.payment_method?.id;
// Check if there's already a default payment method
const existingMethods = paymentMethodsData?.payment_methods;
const hasDefaultMethod = existingMethods?.some(pm => pm.is_default) ?? false;
console.log('[AddPaymentMethodModal] SetupIntent succeeded:', {
paymentMethodId,
existingMethodsCount: existingMethods?.length ?? 0,
hasDefaultMethod,
});
// Set as default if no default payment method exists yet
if (!hasDefaultMethod && paymentMethodId) {
console.log('[AddPaymentMethodModal] No default payment method exists, setting new one as default:', paymentMethodId);
// Set as default (fire and forget - don't block the success flow)
setDefaultPaymentMethod.mutate(paymentMethodId, {
onSuccess: () => {
console.log('[AddPaymentMethodModal] Successfully set payment method as default');
},
onError: (err) => {
console.error('[AddPaymentMethodModal] Failed to set default payment method:', err);
},
});
} else {
console.log('[AddPaymentMethodModal] Default already exists or no paymentMethodId - existingMethods:', existingMethods?.length, 'hasDefaultMethod:', hasDefaultMethod, 'paymentMethodId:', paymentMethodId);
}
// Invalidate payment methods to refresh the list
queryClient.invalidateQueries({ queryKey: ['customerPaymentMethods'] });
setIsComplete(true);
setTimeout(() => {
onSuccess();
}, 1500);
}
} catch (err: any) {
setErrorMessage(err.message || t('billing.unexpectedError', 'An unexpected error occurred.'));
setIsProcessing(false);
}
};
if (isComplete) {
return (
<div className="text-center py-8">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{t('billing.cardAdded', 'Card Added Successfully!')}
</h3>
<p className="text-gray-600 dark:text-gray-400">
{t('billing.cardAddedDescription', 'Your payment method has been saved.')}
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<CardElement
options={{
style: {
base: {
fontSize: '16px',
color: isDarkMode ? '#f1f5f9' : '#1e293b',
iconColor: isDarkMode ? '#94a3b8' : '#64748b',
'::placeholder': {
color: isDarkMode ? '#64748b' : '#94a3b8',
},
},
invalid: {
color: isDarkMode ? '#f87171' : '#dc2626',
iconColor: isDarkMode ? '#f87171' : '#dc2626',
},
},
}}
onChange={(e) => setCardComplete(e.complete)}
/>
</div>
{errorMessage && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{errorMessage}</p>
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
disabled={isProcessing}
className="flex-1 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="submit"
disabled={!stripe || isProcessing || !cardComplete}
className="flex-1 py-2.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.saving', 'Saving...')}
</>
) : (
<>
<CreditCard className="w-4 h-4" />
{t('billing.saveCard', 'Save Card')}
</>
)}
</button>
</div>
<p className="text-xs text-center text-gray-500 dark:text-gray-400 mt-2">
{t('billing.stripeSecure', 'Your payment information is securely processed by Stripe')}
</p>
</form>
);
};
interface AddPaymentMethodModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export const AddPaymentMethodModal: React.FC<AddPaymentMethodModalProps> = ({
isOpen,
onClose,
onSuccess,
}) => {
const { t } = useTranslation();
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [stripeAccount, setStripeAccount] = useState<string | null>(null);
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | null>(null);
const [error, setError] = useState<string | null>(null);
const createSetupIntent = useCreateSetupIntent();
// Detect dark mode for Stripe Elements appearance
const [isDarkMode, setIsDarkMode] = useState(() =>
document.documentElement.classList.contains('dark')
);
useEffect(() => {
// Watch for dark mode changes
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains('dark'));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
if (isOpen && !clientSecret && !createSetupIntent.isPending) {
// Create SetupIntent when modal opens
createSetupIntent.mutate(undefined, {
onSuccess: (data) => {
console.log('[AddPaymentMethodModal] SetupIntent response:', {
client_secret: data.client_secret?.substring(0, 30) + '...',
setup_intent_id: data.setup_intent_id,
customer_id: data.customer_id,
stripe_account: data.stripe_account,
publishable_key: data.publishable_key ? data.publishable_key.substring(0, 20) + '...' : null,
});
// stripe_account can be empty string for direct_api mode, or acct_xxx for connect mode
// Only undefined/null indicates an error
if (data.stripe_account === undefined || data.stripe_account === null) {
console.error('[AddPaymentMethodModal] stripe_account is undefined/null - payment system may not be configured correctly');
setError(t('billing.paymentSystemNotConfigured', 'The payment system is not fully configured. Please contact support.'));
return;
}
setClientSecret(data.client_secret);
setStripeAccount(data.stripe_account);
// Load Stripe - empty stripe_account means direct_api mode (use tenant's publishable_key)
// Non-empty stripe_account means connect mode (use platform key with connected account)
setStripePromise(getStripeInstance(data.stripe_account, data.publishable_key));
},
onError: (err: any) => {
console.error('[AddPaymentMethodModal] SetupIntent error:', err);
setError(err.response?.data?.error || t('billing.setupIntentFailed', 'Failed to initialize. Please try again.'));
},
});
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
// Reset state when modal closes
setClientSecret(null);
setStripeAccount(null);
setStripePromise(null);
setError(null);
}
}, [isOpen]);
const handleSuccess = () => {
onSuccess?.();
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('billing.addPaymentMethod', 'Add Payment Method')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('billing.addPaymentMethodDescription', 'Save a card for future payments')}
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-5 h-5" />
</button>
</div>
{createSetupIntent.isPending ? (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-brand-600 mb-4" />
<p className="text-gray-600 dark:text-gray-400">
{t('common.loading', 'Loading...')}
</p>
</div>
) : error ? (
<div className="space-y-4">
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={() => {
setError(null);
createSetupIntent.mutate(undefined, {
onSuccess: (data) => {
setClientSecret(data.client_secret);
setStripeAccount(data.stripe_account);
setStripePromise(getStripeInstance(data.stripe_account, data.publishable_key));
},
onError: (err: any) => {
setError(err.response?.data?.error || t('billing.setupIntentFailed', 'Failed to initialize. Please try again.'));
},
});
}}
className="flex-1 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
>
{t('common.tryAgain', 'Try Again')}
</button>
</div>
</div>
) : clientSecret && stripePromise ? (
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: isDarkMode ? 'night' : 'stripe',
variables: {
colorPrimary: '#2563eb',
colorBackground: isDarkMode ? '#1f2937' : '#ffffff',
colorText: isDarkMode ? '#f1f5f9' : '#1e293b',
colorDanger: isDarkMode ? '#f87171' : '#dc2626',
fontFamily: 'system-ui, -apple-system, sans-serif',
spacingUnit: '12px',
borderRadius: '8px',
},
},
}}
>
<CardFormInner
clientSecret={clientSecret}
onSuccess={handleSuccess}
onCancel={onClose}
/>
</Elements>
) : null}
</div>
</div>
);
};
export default AddPaymentMethodModal;

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { X, AlertTriangle, CheckCircle, Info, AlertCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type ModalVariant = 'info' | 'warning' | 'danger' | 'success';
@@ -48,11 +49,13 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmText,
cancelText,
variant = 'info',
isLoading = false,
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
const config = variantConfig[variant];
@@ -95,7 +98,7 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
disabled={isLoading}
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
{cancelText}
{cancelText || t('common.cancel')}
</button>
<button
onClick={handleConfirm}
@@ -120,7 +123,7 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
/>
</svg>
)}
{confirmText}
{confirmText || t('common.confirm')}
</button>
</div>
</div>

View File

@@ -4,6 +4,7 @@
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ExternalLink,
CheckCircle,
@@ -27,6 +28,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
tier,
onSuccess,
}) => {
const { t } = useTranslation();
const [error, setError] = useState<string | null>(null);
const onboardingMutation = useConnectOnboarding();
@@ -53,7 +55,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
// Redirect to Stripe onboarding
window.location.href = result.url;
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to start onboarding');
setError(err.response?.data?.error || t('payments.failedToStartOnboarding'));
}
};
@@ -65,7 +67,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
// Redirect to continue onboarding
window.location.href = result.url;
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to refresh onboarding link');
setError(err.response?.data?.error || t('payments.failedToRefreshLink'));
}
};
@@ -73,13 +75,13 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
const getAccountTypeLabel = () => {
switch (connectAccount?.account_type) {
case 'standard':
return 'Standard Connect';
return t('payments.standardConnect');
case 'express':
return 'Express Connect';
return t('payments.expressConnect');
case 'custom':
return 'Custom Connect';
return t('payments.customConnect');
default:
return 'Connect';
return t('payments.connect');
}
};
@@ -91,9 +93,9 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
<div className="flex items-start gap-3">
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-green-800">Stripe Connected</h4>
<h4 className="font-medium text-green-800">{t('payments.stripeConnected')}</h4>
<p className="text-sm text-green-700 mt-1">
Your Stripe account is connected and ready to accept payments.
{t('payments.stripeConnectedDesc')}
</p>
</div>
</div>
@@ -103,14 +105,14 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
{/* Account Details */}
{connectAccount && (
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
<h4 className="font-medium text-gray-900 mb-3">{t('payments.accountDetails')}</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Account Type:</span>
<span className="text-gray-600">{t('payments.accountType')}:</span>
<span className="text-gray-900">{getAccountTypeLabel()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Status:</span>
<span className="text-gray-600">{t('payments.status')}:</span>
<span
className={`px-2 py-0.5 text-xs font-medium rounded-full ${
connectAccount.status === 'active'
@@ -126,40 +128,40 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Charges:</span>
<span className="text-gray-600">{t('payments.charges')}:</span>
<span className="flex items-center gap-1">
{connectAccount.charges_enabled ? (
<>
<CreditCard size={14} className="text-green-600" />
<span className="text-green-600">Enabled</span>
<span className="text-green-600">{t('payments.enabled')}</span>
</>
) : (
<>
<CreditCard size={14} className="text-gray-400" />
<span className="text-gray-500">Disabled</span>
<span className="text-gray-500">{t('payments.disabled')}</span>
</>
)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Payouts:</span>
<span className="text-gray-600">{t('payments.payouts')}:</span>
<span className="flex items-center gap-1">
{connectAccount.payouts_enabled ? (
<>
<Wallet size={14} className="text-green-600" />
<span className="text-green-600">Enabled</span>
<span className="text-green-600">{t('payments.enabled')}</span>
</>
) : (
<>
<Wallet size={14} className="text-gray-400" />
<span className="text-gray-500">Disabled</span>
<span className="text-gray-500">{t('payments.disabled')}</span>
</>
)}
</span>
</div>
{connectAccount.stripe_account_id && (
<div className="flex justify-between">
<span className="text-gray-600">Account ID:</span>
<span className="text-gray-600">{t('payments.accountId')}:</span>
<code className="font-mono text-gray-900 text-xs">
{connectAccount.stripe_account_id}
</code>
@@ -175,10 +177,9 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
<div className="flex items-start gap-3">
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-yellow-800">Complete Onboarding</h4>
<h4 className="font-medium text-yellow-800">{t('payments.completeOnboarding')}</h4>
<p className="text-sm text-yellow-700 mt-1">
Your Stripe Connect account setup is incomplete.
Click below to continue the onboarding process.
{t('payments.onboardingIncomplete')}
</p>
<button
onClick={handleRefreshLink}
@@ -190,7 +191,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
) : (
<RefreshCw size={16} />
)}
Continue Onboarding
{t('payments.continueOnboarding')}
</button>
</div>
</div>
@@ -201,24 +202,22 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
{needsOnboarding && (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-800 mb-2">Connect with Stripe</h4>
<h4 className="font-medium text-blue-800 mb-2">{t('payments.connectWithStripe')}</h4>
<p className="text-sm text-blue-700">
As a {tier} tier business, you'll use Stripe Connect to accept payments.
This provides a seamless payment experience for your customers while
the platform handles payment processing.
{t('payments.tierPaymentDescription', { tier })}
</p>
<ul className="mt-3 space-y-1 text-sm text-blue-700">
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Secure payment processing
{t('payments.securePaymentProcessing')}
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Automatic payouts to your bank account
{t('payments.automaticPayouts')}
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
PCI compliance handled for you
{t('payments.pciCompliance')}
</li>
</ul>
</div>
@@ -233,7 +232,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
) : (
<>
<ExternalLink size={18} />
Connect with Stripe
{t('payments.connectWithStripe')}
</>
)}
</button>
@@ -259,7 +258,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
>
<ExternalLink size={14} />
Open Stripe Dashboard
{t('payments.openStripeDashboard')}
</a>
)}
</div>

View File

@@ -20,6 +20,7 @@ import {
Wallet,
Building2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { createAccountSession, refreshConnectStatus, ConnectAccountInfo } from '../api/payments';
interface ConnectOnboardingEmbedProps {
@@ -37,6 +38,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
onComplete,
onError,
}) => {
const { t } = useTranslation();
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -68,7 +70,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
colorDanger: '#df1b41',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSizeBase: '14px',
spacingUnit: '4px',
spacingUnit: '12px',
borderRadius: '8px',
},
},
@@ -78,12 +80,12 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
setLoadingState('ready');
} catch (err: any) {
console.error('Failed to initialize Stripe Connect:', err);
const message = err.response?.data?.error || err.message || 'Failed to initialize payment setup';
const message = err.response?.data?.error || err.message || t('payments.failedToInitializePayment');
setErrorMessage(message);
setLoadingState('error');
onError?.(message);
}
}, [loadingState, onError]);
}, [loadingState, onError, t]);
// Handle onboarding completion
const handleOnboardingExit = useCallback(async () => {
@@ -100,23 +102,23 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
// Handle errors from the Connect component
const handleLoadError = useCallback((loadError: { error: { message?: string }; elementTagName: string }) => {
console.error('Connect component load error:', loadError);
const message = loadError.error.message || 'Failed to load payment component';
const message = loadError.error.message || t('payments.failedToLoadPaymentComponent');
setErrorMessage(message);
setLoadingState('error');
onError?.(message);
}, [onError]);
}, [onError, t]);
// Account type display
const getAccountTypeLabel = () => {
switch (connectAccount?.account_type) {
case 'standard':
return 'Standard Connect';
return t('payments.standardConnect');
case 'express':
return 'Express Connect';
return t('payments.expressConnect');
case 'custom':
return 'Custom Connect';
return t('payments.customConnect');
default:
return 'Connect';
return t('payments.connect');
}
};
@@ -128,39 +130,39 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
<div className="flex items-start gap-3">
<CheckCircle className="text-green-600 dark:text-green-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-green-800 dark:text-green-300">Stripe Connected</h4>
<h4 className="font-medium text-green-800 dark:text-green-300">{t('payments.stripeConnected')}</h4>
<p className="text-sm text-green-700 dark:text-green-400 mt-1">
Your Stripe account is connected and ready to accept payments.
{t('payments.stripeConnectedDesc')}
</p>
</div>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Account Details</h4>
<h4 className="font-medium text-gray-900 dark:text-white mb-3">{t('payments.accountDetails')}</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Account Type:</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.accountType')}:</span>
<span className="text-gray-900 dark:text-white">{getAccountTypeLabel()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Status:</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.status')}:</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
{connectAccount.status}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">Charges:</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.charges')}:</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CreditCard size={14} />
Enabled
{t('payments.enabled')}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">Payouts:</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.payouts')}:</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Wallet size={14} />
{connectAccount.payouts_enabled ? 'Enabled' : 'Pending'}
{connectAccount.payouts_enabled ? t('payments.enabled') : t('payments.pending')}
</span>
</div>
</div>
@@ -174,9 +176,9 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
return (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6 text-center">
<CheckCircle className="mx-auto text-green-600 dark:text-green-400 mb-3" size={48} />
<h4 className="font-medium text-green-800 dark:text-green-300 text-lg">Onboarding Complete!</h4>
<h4 className="font-medium text-green-800 dark:text-green-300 text-lg">{t('payments.onboardingComplete')}</h4>
<p className="text-sm text-green-700 dark:text-green-400 mt-2">
Your Stripe account has been set up. You can now accept payments.
{t('payments.stripeSetupComplete')}
</p>
</div>
);
@@ -190,7 +192,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
<div className="flex items-start gap-3">
<AlertCircle className="text-red-600 dark:text-red-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-red-800 dark:text-red-300">Setup Failed</h4>
<h4 className="font-medium text-red-800 dark:text-red-300">{t('payments.setupFailed')}</h4>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">{errorMessage}</p>
</div>
</div>
@@ -202,7 +204,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
}}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
>
Try Again
{t('payments.tryAgain')}
</button>
</div>
);
@@ -216,23 +218,22 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
<div className="flex items-start gap-3">
<Building2 className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-blue-800 dark:text-blue-300">Set Up Payments</h4>
<h4 className="font-medium text-blue-800 dark:text-blue-300">{t('payments.setUpPayments')}</h4>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-1">
As a {tier} tier business, you'll use Stripe Connect to accept payments.
Complete the onboarding process to start accepting payments from your customers.
{t('payments.tierPaymentDescriptionWithOnboarding', { tier })}
</p>
<ul className="mt-3 space-y-1 text-sm text-blue-700 dark:text-blue-400">
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Secure payment processing
{t('payments.securePaymentProcessing')}
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Automatic payouts to your bank account
{t('payments.automaticPayouts')}
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
PCI compliance handled for you
{t('payments.pciCompliance')}
</li>
</ul>
</div>
@@ -244,7 +245,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-[#635BFF] rounded-lg hover:bg-[#5851ea] transition-colors"
>
<CreditCard size={18} />
Start Payment Setup
{t('payments.startPaymentSetup')}
</button>
</div>
);
@@ -255,7 +256,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
return (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="animate-spin text-[#635BFF] mb-4" size={40} />
<p className="text-gray-600 dark:text-gray-400">Initializing payment setup...</p>
<p className="text-gray-600 dark:text-gray-400">{t('payments.initializingPaymentSetup')}</p>
</div>
);
}
@@ -265,10 +266,9 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
return (
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Complete Your Account Setup</h4>
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('payments.completeAccountSetup')}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Fill out the information below to finish setting up your payment account.
Your information is securely handled by Stripe.
{t('payments.fillOutInfoForPayment')}
</p>
</div>

View File

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

View File

@@ -377,7 +377,7 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
colorText: '#1e293b',
colorDanger: '#dc2626',
fontFamily: 'system-ui, -apple-system, sans-serif',
spacingUnit: '4px',
spacingUnit: '12px',
borderRadius: '8px',
},
},

View File

@@ -5,77 +5,89 @@ import { useQueryClient } from '@tanstack/react-query';
import { getBaseDomain, buildSubdomainUrl } from '../utils/domain';
export interface TestUser {
username: string;
email: string;
password: string;
role: string;
label: string;
color: string;
category: 'platform' | 'business' | 'customer';
}
const testUsers: TestUser[] = [
{
username: 'superuser',
email: 'superuser@platform.com',
password: 'test123',
role: 'SUPERUSER',
label: 'Platform Superuser',
color: 'bg-purple-600 hover:bg-purple-700',
category: 'platform',
},
{
username: 'platform_manager',
email: 'manager@platform.com',
password: 'test123',
role: 'PLATFORM_MANAGER',
label: 'Platform Manager',
color: 'bg-blue-600 hover:bg-blue-700',
category: 'platform',
},
{
username: 'platform_sales',
email: 'sales@platform.com',
password: 'test123',
role: 'PLATFORM_SALES',
label: 'Platform Sales',
color: 'bg-green-600 hover:bg-green-700',
category: 'platform',
},
{
username: 'platform_support',
email: 'support@platform.com',
password: 'test123',
role: 'PLATFORM_SUPPORT',
label: 'Platform Support',
color: 'bg-yellow-600 hover:bg-yellow-700',
category: 'platform',
},
{
username: 'tenant_owner',
email: 'owner@demo.com',
password: 'test123',
role: 'TENANT_OWNER',
label: 'Business Owner',
color: 'bg-indigo-600 hover:bg-indigo-700',
category: 'business',
},
{
username: 'tenant_manager',
email: 'manager@demo.com',
password: 'test123',
role: 'TENANT_MANAGER',
label: 'Business Manager',
color: 'bg-pink-600 hover:bg-pink-700',
category: 'business',
},
{
username: 'tenant_staff',
email: 'staff@demo.com',
password: 'test123',
role: 'TENANT_STAFF',
label: 'Staff Member',
color: 'bg-teal-600 hover:bg-teal-700',
category: 'business',
},
{
username: 'customer',
email: 'customer@demo.com',
password: 'test123',
role: 'CUSTOMER',
label: 'Customer',
color: 'bg-orange-600 hover:bg-orange-700',
category: 'customer',
},
];
type UserFilter = 'all' | 'platform' | 'business';
interface DevQuickLoginProps {
embedded?: boolean;
filter?: UserFilter;
}
export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
export function DevQuickLogin({ embedded = false, filter = 'all' }: DevQuickLoginProps) {
const queryClient = useQueryClient();
const [loading, setLoading] = useState<string | null>(null);
const [isMinimized, setIsMinimized] = useState(false);
@@ -85,17 +97,25 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
return null;
}
// Filter users based on the filter prop
const filteredUsers = testUsers.filter((user) => {
if (filter === 'all') return true;
if (filter === 'platform') return user.category === 'platform';
if (filter === 'business') return user.category === 'business';
return true;
});
const handleQuickLogin = async (user: TestUser) => {
setLoading(user.username);
setLoading(user.email);
try {
// Call token auth API
const response = await apiClient.post('/auth-token/', {
username: user.username,
// Call custom login API that supports email login
const response = await apiClient.post('/auth/login/', {
email: user.email,
password: user.password,
});
// Store token in cookie (use 'access_token' to match what client.ts expects)
setCookie('access_token', response.data.token, 7);
setCookie('access_token', response.data.access, 7);
// Clear any existing masquerade stack - this is a fresh login
localStorage.removeItem('masquerade_stack');
@@ -174,14 +194,14 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
</div>
<div className="grid grid-cols-2 gap-2">
{testUsers.map((user) => (
{filteredUsers.map((user) => (
<button
key={user.username}
key={user.email}
onClick={() => handleQuickLogin(user)}
disabled={loading !== null}
className={`${user.color} text-white px-3 py-2 rounded text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}
>
{loading === user.username ? (
{loading === user.email ? (
<span className="flex items-center justify-center">
<svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
<circle

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Search,
Globe,
@@ -26,6 +27,7 @@ interface DomainPurchaseProps {
type Step = 'search' | 'details' | 'confirm';
const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>('search');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<DomainAvailability[]>([]);
@@ -138,7 +140,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
>
1
</div>
<span className="text-sm font-medium">Search</span>
<span className="text-sm font-medium">{t('common.search')}</span>
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
<div
@@ -155,7 +157,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
>
2
</div>
<span className="text-sm font-medium">Details</span>
<span className="text-sm font-medium">{t('settings.domain.details')}</span>
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
<div
@@ -172,7 +174,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
>
3
</div>
<span className="text-sm font-medium">Confirm</span>
<span className="text-sm font-medium">{t('common.confirm')}</span>
</div>
</div>
@@ -186,7 +188,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Enter domain name or keyword..."
placeholder={t('settings.domain.searchPlaceholder')}
className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
@@ -200,14 +202,14 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
) : (
<Search className="h-5 w-5" />
)}
Search
{t('common.search')}
</button>
</form>
{/* Search Results */}
{searchResults.length > 0 && (
<div className="space-y-3">
<h4 className="font-medium text-gray-900 dark:text-white">Search Results</h4>
<h4 className="font-medium text-gray-900 dark:text-white">{t('payments.searchResults')}</h4>
<div className="space-y-2">
{searchResults.map((result) => (
<div
@@ -230,7 +232,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</span>
{result.premium && (
<span className="ml-2 px-2 py-0.5 text-xs bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded">
Premium
{t('settings.domain.premium')}
</span>
)}
</div>
@@ -246,12 +248,12 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
className="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700 flex items-center gap-2"
>
<ShoppingCart className="h-4 w-4" />
Select
{t('settings.domain.select')}
</button>
</>
)}
{!result.available && (
<span className="text-sm text-gray-500 dark:text-gray-400">Unavailable</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{t('settings.domain.unavailable')}</span>
)}
</div>
</div>
@@ -264,7 +266,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
{registeredDomains && registeredDomains.length > 0 && (
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-700">
<h4 className="font-medium text-gray-900 dark:text-white mb-4">
Your Registered Domains
{t('settings.domain.yourRegisteredDomains')}
</h4>
<div className="space-y-2">
{registeredDomains.map((domain) => (
@@ -289,7 +291,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
{domain.expires_at && (
<span className="text-sm text-gray-500 dark:text-gray-400">
Expires: {new Date(domain.expires_at).toLocaleDateString()}
{t('settings.domain.expires')}: {new Date(domain.expires_at).toLocaleDateString()}
</span>
)}
</div>
@@ -316,7 +318,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
onClick={() => setStep('search')}
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
>
Change
{t('settings.domain.change')}
</button>
</div>
</div>
@@ -325,7 +327,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Registration Period
{t('payments.registrationPeriod')}
</label>
<select
value={years}
@@ -334,7 +336,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
>
{[1, 2, 3, 5, 10].map((y) => (
<option key={y} value={y}>
{y} {y === 1 ? 'year' : 'years'} - $
{y} {y === 1 ? t('settings.domain.year') : t('settings.domain.years')} - $
{((selectedDomain.premium_price || selectedDomain.price || 0) * y).toFixed(2)}
</option>
))}
@@ -355,10 +357,10 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
<Shield className="h-5 w-5 text-gray-400" />
<div>
<span className="text-gray-900 dark:text-white font-medium">
WHOIS Privacy Protection
{t('settings.domain.whoisPrivacy')}
</span>
<p className="text-sm text-gray-500 dark:text-gray-400">
Hide your personal information from public WHOIS lookups
{t('settings.domain.whoisPrivacyDesc')}
</p>
</div>
</div>
@@ -374,9 +376,9 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
<div className="flex items-center gap-2">
<RefreshCw className="h-5 w-5 text-gray-400" />
<div>
<span className="text-gray-900 dark:text-white font-medium">Auto-Renewal</span>
<span className="text-gray-900 dark:text-white font-medium">{t('settings.domain.autoRenewal')}</span>
<p className="text-sm text-gray-500 dark:text-gray-400">
Automatically renew this domain before it expires
{t('settings.domain.autoRenewalDesc')}
</p>
</div>
</div>
@@ -393,10 +395,10 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
<Globe className="h-5 w-5 text-gray-400" />
<div>
<span className="text-gray-900 dark:text-white font-medium">
Auto-configure as Custom Domain
{t('settings.domain.autoConfigure')}
</span>
<p className="text-sm text-gray-500 dark:text-gray-400">
Automatically set up this domain for your business
{t('settings.domain.autoConfigureDesc')}
</p>
</div>
</div>
@@ -406,12 +408,12 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
{/* Contact Information */}
<div className="pt-6 border-t border-gray-100 dark:border-gray-700">
<h4 className="font-medium text-gray-900 dark:text-white mb-4">
Registrant Information
{t('settings.domain.registrantInfo')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
First Name *
{t('settings.domain.firstName')} *
</label>
<input
type="text"
@@ -423,7 +425,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Name *
{t('settings.domain.lastName')} *
</label>
<input
type="text"
@@ -435,7 +437,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email *
{t('customers.email')} *
</label>
<input
type="email"
@@ -447,7 +449,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Phone *
{t('customers.phone')} *
</label>
<input
type="tel"
@@ -460,7 +462,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Address *
{t('customers.address')} *
</label>
<input
type="text"
@@ -472,7 +474,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
City *
{t('customers.city')} *
</label>
<input
type="text"
@@ -484,7 +486,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
State/Province *
{t('settings.domain.stateProvince')} *
</label>
<input
type="text"
@@ -496,7 +498,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
ZIP/Postal Code *
{t('settings.domain.zipPostalCode')} *
</label>
<input
type="text"
@@ -508,19 +510,19 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Country *
{t('settings.domain.country')} *
</label>
<select
value={contact.country}
onChange={(e) => updateContact('country', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="US">United States</option>
<option value="CA">Canada</option>
<option value="GB">United Kingdom</option>
<option value="AU">Australia</option>
<option value="DE">Germany</option>
<option value="FR">France</option>
<option value="US">{t('settings.domain.countries.us')}</option>
<option value="CA">{t('settings.domain.countries.ca')}</option>
<option value="GB">{t('settings.domain.countries.gb')}</option>
<option value="AU">{t('settings.domain.countries.au')}</option>
<option value="DE">{t('settings.domain.countries.de')}</option>
<option value="FR">{t('settings.domain.countries.fr')}</option>
</select>
</div>
</div>
@@ -532,14 +534,14 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
onClick={() => setStep('search')}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Back
{t('common.back')}
</button>
<button
onClick={() => setStep('confirm')}
disabled={!isContactValid()}
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Continue
{t('settings.domain.continue')}
</button>
</div>
</div>
@@ -548,36 +550,36 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
{/* Step 3: Confirm */}
{step === 'confirm' && selectedDomain && (
<div className="space-y-6">
<h4 className="font-medium text-gray-900 dark:text-white">Order Summary</h4>
<h4 className="font-medium text-gray-900 dark:text-white">{t('payments.orderSummary')}</h4>
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 space-y-3">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Domain</span>
<span className="text-gray-600 dark:text-gray-400">{t('settings.domain.domain')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{selectedDomain.domain}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Registration Period</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.registrationPeriod')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{years} {years === 1 ? 'year' : 'years'}
{years} {years === 1 ? t('settings.domain.year') : t('settings.domain.years')}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">WHOIS Privacy</span>
<span className="text-gray-600 dark:text-gray-400">{t('settings.domain.whoisPrivacy')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{whoisPrivacy ? 'Enabled' : 'Disabled'}
{whoisPrivacy ? t('platform.settings.enabled') : t('platform.settings.none')}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Auto-Renewal</span>
<span className="text-gray-600 dark:text-gray-400">{t('settings.domain.autoRenewal')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{autoRenew ? 'Enabled' : 'Disabled'}
{autoRenew ? t('platform.settings.enabled') : t('platform.settings.none')}
</span>
</div>
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between">
<span className="font-semibold text-gray-900 dark:text-white">Total</span>
<span className="font-semibold text-gray-900 dark:text-white">{t('settings.domain.total')}</span>
<span className="font-bold text-xl text-brand-600 dark:text-brand-400">
${getPrice().toFixed(2)}
</span>
@@ -587,7 +589,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
{/* Registrant Summary */}
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Registrant</h5>
<h5 className="font-medium text-gray-900 dark:text-white mb-2">{t('settings.domain.registrant')}</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
{contact.first_name} {contact.last_name}
<br />
@@ -602,7 +604,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
{registerMutation.isError && (
<div className="flex items-center gap-2 p-4 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg">
<AlertCircle className="h-5 w-5" />
<span>Registration failed. Please try again.</span>
<span>{t('payments.registrationFailed')}</span>
</div>
)}
@@ -612,7 +614,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
onClick={() => setStep('details')}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Back
{t('common.back')}
</button>
<button
onClick={handlePurchase}
@@ -624,7 +626,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
) : (
<ShoppingCart className="h-5 w-5" />
)}
Complete Purchase
{t('settings.domain.completePurchase')}
</button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import axios from '../api/client';
import { X, Calendar, Clock, RotateCw, Zap } from 'lucide-react';
import { formatLocalDate } from '../utils/dateUtils';
interface ScheduledTask {
id: string;
@@ -79,7 +80,7 @@ const EditTaskModal: React.FC<EditTaskModalProps> = ({ task, isOpen, onClose, on
setScheduleMode('onetime');
if (task.run_at) {
const date = new Date(task.run_at);
setRunAtDate(date.toISOString().split('T')[0]);
setRunAtDate(formatLocalDate(date));
setRunAtTime(date.toTimeString().slice(0, 5));
}
} else if (task.schedule_type === 'INTERVAL') {

View File

@@ -0,0 +1,247 @@
/**
* FeatureGate Component
*
* Conditionally renders children based on entitlement checks.
* Used to show/hide features based on the business's subscription plan.
*/
import React from 'react';
import { useEntitlements } from '../hooks/useEntitlements';
// ============================================================================
// FeatureGate - For boolean feature checks
// ============================================================================
interface FeatureGateProps {
/**
* Single feature code to check
*/
feature?: string;
/**
* Multiple feature codes to check
*/
features?: string[];
/**
* If true, ALL features must be enabled. If false, ANY feature being enabled is sufficient.
* Default: true (all required)
*/
requireAll?: boolean;
/**
* Content to render when feature(s) are enabled
*/
children: React.ReactNode;
/**
* Content to render when feature(s) are NOT enabled
*/
fallback?: React.ReactNode;
/**
* Content to render while entitlements are loading
*/
loadingFallback?: React.ReactNode;
}
/**
* Conditionally render content based on feature entitlements.
*
* @example
* ```tsx
* // Single feature check
* <FeatureGate feature="can_use_sms_reminders">
* <SMSSettings />
* </FeatureGate>
*
* // With fallback
* <FeatureGate
* feature="can_use_sms_reminders"
* fallback={<UpgradePrompt feature="SMS Reminders" />}
* >
* <SMSSettings />
* </FeatureGate>
*
* // Multiple features (all required)
* <FeatureGate features={['can_use_plugins', 'can_use_tasks']}>
* <TaskScheduler />
* </FeatureGate>
*
* // Multiple features (any one)
* <FeatureGate features={['can_use_sms_reminders', 'can_use_webhooks']} requireAll={false}>
* <NotificationSettings />
* </FeatureGate>
* ```
*/
export const FeatureGate: React.FC<FeatureGateProps> = ({
feature,
features,
requireAll = true,
children,
fallback = null,
loadingFallback = null,
}) => {
const { hasFeature, isLoading } = useEntitlements();
// Show loading state if provided
if (isLoading) {
return <>{loadingFallback}</>;
}
// Determine which features to check
const featuresToCheck = features ?? (feature ? [feature] : []);
if (featuresToCheck.length === 0) {
// No features specified, render children
return <>{children}</>;
}
// Check features
const hasAccess = requireAll
? featuresToCheck.every((f) => hasFeature(f))
: featuresToCheck.some((f) => hasFeature(f));
if (hasAccess) {
return <>{children}</>;
}
return <>{fallback}</>;
};
// ============================================================================
// LimitGate - For integer limit checks
// ============================================================================
interface LimitGateProps {
/**
* The limit feature code to check (e.g., 'max_users')
*/
limit: string;
/**
* Current usage count
*/
currentUsage: number;
/**
* Content to render when under the limit
*/
children: React.ReactNode;
/**
* Content to render when at or over the limit
*/
fallback?: React.ReactNode;
/**
* Content to render while entitlements are loading
*/
loadingFallback?: React.ReactNode;
}
/**
* Conditionally render content based on usage limits.
*
* @example
* ```tsx
* <LimitGate
* limit="max_users"
* currentUsage={users.length}
* fallback={<UpgradePrompt message="You've reached your user limit" />}
* >
* <AddUserButton />
* </LimitGate>
* ```
*/
export const LimitGate: React.FC<LimitGateProps> = ({
limit,
currentUsage,
children,
fallback = null,
loadingFallback = null,
}) => {
const { getLimit, isLoading } = useEntitlements();
// Show loading state if provided
if (isLoading) {
return <>{loadingFallback}</>;
}
const maxLimit = getLimit(limit);
// If limit is null, treat as unlimited
if (maxLimit === null) {
return <>{children}</>;
}
// Check if under limit
if (currentUsage < maxLimit) {
return <>{children}</>;
}
return <>{fallback}</>;
};
// ============================================================================
// Helper Components
// ============================================================================
interface UpgradePromptProps {
/**
* Feature name to display
*/
feature?: string;
/**
* Custom message
*/
message?: string;
/**
* Upgrade URL (defaults to /settings/billing)
*/
upgradeUrl?: string;
}
/**
* Default upgrade prompt component.
* Can be used as a fallback in FeatureGate/LimitGate.
*/
export const UpgradePrompt: React.FC<UpgradePromptProps> = ({
feature,
message,
upgradeUrl = '/settings/billing',
}) => {
const displayMessage =
message || (feature ? `Upgrade your plan to access ${feature}` : 'Upgrade your plan to access this feature');
return (
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-yellow-600 dark:text-yellow-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span className="text-yellow-800 dark:text-yellow-200 font-medium">{displayMessage}</span>
</div>
<a
href={upgradeUrl}
className="mt-2 inline-block text-sm text-yellow-700 dark:text-yellow-300 hover:underline"
>
View upgrade options &rarr;
</a>
</div>
);
};
export default FeatureGate;

View File

@@ -20,13 +20,17 @@ const routeToHelpPath: Record<string, string> = {
'/services': '/help/services',
'/resources': '/help/resources',
'/staff': '/help/staff',
'/time-blocks': '/help/time-blocks',
'/my-availability': '/help/time-blocks',
'/messages': '/help/messages',
'/tickets': '/help/ticketing',
'/payments': '/help/payments',
'/contracts': '/help/contracts',
'/contracts/templates': '/help/contracts',
'/plugins': '/help/plugins',
'/plugins/marketplace': '/help/plugins',
'/plugins/my-plugins': '/help/plugins',
'/plugins/create': '/help/plugins/create',
'/plugins/create': '/help/plugins/docs',
'/settings': '/help/settings/general',
'/settings/general': '/help/settings/general',
'/settings/resource-types': '/help/settings/resource-types',

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

@@ -1,5 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Eye, XCircle } from 'lucide-react';
import { User } from '../types';
@@ -11,8 +12,9 @@ interface MasqueradeBannerProps {
}
const MasqueradeBanner: React.FC<MasqueradeBannerProps> = ({ effectiveUser, originalUser, previousUser, onStop }) => {
const buttonText = previousUser ? `Return to ${previousUser.name}` : 'Stop Masquerading';
const { t } = useTranslation();
const buttonText = previousUser ? t('platform.masquerade.returnTo', { name: previousUser.name }) : t('platform.masquerade.stopMasquerading');
return (
<div className="bg-orange-600 text-white px-4 py-2 shadow-md flex items-center justify-between z-50 relative">
@@ -21,9 +23,9 @@ const MasqueradeBanner: React.FC<MasqueradeBannerProps> = ({ effectiveUser, orig
<Eye size={18} />
</div>
<span className="text-sm font-medium">
Masquerading as <strong>{effectiveUser.name}</strong> ({effectiveUser.role})
<span className="opacity-75 mx-2 text-xs">|</span>
Logged in as {originalUser.name}
{t('platform.masquerade.masqueradingAs')} <strong>{effectiveUser.name}</strong> ({effectiveUser.role})
<span className="opacity-75 mx-2 text-xs">|</span>
{t('platform.masquerade.loggedInAs', { name: originalUser.name })}
</span>
</div>
<button

View File

@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Bell, Check, CheckCheck, Trash2, X, Ticket, Calendar, MessageSquare } from 'lucide-react';
import { Bell, Check, CheckCheck, Trash2, X, Ticket, Calendar, MessageSquare, Clock } from 'lucide-react';
import {
useNotifications,
useUnreadNotificationCount,
@@ -56,6 +56,14 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
}
}
// Handle time-off request notifications - navigate to time blocks page
// Includes both new requests and modified requests that need re-approval
if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
navigate('/dashboard/time-blocks');
setIsOpen(false);
return;
}
// Navigate to target if available
if (notification.target_url) {
navigate(notification.target_url);
@@ -71,8 +79,13 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
clearAllMutation.mutate();
};
const getNotificationIcon = (targetType: string | null) => {
switch (targetType) {
const getNotificationIcon = (notification: Notification) => {
// Check for time-off request type in data (new or modified)
if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
return <Clock size={16} className="text-amber-500" />;
}
switch (notification.target_type) {
case 'ticket':
return <Ticket size={16} className="text-blue-500" />;
case 'event':
@@ -171,7 +184,7 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
{getNotificationIcon(notification.target_type)}
{getNotificationIcon(notification)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm ${!notification.read ? 'font-medium' : ''} text-gray-900 dark:text-white`}>
@@ -211,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

@@ -1,4 +1,5 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
import { format, addDays, addWeeks, addMonths, startOfDay, startOfWeek, startOfMonth, endOfDay, endOfWeek, endOfMonth, eachDayOfInterval, eachHourOfInterval, isToday, isSameDay, getDay } from 'date-fns';
import { useAppointments, useUpdateAppointment } from '../hooks/useAppointments';
@@ -28,6 +29,7 @@ interface ResourceCalendarProps {
}
const ResourceCalendar: React.FC<ResourceCalendarProps> = ({ resourceId, resourceName, onClose }) => {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>('day');
const [currentDate, setCurrentDate] = useState(new Date());
const timelineRef = useRef<HTMLDivElement>(null);
@@ -712,12 +714,12 @@ const ResourceCalendar: React.FC<ResourceCalendarProps> = ({ resourceId, resourc
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-gray-400 dark:text-gray-500">Loading appointments...</p>
<p className="text-gray-400 dark:text-gray-500">{t('scheduler.loadingAppointments')}</p>
</div>
)}
{!isLoading && appointments.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-gray-400 dark:text-gray-500">No appointments scheduled for this period</p>
<p className="text-gray-400 dark:text-gray-500">{t('scheduler.noAppointmentsScheduled')}</p>
</div>
)}
</div>

View File

@@ -0,0 +1,325 @@
/**
* Resource Detail Modal
*
* Shows resource details including a map of the staff member's
* current location when they are en route or in progress.
*/
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GoogleMap, useJsApiLoader, Marker } from '@react-google-maps/api';
import { Resource } from '../types';
import { useResourceLocation, useLiveResourceLocation } from '../hooks/useResourceLocation';
import Portal from './Portal';
import {
X,
MapPin,
Navigation,
Clock,
User as UserIcon,
Activity,
Loader2,
AlertCircle,
} from 'lucide-react';
interface ResourceDetailModalProps {
resource: Resource;
onClose: () => void;
}
const mapContainerStyle = {
width: '100%',
height: '300px',
borderRadius: '0.5rem',
};
const defaultCenter = {
lat: 39.8283, // Center of US
lng: -98.5795,
};
const ResourceDetailModal: React.FC<ResourceDetailModalProps> = ({ resource, onClose }) => {
const { t } = useTranslation();
const googleMapsApiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || '';
const hasApiKey = googleMapsApiKey.length > 0;
// Fetch location data
const { data: location, isLoading, error } = useResourceLocation(resource.id);
// Connect to live updates when tracking is active
useLiveResourceLocation(resource.id, {
enabled: location?.isTracking === true,
});
// Load Google Maps API only if we have a key
// When no API key, we skip the hook entirely to avoid warnings
const shouldLoadMaps = hasApiKey;
const { isLoaded: mapsLoaded, loadError: mapsLoadError } = useJsApiLoader({
googleMapsApiKey: shouldLoadMaps ? googleMapsApiKey : 'SKIP_LOADING',
});
// Treat missing API key as if maps failed to load
const effectiveMapsLoaded = shouldLoadMaps && mapsLoaded;
const effectiveMapsError = !shouldLoadMaps || mapsLoadError;
// Map center based on location
const mapCenter = useMemo(() => {
if (location?.hasLocation && location.latitude && location.longitude) {
return { lat: location.latitude, lng: location.longitude };
}
return defaultCenter;
}, [location]);
// Format timestamp
const formattedTimestamp = useMemo(() => {
if (!location?.timestamp) return null;
const date = new Date(location.timestamp);
return date.toLocaleString();
}, [location?.timestamp]);
// Status color based on job status
const statusColor = useMemo(() => {
if (!location?.activeJob) return 'gray';
switch (location.activeJob.status) {
case 'EN_ROUTE':
return 'yellow';
case 'IN_PROGRESS':
return 'blue';
default:
return 'gray';
}
}, [location?.activeJob]);
return (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-2xl overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<UserIcon size={20} className="text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{resource.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('resources.staffMember', 'Staff Member')}
</p>
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<span className="sr-only">{t('common.close')}</span>
<X size={24} />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Active Job Status */}
{location?.activeJob && (
<div className={`p-4 rounded-lg border ${
statusColor === 'yellow'
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
: statusColor === 'blue'
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-700'
}`}>
<div className="flex items-center gap-3">
<Activity size={20} className={
statusColor === 'yellow'
? 'text-yellow-600 dark:text-yellow-400'
: statusColor === 'blue'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400'
} />
<div>
<div className="font-medium text-gray-900 dark:text-white">
{location.activeJob.statusDisplay}
</div>
<div className="text-sm text-gray-600 dark:text-gray-300">
{location.activeJob.title}
</div>
</div>
{location.isTracking && (
<span className="ml-auto inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
{t('resources.liveTracking', 'Live')}
</span>
)}
</div>
</div>
)}
{/* Map Section */}
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
<MapPin size={16} />
{t('resources.currentLocation', 'Current Location')}
</h4>
{isLoading ? (
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<Loader2 size={32} className="text-gray-400 animate-spin" />
</div>
) : error ? (
<div className="h-[300px] bg-red-50 dark:bg-red-900/20 rounded-lg flex items-center justify-center">
<div className="text-center">
<AlertCircle size={32} className="text-red-400 mx-auto mb-2" />
<p className="text-red-600 dark:text-red-400">{t('resources.locationError', 'Failed to load location')}</p>
</div>
</div>
) : !location?.hasLocation ? (
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<div className="text-center">
<MapPin size={32} className="text-gray-400 mx-auto mb-2" />
<p className="text-gray-500 dark:text-gray-400">
{location?.message || t('resources.noLocationData', 'No location data available')}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{t('resources.locationHint', 'Location will appear when staff is en route')}
</p>
</div>
</div>
) : effectiveMapsError ? (
// Fallback when Google Maps isn't available - show coordinates
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg p-6">
<div className="h-full flex flex-col items-center justify-center">
<div className="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-4">
<Navigation size={32} className="text-blue-600 dark:text-blue-400" />
</div>
<div className="text-center">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
{t('resources.gpsCoordinates', 'GPS Coordinates')}
</p>
<p className="text-lg font-medium text-gray-900 dark:text-white mb-1">
{location.latitude?.toFixed(6)}, {location.longitude?.toFixed(6)}
</p>
{location.speed !== undefined && location.speed !== null && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('resources.speed', 'Speed')}: {(location.speed * 2.237).toFixed(1)} mph
</p>
)}
{location.heading !== undefined && location.heading !== null && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('resources.heading', 'Heading')}: {location.heading.toFixed(0)}°
</p>
)}
</div>
<a
href={`https://www.google.com/maps?q=${location.latitude},${location.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
>
<MapPin size={16} />
{t('resources.openInMaps', 'Open in Google Maps')}
</a>
</div>
</div>
) : effectiveMapsLoaded ? (
<GoogleMap
mapContainerStyle={mapContainerStyle}
center={mapCenter}
zoom={15}
options={{
disableDefaultUI: false,
zoomControl: true,
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: true,
}}
>
{location.latitude && location.longitude && (
<Marker
position={{ lat: location.latitude, lng: location.longitude }}
title={resource.name}
icon={{
path: google.maps.SymbolPath.CIRCLE,
scale: 10,
fillColor: location.isTracking ? '#22c55e' : '#3b82f6',
fillOpacity: 1,
strokeColor: '#ffffff',
strokeWeight: 3,
}}
/>
)}
</GoogleMap>
) : (
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<Loader2 size={32} className="text-gray-400 animate-spin" />
</div>
)}
</div>
{/* Location Details */}
{location?.hasLocation && (
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1 flex items-center gap-1">
<Clock size={12} />
{t('resources.lastUpdate', 'Last Update')}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{formattedTimestamp || '-'}
</div>
</div>
{location.accuracy && (
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{t('resources.accuracy', 'Accuracy')}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{location.accuracy < 1000
? `${location.accuracy.toFixed(0)}m`
: `${(location.accuracy / 1000).toFixed(1)}km`}
</div>
</div>
)}
{location.speed !== undefined && location.speed !== null && (
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{t('resources.speed', 'Speed')}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{(location.speed * 2.237).toFixed(1)} mph
</div>
</div>
)}
{location.heading !== undefined && location.heading !== null && (
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{t('resources.heading', 'Heading')}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{location.heading.toFixed(0)}°
</div>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
{t('common.close', 'Close')}
</button>
</div>
</div>
</div>
</Portal>
);
};
export default ResourceDetailModal;

View File

@@ -4,7 +4,9 @@ import { CSS } from '@dnd-kit/utilities';
import { clsx } from 'clsx';
import { Clock, DollarSign } from 'lucide-react';
export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
// Import from types.ts for consistency
import type { AppointmentStatus } from '../../types';
export type { AppointmentStatus };
export interface DraggableEventProps {
id: number;

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useDraggable } from '@dnd-kit/core';
import { Clock, GripVertical } from 'lucide-react';
import { clsx } from 'clsx';
import { useTranslation } from 'react-i18next';
export interface PendingAppointment {
id: number;
@@ -15,6 +16,7 @@ interface PendingItemProps {
}
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `pending-${appointment.id}`,
data: {
@@ -43,7 +45,7 @@ const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
</div>
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400">
<Clock size={10} />
<span>{appointment.durationMinutes} min</span>
<span>{appointment.durationMinutes} {t('scheduler.min')}</span>
</div>
</div>
);
@@ -54,16 +56,18 @@ interface PendingSidebarProps {
}
const PendingSidebar: React.FC<PendingSidebarProps> = ({ appointments }) => {
const { t } = useTranslation();
return (
<div className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col h-full shrink-0">
<div className="p-4 border-b border-gray-200 bg-gray-100">
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-2">
<Clock size={12} /> Pending Requests ({appointments.length})
<Clock size={12} /> {t('scheduler.pendingRequests')} ({appointments.length})
</h3>
</div>
<div className="p-4 overflow-y-auto flex-1">
{appointments.length === 0 ? (
<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>
<div className="text-xs text-gray-400 italic text-center py-4">{t('scheduler.noPendingRequests')}</div>
) : (
appointments.map(apt => (
<PendingItem key={apt.id} appointment={apt} />

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useDraggable } from '@dnd-kit/core';
import { Clock, GripVertical, Trash2 } from 'lucide-react';
import { clsx } from 'clsx';
import { useTranslation } from 'react-i18next';
export interface PendingAppointment {
id: number;
@@ -22,6 +23,7 @@ interface PendingItemProps {
}
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `pending-${appointment.id}`,
data: {
@@ -50,7 +52,7 @@ const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
</div>
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
<Clock size={10} />
<span>{appointment.durationMinutes} min</span>
<span>{appointment.durationMinutes} {t('scheduler.min')}</span>
</div>
</div>
);
@@ -63,11 +65,13 @@ interface SidebarProps {
}
const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments, scrollRef }) => {
const { t } = useTranslation();
return (
<div className="flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shrink-0 shadow-lg z-20 transition-colors duration-200" style={{ width: 250 }}>
{/* Resources Header */}
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex items-center px-4 font-semibold text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider shrink-0 transition-colors duration-200" style={{ height: 48 }}>
Resources
{t('scheduler.resources')}
</div>
{/* Resources List (Synced Scroll) */}
@@ -89,10 +93,10 @@ const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments,
<div>
<p className="font-medium text-sm text-gray-900 dark:text-white">{layout.resourceName}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 capitalize flex items-center gap-1">
Resource
{t('scheduler.resource')}
{layout.laneCount > 1 && (
<span className="text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/50 px-1 rounded text-[10px]">
{layout.laneCount} lanes
{layout.laneCount} {t('scheduler.lanes')}
</span>
)}
</p>
@@ -106,11 +110,11 @@ const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments,
{/* Pending Requests (Fixed Bottom) */}
<div className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 h-80 flex flex-col transition-colors duration-200">
<h3 className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2 shrink-0">
<Clock size={12} /> Pending Requests ({pendingAppointments.length})
<Clock size={12} /> {t('scheduler.pendingRequests')} ({pendingAppointments.length})
</h3>
<div className="space-y-2 overflow-y-auto flex-1 mb-2">
{pendingAppointments.length === 0 ? (
<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>
<div className="text-xs text-gray-400 italic text-center py-4">{t('scheduler.noPendingRequests')}</div>
) : (
pendingAppointments.map(apt => (
<PendingItem key={apt.id} appointment={apt} />
@@ -122,7 +126,7 @@ const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments,
<div className="shrink-0 mt-2 border-t border-gray-200 dark:border-gray-700 pt-2 opacity-50">
<div className="flex items-center justify-center gap-2 p-3 rounded-lg border-2 border-dashed border-gray-200 dark:border-gray-700 bg-transparent text-gray-400">
<Trash2 size={16} />
<span className="text-xs font-medium">Drop here to archive</span>
<span className="text-xs font-medium">{t('scheduler.dropToArchive')}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,921 @@
/**
* Unit tests for Sidebar component
*
* Tests cover:
* - Component rendering
* - Resources list display
* - Pending appointments list
* - Empty state handling
* - Drag source setup with @dnd-kit
* - Scrolling reference setup
* - Multi-lane resource badges
* - Archive drop zone display
* - Internationalization (i18n)
*/
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { DndContext } from '@dnd-kit/core';
import React from 'react';
import Sidebar, { PendingAppointment, ResourceLayout } from '../Sidebar';
// Setup proper mocks for @dnd-kit
beforeAll(() => {
// Mock IntersectionObserver properly as a constructor
class IntersectionObserverMock {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
constructor() {
return this;
}
}
global.IntersectionObserver = IntersectionObserverMock as any;
// Mock ResizeObserver properly as a constructor
class ResizeObserverMock {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
constructor() {
return this;
}
}
global.ResizeObserver = ResizeObserverMock as any;
});
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'scheduler.resources': 'Resources',
'scheduler.resource': 'Resource',
'scheduler.lanes': 'lanes',
'scheduler.pendingRequests': 'Pending Requests',
'scheduler.noPendingRequests': 'No pending requests',
'scheduler.dropToArchive': 'Drop here to archive',
'scheduler.min': 'min',
};
return translations[key] || key;
},
}),
}));
// Helper function to create a wrapper with DndContext
const createDndWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<DndContext>{children}</DndContext>
);
};
describe('Sidebar', () => {
const mockScrollRef = { current: null } as React.RefObject<HTMLDivElement>;
const mockResourceLayouts: ResourceLayout[] = [
{
resourceId: 1,
resourceName: 'Dr. Smith',
height: 100,
laneCount: 1,
},
{
resourceId: 2,
resourceName: 'Conference Room A',
height: 120,
laneCount: 2,
},
{
resourceId: 3,
resourceName: 'Equipment Bay',
height: 100,
laneCount: 3,
},
];
const mockPendingAppointments: PendingAppointment[] = [
{
id: 1,
customerName: 'John Doe',
serviceName: 'Consultation',
durationMinutes: 30,
},
{
id: 2,
customerName: 'Jane Smith',
serviceName: 'Follow-up',
durationMinutes: 15,
},
{
id: 3,
customerName: 'Bob Johnson',
serviceName: 'Initial Assessment',
durationMinutes: 60,
},
];
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render the sidebar container', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toBeInTheDocument();
expect(sidebar).toHaveClass('flex', 'flex-col', 'bg-white');
});
it('should render with fixed width of 250px', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveStyle({ width: '250px' });
});
it('should render resources header', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const header = screen.getByText('Resources');
expect(header).toBeInTheDocument();
});
it('should render pending requests section', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const pendingHeader = screen.getByText(/Pending Requests/);
expect(pendingHeader).toBeInTheDocument();
});
it('should render archive drop zone', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const dropZone = screen.getByText('Drop here to archive');
expect(dropZone).toBeInTheDocument();
});
});
describe('Resources List', () => {
it('should render all resources from resourceLayouts', () => {
render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('Dr. Smith')).toBeInTheDocument();
expect(screen.getByText('Conference Room A')).toBeInTheDocument();
expect(screen.getByText('Equipment Bay')).toBeInTheDocument();
});
it('should apply correct height to each resource row', () => {
render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// The height style is on the resource row container (3 levels up from the text)
const drSmith = screen.getByText('Dr. Smith').closest('[style*="height"]');
const confRoom = screen.getByText('Conference Room A').closest('[style*="height"]');
expect(drSmith).toHaveStyle({ height: '100px' });
expect(confRoom).toHaveStyle({ height: '120px' });
});
it('should display "Resource" label for each resource', () => {
render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const resourceLabels = screen.getAllByText('Resource');
expect(resourceLabels.length).toBeGreaterThan(0);
});
it('should render grip icons for resources', () => {
const { container } = render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const gripIcons = container.querySelectorAll('svg');
expect(gripIcons.length).toBeGreaterThan(0);
});
it('should not render lane count badge for single-lane resources', () => {
render(
<Sidebar
resourceLayouts={[mockResourceLayouts[0]]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.queryByText(/lanes/)).not.toBeInTheDocument();
});
it('should render lane count badge for multi-lane resources', () => {
render(
<Sidebar
resourceLayouts={[mockResourceLayouts[1]]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('2 lanes')).toBeInTheDocument();
});
it('should render all multi-lane badges correctly', () => {
render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('2 lanes')).toBeInTheDocument();
expect(screen.getByText('3 lanes')).toBeInTheDocument();
});
it('should apply correct styling to multi-lane badges', () => {
render(
<Sidebar
resourceLayouts={[mockResourceLayouts[1]]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const badge = screen.getByText('2 lanes');
expect(badge).toHaveClass('text-blue-600', 'bg-blue-50');
});
it('should attach scroll ref to resource list container', () => {
const testRef = React.createRef<HTMLDivElement>();
render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={testRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(testRef.current).toBeInstanceOf(HTMLDivElement);
});
it('should render empty resources list when no resources provided', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.queryByText('Dr. Smith')).not.toBeInTheDocument();
});
});
describe('Pending Appointments List', () => {
it('should render all pending appointments', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
});
it('should display customer names correctly', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
mockPendingAppointments.forEach((apt) => {
expect(screen.getByText(apt.customerName)).toBeInTheDocument();
});
});
it('should display service names correctly', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('Consultation')).toBeInTheDocument();
expect(screen.getByText('Follow-up')).toBeInTheDocument();
expect(screen.getByText('Initial Assessment')).toBeInTheDocument();
});
it('should display duration in minutes for each appointment', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('30 min')).toBeInTheDocument();
expect(screen.getByText('15 min')).toBeInTheDocument();
expect(screen.getByText('60 min')).toBeInTheDocument();
});
it('should display clock icon for each appointment', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Clock icons are SVGs
const clockIcons = container.querySelectorAll('svg');
expect(clockIcons.length).toBeGreaterThan(0);
});
it('should display grip vertical icon for drag handle', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[mockPendingAppointments[0]]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Navigate up to the draggable container which has the svg
const appointment = screen.getByText('John Doe').closest('.cursor-grab');
const svg = appointment?.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('should show appointment count in header', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument();
});
it('should update count when appointments change', () => {
const { rerender } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument();
rerender(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[mockPendingAppointments[0]]}
scrollRef={mockScrollRef}
/>
);
expect(screen.getByText(/Pending Requests \(1\)/)).toBeInTheDocument();
});
});
describe('Empty State', () => {
it('should display empty message when no pending appointments', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('No pending requests')).toBeInTheDocument();
});
it('should show count of 0 in header when empty', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText(/Pending Requests \(0\)/)).toBeInTheDocument();
});
it('should apply italic styling to empty message', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const emptyMessage = screen.getByText('No pending requests');
expect(emptyMessage).toHaveClass('italic');
});
it('should not render appointment items when empty', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument();
});
});
describe('Drag and Drop Setup', () => {
it('should setup draggable for each pending appointment', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Each appointment should have drag cursor classes
const appointments = container.querySelectorAll('[class*="cursor-grab"]');
expect(appointments.length).toBe(mockPendingAppointments.length);
});
it('should apply cursor-grab class to draggable items', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[mockPendingAppointments[0]]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Use the specific class selector since .closest('div') returns the inner div
const appointmentCard = screen.getByText('John Doe').closest('.cursor-grab');
expect(appointmentCard).toBeInTheDocument();
});
it('should apply active cursor-grabbing class to draggable items', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[mockPendingAppointments[0]]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Verify the draggable container has the active:cursor-grabbing class
const appointmentCard = screen.getByText('John Doe').closest('[class*="active:cursor-grabbing"]');
expect(appointmentCard).toBeInTheDocument();
});
it('should render pending items with orange left border', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[mockPendingAppointments[0]]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Use the specific class selector
const appointmentCard = screen.getByText('John Doe').closest('.border-l-orange-400');
expect(appointmentCard).toBeInTheDocument();
});
it('should apply shadow on hover for draggable items', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[mockPendingAppointments[0]]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Use the specific class selector
const appointmentCard = screen.getByText('John Doe').closest('[class*="hover:shadow-md"]');
expect(appointmentCard).toBeInTheDocument();
});
});
describe('Archive Drop Zone', () => {
it('should render drop zone with trash icon', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const dropZone = screen.getByText('Drop here to archive').parentElement;
const trashIcon = dropZone?.querySelector('svg');
expect(trashIcon).toBeInTheDocument();
});
it('should apply dashed border to drop zone', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const dropZone = screen.getByText('Drop here to archive').parentElement;
expect(dropZone).toHaveClass('border-dashed');
});
it('should apply opacity to drop zone container', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const dropZoneContainer = screen
.getByText('Drop here to archive')
.closest('.opacity-50');
expect(dropZoneContainer).toBeInTheDocument();
});
});
describe('Layout and Styling', () => {
it('should apply fixed height to resources header', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// The height style is on the header div itself
const header = screen.getByText('Resources').closest('[style*="height"]');
expect(header).toHaveStyle({ height: '48px' });
});
it('should apply fixed height to pending requests section', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const pendingSection = screen
.getByText(/Pending Requests/)
.closest('.h-80');
expect(pendingSection).toBeInTheDocument();
});
it('should have overflow-hidden on resource list', () => {
const { container } = render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const resourceList = container.querySelector('.overflow-hidden');
expect(resourceList).toBeInTheDocument();
});
it('should have overflow-y-auto on pending appointments list', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const pendingList = container.querySelector('.overflow-y-auto');
expect(pendingList).toBeInTheDocument();
});
it('should apply border-right to sidebar', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass('border-r');
});
it('should apply shadow to sidebar', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass('shadow-lg');
});
it('should have dark mode classes', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass('dark:bg-gray-800');
expect(sidebar).toHaveClass('dark:border-gray-700');
});
});
describe('Internationalization', () => {
it('should use translation for resources header', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('Resources')).toBeInTheDocument();
});
it('should use translation for pending requests header', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText(/Pending Requests/)).toBeInTheDocument();
});
it('should use translation for empty state message', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('No pending requests')).toBeInTheDocument();
});
it('should use translation for drop zone text', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('Drop here to archive')).toBeInTheDocument();
});
it('should use translation for duration units', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[mockPendingAppointments[0]]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('30 min')).toBeInTheDocument();
});
it('should use translation for resource label', () => {
render(
<Sidebar
resourceLayouts={[mockResourceLayouts[0]]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('Resource')).toBeInTheDocument();
});
it('should use translation for lanes label', () => {
render(
<Sidebar
resourceLayouts={[mockResourceLayouts[1]]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('2 lanes')).toBeInTheDocument();
});
});
describe('Integration', () => {
it('should render correctly with all props together', () => {
render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Verify resources
expect(screen.getByText('Dr. Smith')).toBeInTheDocument();
expect(screen.getByText('Conference Room A')).toBeInTheDocument();
expect(screen.getByText('Equipment Bay')).toBeInTheDocument();
// Verify pending appointments
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
// Verify count
expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument();
// Verify archive drop zone
expect(screen.getByText('Drop here to archive')).toBeInTheDocument();
});
it('should handle empty resources with full pending appointments', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.queryByText('Dr. Smith')).not.toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument();
});
it('should handle full resources with empty pending appointments', () => {
render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('Dr. Smith')).toBeInTheDocument();
expect(screen.getByText('No pending requests')).toBeInTheDocument();
expect(screen.getByText(/Pending Requests \(0\)/)).toBeInTheDocument();
});
it('should maintain structure with resources and pending sections', () => {
const { container } = render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const sidebar = container.firstChild as HTMLElement;
// Should have header, resources list, and pending section
const sections = sidebar.querySelectorAll(
'.border-b, .border-t, .flex-col'
);
expect(sections.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,750 @@
/**
* Comprehensive unit tests for Timeline component
*
* Tests cover:
* - Component rendering
* - Time slots display for different view modes (day, week, month)
* - Resource rows display with proper heights
* - Events positioned correctly on timeline
* - Current time indicator visibility and position
* - Date navigation controls
* - View mode switching
* - Zoom functionality
* - Drag and drop interactions
* - Scroll synchronization between sidebar and timeline
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Timeline from '../Timeline';
import * as apiClient from '../../../api/client';
// Mock modules
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback || key,
}),
}));
vi.mock('../../../api/client', () => ({
default: {
get: vi.fn(),
},
}));
// Mock DnD Kit - simplified for testing
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
useSensor: vi.fn(),
useSensors: vi.fn(() => []),
PointerSensor: vi.fn(),
useDroppable: vi.fn(() => ({
setNodeRef: vi.fn(),
isOver: false,
})),
useDraggable: vi.fn(() => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
isDragging: false,
})),
DragOverlay: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
// Mock child components
vi.mock('../../Timeline/TimelineRow', () => ({
default: ({ resourceId, events, height }: any) => (
<div
data-testid={`timeline-row-${resourceId}`}
data-event-count={events.length}
style={{ height }}
>
{events.map((event: any) => (
<div key={event.id} data-testid={`event-${event.id}`}>
{event.title}
</div>
))}
</div>
),
}));
vi.mock('../../Timeline/CurrentTimeIndicator', () => ({
default: ({ startTime, hourWidth }: any) => (
<div
id="current-time-indicator"
data-testid="current-time-indicator"
data-start-time={startTime.toISOString()}
data-hour-width={hourWidth}
/>
),
}));
vi.mock('../Sidebar', () => ({
default: ({ resourceLayouts, pendingAppointments }: any) => (
<div data-testid="sidebar">
<div data-testid="resource-count">{resourceLayouts.length}</div>
<div data-testid="pending-count">{pendingAppointments.length}</div>
</div>
),
}));
// Test data
const mockResources = [
{ id: 1, name: 'Resource 1', type: 'STAFF' },
{ id: 2, name: 'Resource 2', type: 'ROOM' },
{ id: 3, name: 'Resource 3', type: 'EQUIPMENT' },
];
const mockAppointments = [
{
id: 1,
resource: 1,
customer: 101,
service: 201,
customer_name: 'John Doe',
service_name: 'Haircut',
start_time: new Date('2025-12-07T10:00:00').toISOString(),
end_time: new Date('2025-12-07T11:00:00').toISOString(),
status: 'CONFIRMED' as const,
is_paid: false,
},
{
id: 2,
resource: 1,
customer: 102,
service: 202,
customer_name: 'Jane Smith',
service_name: 'Coloring',
start_time: new Date('2025-12-07T11:30:00').toISOString(),
end_time: new Date('2025-12-07T13:00:00').toISOString(),
status: 'CONFIRMED' as const,
is_paid: true,
},
{
id: 3,
resource: undefined, // Pending appointment - no resource assigned
customer: 103,
service: 203,
customer_name: 'Bob Johnson',
service_name: 'Massage',
start_time: new Date('2025-12-07T14:00:00').toISOString(),
end_time: new Date('2025-12-07T15:00:00').toISOString(),
status: 'PENDING' as const,
is_paid: false,
},
];
// Test wrapper with Query Client
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('Timeline Component', () => {
let mockGet: any;
beforeEach(() => {
vi.clearAllMocks();
mockGet = vi.mocked(apiClient.default.get);
// Default API responses
mockGet.mockImplementation((url: string) => {
if (url === '/resources/') {
return Promise.resolve({ data: mockResources });
}
if (url === '/appointments/') {
return Promise.resolve({ data: mockAppointments });
}
return Promise.reject(new Error('Unknown endpoint'));
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Component Rendering', () => {
it('should render the timeline component', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
});
});
it('should display header bar with controls', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTitle('Previous')).toBeInTheDocument();
expect(screen.getByTitle('Next')).toBeInTheDocument();
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
});
});
it('should fetch resources from API', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(mockGet).toHaveBeenCalledWith('/resources/');
});
});
it('should fetch appointments from API', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(mockGet).toHaveBeenCalledWith('/appointments/');
});
});
});
describe('Time Slots Rendering', () => {
it('should render 24 hour slots in day view', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Check for some time labels
expect(screen.getByText('12 AM')).toBeInTheDocument();
expect(screen.getByText('6 AM')).toBeInTheDocument();
expect(screen.getByText('12 PM')).toBeInTheDocument();
expect(screen.getByText('6 PM')).toBeInTheDocument();
});
});
it('should render all 24 hours with correct spacing in day view', async () => {
const { container } = render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const headerRow = container.querySelector('.sticky.top-0');
expect(headerRow).toBeInTheDocument();
// Should have 24 time slots
const timeSlots = headerRow?.querySelectorAll('[style*="width"]');
expect(timeSlots?.length).toBeGreaterThan(0);
});
});
it('should render day headers in week view', async () => {
const user = userEvent.setup();
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('day')).toBeInTheDocument();
});
const weekButton = screen.getByRole('button', { name: /week/i });
await user.click(weekButton);
await waitFor(() => {
// Week view should show day names
const container = screen.getByRole('button', { name: /week/i }).closest('div')?.parentElement?.parentElement?.parentElement;
expect(container).toBeInTheDocument();
});
});
it('should display date range label for current view', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Should show day view date format
const dateLabel = screen.getByText(/December/i);
expect(dateLabel).toBeInTheDocument();
});
});
});
describe('Resource Rows Display', () => {
it('should render resource rows for all resources', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTestId('timeline-row-1')).toBeInTheDocument();
expect(screen.getByTestId('timeline-row-2')).toBeInTheDocument();
expect(screen.getByTestId('timeline-row-3')).toBeInTheDocument();
});
});
it('should display correct number of resources in sidebar', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const resourceCount = screen.getByTestId('resource-count');
expect(resourceCount).toHaveTextContent('3');
});
});
it('should calculate row heights based on event lanes', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const row1 = screen.getByTestId('timeline-row-1');
// Row 1 has 2 events, should have calculated height
expect(row1).toHaveAttribute('style');
});
});
it('should handle resources with no events', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/resources/') {
return Promise.resolve({ data: mockResources });
}
if (url === '/appointments/') {
return Promise.resolve({ data: [] });
}
return Promise.reject(new Error('Unknown endpoint'));
});
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTestId('timeline-row-1')).toBeInTheDocument();
expect(screen.getByTestId('timeline-row-1')).toHaveAttribute('data-event-count', '0');
});
});
});
describe('Events Positioning', () => {
it('should render events on their assigned resources', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const row1 = screen.getByTestId('timeline-row-1');
expect(row1).toHaveAttribute('data-event-count', '2');
});
});
it('should display event titles correctly', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
});
it('should filter events by resource', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const row1 = screen.getByTestId('timeline-row-1');
const row2 = screen.getByTestId('timeline-row-2');
expect(row1).toHaveAttribute('data-event-count', '2');
expect(row2).toHaveAttribute('data-event-count', '0');
});
});
it('should handle overlapping events with lane calculation', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Both events are on resource 1, should be in timeline
expect(screen.getByTestId('event-1')).toBeInTheDocument();
expect(screen.getByTestId('event-2')).toBeInTheDocument();
});
});
});
describe('Current Time Indicator', () => {
it('should render current time indicator', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTestId('current-time-indicator')).toBeInTheDocument();
});
});
it('should pass correct props to current time indicator', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const indicator = screen.getByTestId('current-time-indicator');
expect(indicator).toHaveAttribute('data-start-time');
expect(indicator).toHaveAttribute('data-hour-width');
});
});
it('should have correct id for auto-scroll', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const indicator = screen.getByTestId('current-time-indicator');
expect(indicator).toHaveAttribute('id', 'current-time-indicator');
});
});
});
describe('Date Navigation', () => {
it('should have previous and next navigation buttons', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTitle('Previous')).toBeInTheDocument();
expect(screen.getByTitle('Next')).toBeInTheDocument();
});
});
it('should navigate to previous day when clicking previous button', async () => {
const user = userEvent.setup();
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTitle('Previous')).toBeInTheDocument();
});
const previousButton = screen.getByTitle('Previous');
await user.click(previousButton);
// Date should change (we can't easily test exact date without exposing state)
expect(previousButton).toBeInTheDocument();
});
it('should navigate to next day when clicking next button', async () => {
const user = userEvent.setup();
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTitle('Next')).toBeInTheDocument();
});
const nextButton = screen.getByTitle('Next');
await user.click(nextButton);
expect(nextButton).toBeInTheDocument();
});
it('should display current date range', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Should show a date with calendar icon
const dateDisplay = screen.getByText(/2025/);
expect(dateDisplay).toBeInTheDocument();
});
});
});
describe('View Mode Switching', () => {
it('should render view mode buttons (day, week, month)', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByRole('button', { name: /day/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /month/i })).toBeInTheDocument();
});
});
it('should highlight active view mode (day by default)', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const dayButton = screen.getByRole('button', { name: /day/i });
expect(dayButton).toHaveClass('bg-blue-500');
});
});
it('should switch to week view when clicking week button', async () => {
const user = userEvent.setup();
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
});
const weekButton = screen.getByRole('button', { name: /week/i });
await user.click(weekButton);
await waitFor(() => {
expect(weekButton).toHaveClass('bg-blue-500');
});
});
it('should switch to month view when clicking month button', async () => {
const user = userEvent.setup();
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByRole('button', { name: /month/i })).toBeInTheDocument();
});
const monthButton = screen.getByRole('button', { name: /month/i });
await user.click(monthButton);
await waitFor(() => {
expect(monthButton).toHaveClass('bg-blue-500');
});
});
it('should only have one active view mode at a time', async () => {
const user = userEvent.setup();
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
});
const weekButton = screen.getByRole('button', { name: /week/i });
await user.click(weekButton);
await waitFor(() => {
const dayButton = screen.getByRole('button', { name: /day/i });
expect(weekButton).toHaveClass('bg-blue-500');
expect(dayButton).not.toHaveClass('bg-blue-500');
});
});
});
describe('Zoom Functionality', () => {
it('should render zoom in and zoom out buttons', async () => {
const { container } = render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Look for Zoom label and buttons
expect(screen.getByText('Zoom')).toBeInTheDocument();
});
// Zoom buttons are rendered via Lucide icons
const zoomSection = screen.getByText('Zoom').parentElement;
expect(zoomSection).toBeInTheDocument();
});
it('should increase zoom when clicking zoom in button', async () => {
const user = userEvent.setup();
const { container } = render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Zoom')).toBeInTheDocument();
});
// Find zoom in button (second button after Zoom label)
const zoomSection = screen.getByText('Zoom').parentElement;
const buttons = zoomSection?.querySelectorAll('button');
const zoomInButton = buttons?.[1];
if (zoomInButton) {
await user.click(zoomInButton);
// Component should still be rendered
expect(screen.getByText('Zoom')).toBeInTheDocument();
}
});
it('should decrease zoom when clicking zoom out button', async () => {
const user = userEvent.setup();
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Zoom')).toBeInTheDocument();
});
const zoomSection = screen.getByText('Zoom').parentElement;
const buttons = zoomSection?.querySelectorAll('button');
const zoomOutButton = buttons?.[0];
if (zoomOutButton) {
await user.click(zoomOutButton);
expect(screen.getByText('Zoom')).toBeInTheDocument();
}
});
});
describe('Pending Appointments', () => {
it('should display pending appointments in sidebar', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const pendingCount = screen.getByTestId('pending-count');
expect(pendingCount).toHaveTextContent('1');
});
});
it('should filter pending appointments from events', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Should not render pending appointment as event
expect(screen.queryByText('Bob Johnson')).not.toBeInTheDocument();
});
});
});
describe('Accessibility', () => {
it('should have accessible button labels', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByRole('button', { name: /day/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /month/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /new appointment/i })).toBeInTheDocument();
});
});
it('should have title attributes on navigation buttons', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTitle('Previous')).toBeInTheDocument();
expect(screen.getByTitle('Next')).toBeInTheDocument();
});
});
});
describe('Undo/Redo Controls', () => {
it('should render undo and redo buttons', async () => {
const { container } = render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Undo/redo buttons exist but are disabled
const buttons = container.querySelectorAll('button[disabled]');
expect(buttons.length).toBeGreaterThan(0);
});
});
it('should have undo and redo buttons disabled by default', async () => {
const { container } = render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const disabledButtons = container.querySelectorAll('button[disabled]');
expect(disabledButtons.length).toBeGreaterThanOrEqual(2);
});
});
});
describe('Error Handling', () => {
it('should handle API errors gracefully for resources', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/resources/') {
return Promise.reject(new Error('Network error'));
}
if (url === '/appointments/') {
return Promise.resolve({ data: [] });
}
return Promise.reject(new Error('Unknown endpoint'));
});
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Should still render even with error
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
});
});
it('should handle API errors gracefully for appointments', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/resources/') {
return Promise.resolve({ data: mockResources });
}
if (url === '/appointments/') {
return Promise.reject(new Error('Network error'));
}
return Promise.reject(new Error('Unknown endpoint'));
});
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
});
});
it('should handle empty resources array', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/resources/') {
return Promise.resolve({ data: [] });
}
if (url === '/appointments/') {
return Promise.resolve({ data: [] });
}
return Promise.reject(new Error('Unknown endpoint'));
});
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const resourceCount = screen.getByTestId('resource-count');
expect(resourceCount).toHaveTextContent('0');
});
});
it('should handle empty appointments array', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/resources/') {
return Promise.resolve({ data: mockResources });
}
if (url === '/appointments/') {
return Promise.resolve({ data: [] });
}
return Promise.reject(new Error('Unknown endpoint'));
});
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const pendingCount = screen.getByTestId('pending-count');
expect(pendingCount).toHaveTextContent('0');
});
});
});
describe('Dark Mode Support', () => {
it('should apply dark mode classes', async () => {
const { container } = render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const mainContainer = container.querySelector('.bg-white');
expect(mainContainer).toHaveClass('dark:bg-gray-900');
});
});
it('should apply dark mode to header', async () => {
const { container } = render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const header = container.querySelector('.border-b');
expect(header).toHaveClass('dark:bg-gray-800');
});
});
});
describe('Integration', () => {
it('should render complete timeline with all features', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Header controls
expect(screen.getByTitle('Previous')).toBeInTheDocument();
expect(screen.getByTitle('Next')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /day/i })).toBeInTheDocument();
expect(screen.getByText('Zoom')).toBeInTheDocument();
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
// Sidebar
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
// Current time indicator
expect(screen.getByTestId('current-time-indicator')).toBeInTheDocument();
// Resources
expect(screen.getByTestId('resource-count')).toHaveTextContent('3');
// Events
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
});
});

View File

@@ -1,18 +1,20 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import './ServiceList.css';
const ServiceList = ({ services, onSelectService, loading }) => {
const { t } = useTranslation();
if (loading) {
return <div className="service-list-loading">Loading services...</div>;
return <div className="service-list-loading">{t('services.loadingServices')}</div>;
}
if (!services || services.length === 0) {
return <div className="service-list-empty">No services available</div>;
return <div className="service-list-empty">{t('services.noServicesAvailable')}</div>;
}
return (
<div className="service-list">
<h2>Available Services</h2>
<h2>{t('services.availableServices')}</h2>
<div className="service-grid">
{services.map((service) => (
<div
@@ -28,7 +30,7 @@ const ServiceList = ({ services, onSelectService, loading }) => {
{service.description && (
<p className="service-description">{service.description}</p>
)}
<button className="service-book-btn">Book Now</button>
<button className="service-book-btn">{t('services.bookNow')}</button>
</div>
))}
</div>

View File

@@ -15,11 +15,17 @@ import {
HelpCircle,
Clock,
Plug,
FileSignature,
CalendarOff,
LayoutTemplate,
MapPin,
Image,
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
import { usePlanFeatures } from '../hooks/usePlanFeatures';
import SmoothScheduleLogo from './SmoothScheduleLogo';
import UnfinishedBadge from './ui/UnfinishedBadge';
import {
SidebarSection,
SidebarItem,
@@ -40,9 +46,11 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
const { canUse } = usePlanFeatures();
const canViewAdminPages = role === 'owner' || role === 'manager';
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
const canViewManagementPages = role === 'owner' || role === 'manager';
const isStaff = role === 'staff';
const canViewSettings = role === 'owner';
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
const canSendMessages = user.can_send_messages === true;
const handleSignOut = () => {
logoutMutation.mutate();
@@ -59,7 +67,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
<button
onClick={toggleCollapse}
className={`flex items-center gap-3 w-full text-left px-6 py-6 ${isCollapsed ? 'justify-center' : ''} hover:bg-white/5 transition-colors focus:outline-none`}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-label={isCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar')}
>
{business.logoDisplayMode === 'logo-only' && business.logoUrl ? (
<div className="flex items-center justify-center w-full">
@@ -102,65 +110,125 @@ 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}
exact
/>
<SidebarItem
to="/scheduler"
icon={CalendarDays}
label={t('nav.scheduler')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/tasks"
icon={Clock}
label={t('nav.tasks', 'Tasks')}
isCollapsed={isCollapsed}
locked={!canUse('plugins') || !canUse('tasks')}
/>
{!isStaff && (
<SidebarItem
to="/dashboard/scheduler"
icon={CalendarDays}
label={t('nav.scheduler')}
isCollapsed={isCollapsed}
/>
)}
{!isStaff && (
<SidebarItem
to="/dashboard/tasks"
icon={Clock}
label={t('nav.tasks', 'Tasks')}
isCollapsed={isCollapsed}
locked={!canUse('plugins') || !canUse('tasks')}
badgeElement={<UnfinishedBadge />}
/>
)}
{isStaff && (
<SidebarItem
to="/dashboard/my-schedule"
icon={CalendarDays}
label={t('nav.mySchedule', 'My Schedule')}
isCollapsed={isCollapsed}
/>
)}
{(role === 'staff' || role === 'resource') && (
<SidebarItem
to="/dashboard/my-availability"
icon={CalendarOff}
label={t('nav.myAvailability', 'My Availability')}
isCollapsed={isCollapsed}
/>
)}
</SidebarSection>
{/* Manage Section - Staff+ */}
{canViewManagementPages && (
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
<SidebarItem
to="/customers"
icon={Users}
label={t('nav.customers')}
to="/dashboard/site-editor"
icon={LayoutTemplate}
label={t('nav.siteBuilder', 'Site Builder')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
<SidebarItem
to="/dashboard/gallery"
icon={Image}
label={t('nav.gallery', 'Media Gallery')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/services"
to="/dashboard/customers"
icon={Users}
label={t('nav.customers')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
<SidebarItem
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}
/>
{canViewAdminPages && (
<SidebarItem
to="/staff"
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
/>
<>
<SidebarItem
to="/dashboard/staff"
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
{canUse('contracts') && (
<SidebarItem
to="/dashboard/contracts"
icon={FileSignature}
label={t('nav.contracts', 'Contracts')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
)}
<SidebarItem
to="/dashboard/time-blocks"
icon={CalendarOff}
label={t('nav.timeBlocks', 'Time Blocks')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/dashboard/locations"
icon={MapPin}
label={t('nav.locations', 'Locations')}
isCollapsed={isCollapsed}
locked={!canUse('multi_location')}
/>
</>
)}
</SidebarSection>
)}
{/* Communicate Section - Tickets + Messages */}
{(canViewTickets || canViewAdminPages) && (
{(canViewTickets || canSendMessages) && (
<SidebarSection title={t('nav.sections.communicate', 'Communicate')} isCollapsed={isCollapsed}>
{canViewAdminPages && (
{canSendMessages && (
<SidebarItem
to="/messages"
to="/dashboard/messages"
icon={MessageSquare}
label={t('nav.messages')}
isCollapsed={isCollapsed}
@@ -168,7 +236,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}
@@ -181,7 +249,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}
@@ -194,11 +262,12 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{canViewAdminPages && (
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
<SidebarItem
to="/plugins/my-plugins"
to="/dashboard/plugins/my-plugins"
icon={Plug}
label={t('nav.plugins', 'Plugins')}
isCollapsed={isCollapsed}
locked={!canUse('plugins')}
badgeElement={<UnfinishedBadge />}
/>
</SidebarSection>
)}
@@ -209,14 +278,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}
@@ -234,7 +303,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
>
<SmoothScheduleLogo className="w-5 h-5 text-white" />
{!isCollapsed && (
<span className="text-white/60">Smooth Schedule</span>
<span className="text-white/60">{t('nav.smoothSchedule')}</span>
)}
</a>
<button

View File

@@ -68,6 +68,15 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
defaultValue: false,
roles: ['manager'],
},
{
key: 'can_send_messages',
labelKey: 'staff.canSendMessages',
labelDefault: 'Can send broadcast messages',
hintKey: 'staff.canSendMessagesHint',
hintDefault: 'Send messages to groups of staff and customers',
defaultValue: true,
roles: ['manager'],
},
// Staff-only permissions
{
key: 'can_view_all_schedules',
@@ -87,6 +96,15 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
defaultValue: true,
roles: ['staff'],
},
{
key: 'can_self_approve_time_off',
labelKey: 'staff.canSelfApproveTimeOff',
labelDefault: 'Can self-approve time off',
hintKey: 'staff.canSelfApproveTimeOffHint',
hintDefault: 'Add time off without requiring manager/owner approval',
defaultValue: false,
roles: ['staff'],
},
// Shared permissions (both manager and staff)
{
key: 'can_access_tickets',

View File

@@ -4,6 +4,7 @@
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Key,
Eye,
@@ -30,6 +31,7 @@ interface StripeApiKeysFormProps {
}
const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSuccess }) => {
const { t } = useTranslation();
const [secretKey, setSecretKey] = useState('');
const [publishableKey, setPublishableKey] = useState('');
const [showSecretKey, setShowSecretKey] = useState(false);
@@ -72,7 +74,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
} catch (error: any) {
setValidationResult({
valid: false,
error: error.response?.data?.error || 'Validation failed',
error: error.response?.data?.error || t('payments.stripeApiKeys.validationFailed'),
});
}
};
@@ -87,7 +89,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
} catch (error: any) {
setValidationResult({
valid: false,
error: error.response?.data?.error || 'Failed to save keys',
error: error.response?.data?.error || t('payments.stripeApiKeys.failedToSaveKeys'),
});
}
};
@@ -121,7 +123,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
<CheckCircle size={18} className="text-green-500" />
Stripe Keys Configured
{t('payments.stripeApiKeys.configured')}
</h4>
<div className="flex items-center gap-2">
{/* Environment Badge */}
@@ -136,12 +138,12 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
{keyEnvironment === 'test' ? (
<>
<FlaskConical size={12} />
Test Mode
{t('payments.stripeApiKeys.testMode')}
</>
) : (
<>
<Zap size={12} />
Live Mode
{t('payments.stripeApiKeys.liveMode')}
</>
)}
</span>
@@ -163,22 +165,22 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Publishable Key:</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.stripeApiKeys.publishableKey')}:</span>
<code className="font-mono text-gray-900 dark:text-white">{apiKeys.publishable_key_masked}</code>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Secret Key:</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.stripeApiKeys.secretKey')}:</span>
<code className="font-mono text-gray-900 dark:text-white">{apiKeys.secret_key_masked}</code>
</div>
{apiKeys.stripe_account_name && (
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Account:</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.stripeApiKeys.account')}:</span>
<span className="text-gray-900 dark:text-white">{apiKeys.stripe_account_name}</span>
</div>
)}
{apiKeys.last_validated_at && (
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Last Validated:</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.stripeApiKeys.lastValidated')}:</span>
<span className="text-gray-900 dark:text-white">
{new Date(apiKeys.last_validated_at).toLocaleDateString()}
</span>
@@ -190,10 +192,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
{keyEnvironment === 'test' && apiKeys.status === 'active' && (
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2">
<FlaskConical size={16} className="shrink-0 mt-0.5" />
<span>
You are using <strong>test keys</strong>. Payments will not be processed for real.
Switch to live keys when ready to accept real payments.
</span>
<span dangerouslySetInnerHTML={{ __html: t('payments.stripeApiKeys.testKeysWarning') }} />
</div>
)}
@@ -214,14 +213,14 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
) : (
<RefreshCw size={16} />
)}
Re-validate
{t('payments.stripeApiKeys.revalidate')}
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-red-700 bg-white border border-red-300 rounded-lg hover:bg-red-50"
>
<Trash2 size={16} />
Remove
{t('payments.stripeApiKeys.remove')}
</button>
</div>
</div>
@@ -233,10 +232,9 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
<div className="flex items-start gap-3">
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
<div>
<h4 className="font-medium text-yellow-800">API Keys Deprecated</h4>
<h4 className="font-medium text-yellow-800">{t('payments.stripeApiKeys.deprecated')}</h4>
<p className="text-sm text-yellow-700 mt-1">
Your API keys have been deprecated because you upgraded to a paid tier.
Please complete Stripe Connect onboarding to accept payments.
{t('payments.stripeApiKeys.deprecatedMessage')}
</p>
</div>
</div>
@@ -247,19 +245,18 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
{(!isConfigured || isDeprecated) && (
<div className="space-y-4">
<h4 className="font-medium text-gray-900">
{isConfigured ? 'Update API Keys' : 'Add Stripe API Keys'}
{isConfigured ? t('payments.stripeApiKeys.updateApiKeys') : t('payments.stripeApiKeys.addApiKeys')}
</h4>
<p className="text-sm text-gray-600">
Enter your Stripe API keys to enable payment collection.
You can find these in your{' '}
{t('payments.stripeApiKeys.enterKeysDescription')}{' '}
<a
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Stripe Dashboard
{t('payments.stripeApiKeys.stripeDashboard')}
</a>
.
</p>
@@ -267,7 +264,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
{/* Publishable Key */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Publishable Key
{t('payments.stripeApiKeys.publishableKeyLabel')}
</label>
<div className="relative">
<Key
@@ -290,7 +287,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
{/* Secret Key */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Secret Key
{t('payments.stripeApiKeys.secretKeyLabel')}
</label>
<div className="relative">
<Key
@@ -335,7 +332,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
{validationResult.valid ? (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="font-medium">Keys are valid!</span>
<span className="font-medium">{t('payments.stripeApiKeys.keysAreValid')}</span>
{validationResult.environment && (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${
@@ -347,23 +344,23 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
{validationResult.environment === 'test' ? (
<>
<FlaskConical size={10} />
Test Mode
{t('payments.stripeApiKeys.testMode')}
</>
) : (
<>
<Zap size={10} />
Live Mode
{t('payments.stripeApiKeys.liveMode')}
</>
)}
</span>
)}
</div>
{validationResult.accountName && (
<div>Connected to: {validationResult.accountName}</div>
<div>{t('payments.stripeApiKeys.connectedTo', { accountName: validationResult.accountName })}</div>
)}
{validationResult.environment === 'test' && (
<div className="text-amber-700 dark:text-amber-400 text-xs mt-1">
These are test keys. No real payments will be processed.
{t('payments.stripeApiKeys.testKeysNote')}
</div>
)}
</div>
@@ -386,7 +383,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
) : (
<CheckCircle size={16} />
)}
Validate
{t('payments.stripeApiKeys.validate')}
</button>
<button
onClick={handleSave}
@@ -398,7 +395,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
) : (
<Key size={16} />
)}
Save Keys
{t('payments.stripeApiKeys.saveKeys')}
</button>
</div>
</div>
@@ -409,18 +406,17 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Remove API Keys?
{t('payments.stripeApiKeys.removeApiKeys')}
</h3>
<p className="text-gray-600 mb-4">
Are you sure you want to remove your Stripe API keys?
You will not be able to accept payments until you add them again.
{t('payments.stripeApiKeys.removeApiKeysMessage')}
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
{t('payments.stripeApiKeys.cancel')}
</button>
<button
onClick={handleDelete}
@@ -428,7 +424,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
>
{deleteMutation.isPending && <Loader2 size={16} className="animate-spin" />}
Remove
{t('payments.stripeApiKeys.remove')}
</button>
</div>
</div>

View File

@@ -6,6 +6,7 @@
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
X,
CreditCard,
@@ -37,6 +38,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
transactionId,
onClose,
}) => {
const { t } = useTranslation();
const { data: transaction, isLoading, error } = useTransactionDetail(transactionId);
const refundMutation = useRefundTransaction();
@@ -62,11 +64,11 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
if (refundType === 'partial') {
const amountCents = Math.round(parseFloat(refundAmount) * 100);
if (isNaN(amountCents) || amountCents <= 0) {
setRefundError('Please enter a valid refund amount');
setRefundError(t('payments.enterValidRefundAmount'));
return;
}
if (amountCents > transaction.refundable_amount) {
setRefundError(`Amount exceeds refundable amount ($${(transaction.refundable_amount / 100).toFixed(2)})`);
setRefundError(t('payments.amountExceedsRefundable', { max: (transaction.refundable_amount / 100).toFixed(2) }));
return;
}
request.amount = amountCents;
@@ -80,7 +82,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
setShowRefundForm(false);
setRefundAmount('');
} catch (err: any) {
setRefundError(err.response?.data?.error || 'Failed to process refund');
setRefundError(err.response?.data?.error || t('payments.failedToProcessRefund'));
}
};
@@ -143,7 +145,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
</p>
{pm.exp_month && pm.exp_year && (
<p className="text-sm text-gray-500">
Expires {pm.exp_month}/{pm.exp_year}
{t('payments.expires')} {pm.exp_month}/{pm.exp_year}
{pm.funding && ` (${pm.funding})`}
</p>
)}
@@ -176,7 +178,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Transaction Details
{t('payments.transactionDetails')}
</h3>
{transaction && (
<p className="text-sm text-gray-500 font-mono">
@@ -204,7 +206,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center gap-2 text-red-700">
<AlertCircle size={18} />
<p className="font-medium">Failed to load transaction details</p>
<p className="font-medium">{t('payments.failedToLoadTransaction')}</p>
</div>
</div>
)}
@@ -228,7 +230,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
<RefreshCcw size={16} />
Issue Refund
{t('payments.issueRefund')}
</button>
)}
</div>
@@ -238,7 +240,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<div className="bg-red-50 border border-red-200 rounded-lg p-4 space-y-4">
<div className="flex items-center gap-2 text-red-800">
<RefreshCcw size={18} />
<h4 className="font-semibold">Issue Refund</h4>
<h4 className="font-semibold">{t('payments.issueRefund')}</h4>
</div>
{/* Refund Type */}
@@ -252,7 +254,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
className="text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">
Full refund (${(transaction.refundable_amount / 100).toFixed(2)})
{t('payments.fullRefundAmount', { amount: (transaction.refundable_amount / 100).toFixed(2) })}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
@@ -263,7 +265,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
onChange={() => setRefundType('partial')}
className="text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">Partial refund</span>
<span className="text-sm text-gray-700">{t('payments.partialRefund')}</span>
</label>
</div>
@@ -271,7 +273,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
{refundType === 'partial' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Refund Amount (max ${(transaction.refundable_amount / 100).toFixed(2)})
{t('payments.refundAmountMax', { max: (transaction.refundable_amount / 100).toFixed(2) })}
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
@@ -292,16 +294,16 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
{/* Reason */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Refund Reason
{t('payments.refundReason')}
</label>
<select
value={refundReason}
onChange={(e) => setRefundReason(e.target.value as RefundRequest['reason'])}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
>
<option value="requested_by_customer">Requested by customer</option>
<option value="duplicate">Duplicate charge</option>
<option value="fraudulent">Fraudulent</option>
<option value="requested_by_customer">{t('payments.requestedByCustomer')}</option>
<option value="duplicate">{t('payments.duplicate')}</option>
<option value="fraudulent">{t('payments.fraudulent')}</option>
</select>
</div>
@@ -322,12 +324,12 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
{refundMutation.isPending ? (
<>
<Loader2 className="animate-spin" size={16} />
Processing...
{t('payments.processing')}
</>
) : (
<>
<RefreshCcw size={16} />
Confirm Refund
{t('payments.processRefund')}
</>
)}
</button>
@@ -340,7 +342,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
disabled={refundMutation.isPending}
className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-lg"
>
Cancel
{t('common.cancel')}
</button>
</div>
</div>
@@ -352,7 +354,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<User size={16} />
Customer
{t('payments.customer')}
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
{transaction.customer_name && (
@@ -378,27 +380,27 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<DollarSign size={16} />
Amount Breakdown
{t('payments.amountBreakdown')}
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Gross Amount</span>
<span className="text-gray-600">{t('payments.grossAmount')}</span>
<span className="font-medium">{transaction.amount_display}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Platform Fee</span>
<span className="text-gray-600">{t('payments.platformFee')}</span>
<span className="text-red-600">-{transaction.fee_display}</span>
</div>
{transaction.total_refunded > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-600">Refunded</span>
<span className="text-gray-600">{t('payments.refunded')}</span>
<span className="text-orange-600">
-${(transaction.total_refunded / 100).toFixed(2)}
</span>
</div>
)}
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 mt-2 flex justify-between">
<span className="font-medium text-gray-900 dark:text-white">Net Amount</span>
<span className="font-medium text-gray-900 dark:text-white">{t('payments.netAmount')}</span>
<span className="font-bold text-green-600">
${(transaction.net_amount / 100).toFixed(2)}
</span>
@@ -412,7 +414,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<CreditCard size={16} />
Payment Method
{t('payments.paymentMethod')}
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
{getPaymentMethodDisplay()}
@@ -425,7 +427,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Receipt size={16} />
Description
{t('payments.description')}
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<p className="text-gray-700 dark:text-gray-300">{transaction.description}</p>
@@ -438,7 +440,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<RefreshCcw size={16} />
Refund History
{t('payments.refundHistory')}
</h4>
<div className="space-y-3">
{transaction.refunds.map((refund: RefundInfo) => (
@@ -451,7 +453,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<p className="text-sm text-orange-600">
{refund.reason
? refund.reason.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())
: 'No reason provided'}
: t('payments.noReasonProvided')}
</p>
<p className="text-xs text-orange-500 mt-1">
{formatRefundDate(refund.created)}
@@ -482,12 +484,12 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Calendar size={16} />
Timeline
{t('payments.timeline')}
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-3 text-sm">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-gray-600">Created</span>
<span className="text-gray-600">{t('payments.created')}</span>
<span className="ml-auto text-gray-900 dark:text-white">
{formatDate(transaction.created_at)}
</span>
@@ -495,7 +497,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
{transaction.updated_at !== transaction.created_at && (
<div className="flex items-center gap-3 text-sm">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="text-gray-600">Last Updated</span>
<span className="text-gray-600">{t('payments.lastUpdated')}</span>
<span className="ml-auto text-gray-900 dark:text-white">
{formatDate(transaction.updated_at)}
</span>
@@ -508,29 +510,29 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<ArrowLeftRight size={16} />
Technical Details
{t('payments.technicalDetails')}
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2 font-mono text-xs">
<div className="flex justify-between">
<span className="text-gray-500">Payment Intent</span>
<span className="text-gray-500">{t('payments.paymentIntent')}</span>
<span className="text-gray-700 dark:text-gray-300">
{transaction.stripe_payment_intent_id}
</span>
</div>
{transaction.stripe_charge_id && (
<div className="flex justify-between">
<span className="text-gray-500">Charge ID</span>
<span className="text-gray-500">{t('payments.chargeId')}</span>
<span className="text-gray-700 dark:text-gray-300">
{transaction.stripe_charge_id}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-500">Transaction ID</span>
<span className="text-gray-500">{t('payments.transactionId')}</span>
<span className="text-gray-700 dark:text-gray-300">{transaction.id}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Currency</span>
<span className="text-gray-500">{t('payments.currency')}</span>
<span className="text-gray-700 dark:text-gray-300 uppercase">
{transaction.currency}
</span>

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

@@ -0,0 +1,114 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ConfirmationModal from '../ConfirmationModal';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe('ConfirmationModal', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onConfirm: vi.fn(),
title: 'Test Title',
message: 'Test message',
};
beforeEach(() => {
vi.clearAllMocks();
});
it('returns null when not open', () => {
const { container } = render(
<ConfirmationModal {...defaultProps} isOpen={false} />
);
expect(container.firstChild).toBeNull();
});
it('renders title when open', () => {
render(<ConfirmationModal {...defaultProps} />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('renders message when open', () => {
render(<ConfirmationModal {...defaultProps} />);
expect(screen.getByText('Test message')).toBeInTheDocument();
});
it('renders message as ReactNode', () => {
render(
<ConfirmationModal
{...defaultProps}
message={<span data-testid="custom-message">Custom content</span>}
/>
);
expect(screen.getByTestId('custom-message')).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
const buttons = screen.getAllByRole('button');
fireEvent.click(buttons[0]);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('calls onClose when cancel button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByText('common.cancel'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('calls onConfirm when confirm button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByText('common.confirm'));
expect(defaultProps.onConfirm).toHaveBeenCalled();
});
it('uses custom confirm text', () => {
render(<ConfirmationModal {...defaultProps} confirmText="Yes, delete" />);
expect(screen.getByText('Yes, delete')).toBeInTheDocument();
});
it('uses custom cancel text', () => {
render(<ConfirmationModal {...defaultProps} cancelText="No, keep" />);
expect(screen.getByText('No, keep')).toBeInTheDocument();
});
it('renders info variant', () => {
render(<ConfirmationModal {...defaultProps} variant="info" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('renders warning variant', () => {
render(<ConfirmationModal {...defaultProps} variant="warning" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('renders danger variant', () => {
render(<ConfirmationModal {...defaultProps} variant="danger" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('renders success variant', () => {
render(<ConfirmationModal {...defaultProps} variant="success" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('disables buttons when loading', () => {
render(<ConfirmationModal {...defaultProps} isLoading={true} />);
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
expect(button).toBeDisabled();
});
});
it('shows spinner when loading', () => {
render(<ConfirmationModal {...defaultProps} isLoading={true} />);
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import EmailTemplateSelector from '../EmailTemplateSelector';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
// Mock API client
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(() => Promise.resolve({ data: [] })),
},
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('EmailTemplateSelector', () => {
it('renders select element', () => {
render(
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
{ wrapper: createWrapper() }
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('shows placeholder text after loading', async () => {
render(
<EmailTemplateSelector
value={undefined}
onChange={() => {}}
placeholder="Select a template"
/>,
{ wrapper: createWrapper() }
);
// Wait for loading to finish and placeholder to appear
await screen.findByText('Select a template');
});
it('is disabled when disabled prop is true', () => {
render(
<EmailTemplateSelector value={undefined} onChange={() => {}} disabled />,
{ wrapper: createWrapper() }
);
expect(screen.getByRole('combobox')).toBeDisabled();
});
it('applies custom className', () => {
const { container } = render(
<EmailTemplateSelector
value={undefined}
onChange={() => {}}
className="custom-class"
/>,
{ wrapper: createWrapper() }
);
expect(container.firstChild).toHaveClass('custom-class');
});
it('shows empty state message when no templates', async () => {
render(
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
{ wrapper: createWrapper() }
);
// Wait for loading to finish
await screen.findByText('No email templates yet.');
});
});

View File

@@ -0,0 +1,270 @@
/**
* Tests for FeatureGate component
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { FeatureGate, LimitGate } from '../FeatureGate';
import * as useEntitlementsModule from '../../hooks/useEntitlements';
// Mock the useEntitlements hook
vi.mock('../../hooks/useEntitlements', () => ({
useEntitlements: vi.fn(),
}));
describe('FeatureGate', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders children when feature is enabled', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { can_use_sms_reminders: true },
isLoading: false,
hasFeature: (key: string) => key === 'can_use_sms_reminders',
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate feature="can_use_sms_reminders">
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.getByText('SMS Feature Content')).toBeInTheDocument();
});
it('does not render children when feature is disabled', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { can_use_sms_reminders: false },
isLoading: false,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate feature="can_use_sms_reminders">
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
});
it('renders fallback when feature is disabled', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { can_use_sms_reminders: false },
isLoading: false,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate
feature="can_use_sms_reminders"
fallback={<div>Upgrade to access SMS</div>}
>
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
expect(screen.getByText('Upgrade to access SMS')).toBeInTheDocument();
});
it('renders nothing while loading', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {},
isLoading: true,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate feature="can_use_sms_reminders">
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
});
it('renders loading component when provided and loading', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {},
isLoading: true,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate
feature="can_use_sms_reminders"
loadingFallback={<div>Loading...</div>}
>
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
});
it('checks multiple features with requireAll=true', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {
can_use_sms_reminders: true,
can_use_mobile_app: false,
},
isLoading: false,
hasFeature: (key: string) => key === 'can_use_sms_reminders',
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate
features={['can_use_sms_reminders', 'can_use_mobile_app']}
requireAll={true}
>
<div>Multi Feature Content</div>
</FeatureGate>
);
// Should not render because mobile_app is disabled
expect(screen.queryByText('Multi Feature Content')).not.toBeInTheDocument();
});
it('checks multiple features with requireAll=false (any)', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {
can_use_sms_reminders: true,
can_use_mobile_app: false,
},
isLoading: false,
hasFeature: (key: string) => key === 'can_use_sms_reminders',
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate
features={['can_use_sms_reminders', 'can_use_mobile_app']}
requireAll={false}
>
<div>Multi Feature Content</div>
</FeatureGate>
);
// Should render because at least one (sms) is enabled
expect(screen.getByText('Multi Feature Content')).toBeInTheDocument();
});
});
describe('LimitGate', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders children when under limit', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { max_users: 10 },
isLoading: false,
hasFeature: () => false,
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
refetch: vi.fn(),
});
render(
<LimitGate limit="max_users" currentUsage={5}>
<div>Under Limit Content</div>
</LimitGate>
);
expect(screen.getByText('Under Limit Content')).toBeInTheDocument();
});
it('does not render children when at limit', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { max_users: 10 },
isLoading: false,
hasFeature: () => false,
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
refetch: vi.fn(),
});
render(
<LimitGate limit="max_users" currentUsage={10}>
<div>Under Limit Content</div>
</LimitGate>
);
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
});
it('does not render children when over limit', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { max_users: 10 },
isLoading: false,
hasFeature: () => false,
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
refetch: vi.fn(),
});
render(
<LimitGate limit="max_users" currentUsage={15}>
<div>Under Limit Content</div>
</LimitGate>
);
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
});
it('renders fallback when over limit', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { max_users: 10 },
isLoading: false,
hasFeature: () => false,
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
refetch: vi.fn(),
});
render(
<LimitGate
limit="max_users"
currentUsage={15}
fallback={<div>Upgrade for more users</div>}
>
<div>Under Limit Content</div>
</LimitGate>
);
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
expect(screen.getByText('Upgrade for more users')).toBeInTheDocument();
});
it('renders children when limit is null (unlimited)', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {},
isLoading: false,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<LimitGate limit="max_users" currentUsage={1000}>
<div>Unlimited Content</div>
</LimitGate>
);
// When limit is null, treat as unlimited
expect(screen.getByText('Unlimited Content')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import FloatingHelpButton from '../FloatingHelpButton';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
describe('FloatingHelpButton', () => {
const renderWithRouter = (initialPath: string) => {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<FloatingHelpButton />
</MemoryRouter>
);
};
it('renders help link on dashboard', () => {
renderWithRouter('/dashboard');
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
});
it('links to correct help page for dashboard', () => {
renderWithRouter('/dashboard');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/dashboard');
});
it('links to correct help page for scheduler', () => {
renderWithRouter('/scheduler');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/scheduler');
});
it('links to correct help page for services', () => {
renderWithRouter('/services');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/services');
});
it('links to correct help page for resources', () => {
renderWithRouter('/resources');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/resources');
});
it('links to correct help page for settings', () => {
renderWithRouter('/settings/general');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/settings/general');
});
it('returns null on help pages', () => {
const { container } = renderWithRouter('/help/dashboard');
expect(container.firstChild).toBeNull();
});
it('has aria-label', () => {
renderWithRouter('/dashboard');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('aria-label', 'Help');
});
it('has title attribute', () => {
renderWithRouter('/dashboard');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('title', 'Help');
});
it('links to default help for unknown routes', () => {
renderWithRouter('/unknown-route');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help');
});
it('handles dynamic routes by matching prefix', () => {
renderWithRouter('/customers/123');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/customers');
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import HelpButton from '../HelpButton';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
describe('HelpButton', () => {
const renderHelpButton = (props: { helpPath: string; className?: string }) => {
return render(
<BrowserRouter>
<HelpButton {...props} />
</BrowserRouter>
);
};
it('renders help link', () => {
renderHelpButton({ helpPath: '/help/dashboard' });
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
});
it('has correct href', () => {
renderHelpButton({ helpPath: '/help/dashboard' });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/dashboard');
});
it('renders help text', () => {
renderHelpButton({ helpPath: '/help/test' });
expect(screen.getByText('Help')).toBeInTheDocument();
});
it('has title attribute', () => {
renderHelpButton({ helpPath: '/help/test' });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('title', 'Help');
});
it('applies custom className', () => {
renderHelpButton({ helpPath: '/help/test', className: 'custom-class' });
const link = screen.getByRole('link');
expect(link).toHaveClass('custom-class');
});
it('has default styles', () => {
renderHelpButton({ helpPath: '/help/test' });
const link = screen.getByRole('link');
expect(link).toHaveClass('inline-flex');
expect(link).toHaveClass('items-center');
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import LanguageSelector from '../LanguageSelector';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
i18n: {
language: 'en',
changeLanguage: vi.fn(),
},
}),
}));
// Mock i18n module
vi.mock('../../i18n', () => ({
supportedLanguages: [
{ code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'es', name: 'Español', flag: '🇪🇸' },
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
],
}));
describe('LanguageSelector', () => {
describe('dropdown variant', () => {
it('renders dropdown button', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
it('shows current language flag by default', () => {
render(<LanguageSelector />);
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
});
it('shows current language name on larger screens', () => {
render(<LanguageSelector />);
expect(screen.getByText('English')).toBeInTheDocument();
});
it('opens dropdown on click', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
it('shows all languages when open', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Español')).toBeInTheDocument();
expect(screen.getByText('Français')).toBeInTheDocument();
});
it('hides flag when showFlag is false', () => {
render(<LanguageSelector showFlag={false} />);
expect(screen.queryByText('🇺🇸')).not.toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<LanguageSelector className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
});
describe('inline variant', () => {
it('renders all language buttons', () => {
render(<LanguageSelector variant="inline" />);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBe(3);
});
it('renders language names', () => {
render(<LanguageSelector variant="inline" />);
expect(screen.getByText(/English/)).toBeInTheDocument();
expect(screen.getByText(/Español/)).toBeInTheDocument();
expect(screen.getByText(/Français/)).toBeInTheDocument();
});
it('highlights current language', () => {
render(<LanguageSelector variant="inline" />);
const englishButton = screen.getByText(/English/).closest('button');
expect(englishButton).toHaveClass('bg-brand-600');
});
it('shows flags by default', () => {
render(<LanguageSelector variant="inline" />);
expect(screen.getByText(/🇺🇸/)).toBeInTheDocument();
});
});
});

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

@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import MasqueradeBanner from '../MasqueradeBanner';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { name?: string }) => {
if (options?.name) return `${key} ${options.name}`;
return key;
},
}),
}));
describe('MasqueradeBanner', () => {
const defaultProps = {
effectiveUser: { id: '1', name: 'John Doe', email: 'john@test.com', role: 'staff' as const },
originalUser: { id: '2', name: 'Admin User', email: 'admin@test.com', role: 'superuser' as const },
previousUser: null,
onStop: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders effective user name', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
it('renders effective user role', () => {
render(<MasqueradeBanner {...defaultProps} />);
// The role is split across elements: "(" + "staff" + ")"
expect(screen.getByText(/staff/)).toBeInTheDocument();
});
it('renders original user info', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText(/Admin User/)).toBeInTheDocument();
});
it('calls onStop when button is clicked', () => {
render(<MasqueradeBanner {...defaultProps} />);
const stopButton = screen.getByRole('button');
fireEvent.click(stopButton);
expect(defaultProps.onStop).toHaveBeenCalled();
});
it('shows return to previous user text when previousUser exists', () => {
const propsWithPrevious = {
...defaultProps,
previousUser: { id: '3', name: 'Manager', email: 'manager@test.com', role: 'manager' as const },
};
render(<MasqueradeBanner {...propsWithPrevious} />);
expect(screen.getByText(/platform.masquerade.returnTo/)).toBeInTheDocument();
});
it('shows stop masquerading text when no previousUser', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText('platform.masquerade.stopMasquerading')).toBeInTheDocument();
});
it('renders with masquerading label', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText(/platform.masquerade.masqueradingAs/)).toBeInTheDocument();
});
});

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,714 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
import PlatformSidebar from '../PlatformSidebar';
import { User } from '../../types';
// Mock the i18next module
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => {
const translations: Record<string, string> = {
'nav.platformDashboard': 'Platform Dashboard',
'nav.dashboard': 'Dashboard',
'nav.businesses': 'Businesses',
'nav.users': 'Users',
'nav.support': 'Support',
'nav.staff': 'Staff',
'nav.platformSettings': 'Platform Settings',
'nav.help': 'Help',
'nav.apiDocs': 'API Docs',
};
return translations[key] || fallback || key;
},
}),
}));
// Mock the SmoothScheduleLogo component
vi.mock('../SmoothScheduleLogo', () => ({
default: ({ className }: { className?: string }) => (
<div data-testid="smooth-schedule-logo" className={className}>Logo</div>
),
}));
describe('PlatformSidebar', () => {
const mockSuperuser: User = {
id: '1',
name: 'Super User',
email: 'super@example.com',
role: 'superuser',
};
const mockPlatformManager: User = {
id: '2',
name: 'Platform Manager',
email: 'manager@example.com',
role: 'platform_manager',
};
const mockPlatformSupport: User = {
id: '3',
name: 'Platform Support',
email: 'support@example.com',
role: 'platform_support',
};
const mockToggleCollapse = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders the sidebar with logo and user role', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
expect(screen.getByText('Smooth Schedule')).toBeInTheDocument();
expect(screen.getByText('superuser')).toBeInTheDocument();
});
it('renders all navigation links for superuser', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Operations section
expect(screen.getByText('Operations')).toBeInTheDocument();
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.getByText('Businesses')).toBeInTheDocument();
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Support')).toBeInTheDocument();
expect(screen.getAllByText('Email Addresses')[0]).toBeInTheDocument();
// System section (superuser only)
expect(screen.getByText('System')).toBeInTheDocument();
expect(screen.getByText('Staff')).toBeInTheDocument();
expect(screen.getByText('Platform Settings')).toBeInTheDocument();
// Help section
expect(screen.getByText('Help')).toBeInTheDocument();
expect(screen.getAllByText('Email Settings')[0]).toBeInTheDocument();
expect(screen.getByText('API Docs')).toBeInTheDocument();
});
it('hides system section for platform manager', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockPlatformManager}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Operations section visible
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.getByText('Businesses')).toBeInTheDocument();
// System section not visible
expect(screen.queryByText('System')).not.toBeInTheDocument();
expect(screen.queryByText('Staff')).not.toBeInTheDocument();
expect(screen.queryByText('Platform Settings')).not.toBeInTheDocument();
});
it('hides system section and dashboard for platform support', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockPlatformSupport}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Dashboard not visible for support
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
// Operations section visible
expect(screen.getByText('Businesses')).toBeInTheDocument();
expect(screen.getByText('Users')).toBeInTheDocument();
// System section not visible
expect(screen.queryByText('System')).not.toBeInTheDocument();
expect(screen.queryByText('Staff')).not.toBeInTheDocument();
});
it('displays role with underscores replaced by spaces', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockPlatformManager}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByText('platform manager')).toBeInTheDocument();
});
});
describe('Collapsed State', () => {
it('hides text labels when collapsed', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={true}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Logo should be visible
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
// Text should be hidden
expect(screen.queryByText('Smooth Schedule')).not.toBeInTheDocument();
expect(screen.queryByText('superuser')).not.toBeInTheDocument();
// Section headers should show abbreviated versions
expect(screen.getByText('Ops')).toBeInTheDocument();
expect(screen.getByText('Sys')).toBeInTheDocument();
});
it('shows full section names when expanded', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByText('Operations')).toBeInTheDocument();
expect(screen.getByText('System')).toBeInTheDocument();
expect(screen.queryByText('Ops')).not.toBeInTheDocument();
expect(screen.queryByText('Sys')).not.toBeInTheDocument();
});
it('applies correct width classes based on collapsed state', () => {
const { container, rerender } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass('w-64');
expect(sidebar).not.toHaveClass('w-20');
rerender(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={true}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(sidebar).toHaveClass('w-20');
expect(sidebar).not.toHaveClass('w-64');
});
});
describe('Toggle Collapse Button', () => {
it('calls toggleCollapse when clicked', async () => {
const user = userEvent.setup();
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const toggleButton = screen.getByRole('button', { name: /collapse sidebar/i });
await user.click(toggleButton);
expect(mockToggleCollapse).toHaveBeenCalledTimes(1);
});
it('has correct aria-label when collapsed', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={true}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByRole('button', { name: /expand sidebar/i })).toBeInTheDocument();
});
it('has correct aria-label when expanded', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByRole('button', { name: /collapse sidebar/i })).toBeInTheDocument();
});
});
describe('Active Link Highlighting', () => {
it('highlights the active link based on current path', () => {
render(
<MemoryRouter initialEntries={['/platform/businesses']}>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</MemoryRouter>
);
const businessesLink = screen.getByRole('link', { name: /businesses/i });
const usersLink = screen.getByRole('link', { name: /^users$/i });
// Active link should have active classes
expect(businessesLink).toHaveClass('bg-gray-700', 'text-white');
expect(businessesLink).not.toHaveClass('text-gray-400');
// Inactive link should have inactive classes
expect(usersLink).toHaveClass('text-gray-400');
expect(usersLink).not.toHaveClass('bg-gray-700');
});
it('highlights dashboard link when on dashboard route', () => {
render(
<MemoryRouter initialEntries={['/platform/dashboard']}>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</MemoryRouter>
);
const dashboardLink = screen.getByRole('link', { name: /dashboard/i });
expect(dashboardLink).toHaveClass('bg-gray-700', 'text-white');
});
it('highlights link for nested routes', () => {
render(
<MemoryRouter initialEntries={['/platform/businesses/123']}>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</MemoryRouter>
);
const businessesLink = screen.getByRole('link', { name: /businesses/i });
expect(businessesLink).toHaveClass('bg-gray-700', 'text-white');
});
it('highlights staff link when on staff route', () => {
render(
<MemoryRouter initialEntries={['/platform/staff']}>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</MemoryRouter>
);
const staffLink = screen.getByRole('link', { name: /staff/i });
expect(staffLink).toHaveClass('bg-gray-700', 'text-white');
});
it('highlights help link when on help route', () => {
render(
<MemoryRouter initialEntries={['/help/api']}>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</MemoryRouter>
);
const apiDocsLink = screen.getByRole('link', { name: /api docs/i });
expect(apiDocsLink).toHaveClass('bg-gray-700', 'text-white');
});
});
describe('Navigation Links', () => {
it('has correct href attributes for all links', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '/platform/dashboard');
expect(screen.getByRole('link', { name: /businesses/i })).toHaveAttribute('href', '/platform/businesses');
expect(screen.getByRole('link', { name: /^users$/i })).toHaveAttribute('href', '/platform/users');
expect(screen.getByRole('link', { name: /support/i })).toHaveAttribute('href', '/platform/support');
expect(screen.getByRole('link', { name: /staff/i })).toHaveAttribute('href', '/platform/staff');
expect(screen.getByRole('link', { name: /platform settings/i })).toHaveAttribute('href', '/platform/settings');
expect(screen.getByRole('link', { name: /api docs/i })).toHaveAttribute('href', '/help/api');
});
it('shows title attributes on links for accessibility', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('title', 'Platform Dashboard');
expect(screen.getByRole('link', { name: /businesses/i })).toHaveAttribute('title', 'Businesses');
expect(screen.getByRole('link', { name: /^users$/i })).toHaveAttribute('title', 'Users');
});
});
describe('Icons', () => {
it('renders lucide-react icons for all navigation items', () => {
const { container } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Check that SVG icons are present (lucide-react renders as SVG)
const svgs = container.querySelectorAll('svg');
// Should have: logo + icons for each nav item
expect(svgs.length).toBeGreaterThanOrEqual(10);
});
it('keeps icons visible when collapsed', () => {
const { container } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={true}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Icons should still be present when collapsed
const svgs = container.querySelectorAll('svg');
expect(svgs.length).toBeGreaterThanOrEqual(10);
});
});
describe('Responsive Design', () => {
it('applies flex column layout', () => {
const { container } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass('flex', 'flex-col', 'h-full');
});
it('applies dark theme colors', () => {
const { container } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass('bg-gray-900', 'text-white');
});
it('has transition classes for smooth collapse animation', () => {
const { container } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass('transition-all', 'duration-300');
});
});
describe('Role-Based Access Control', () => {
it('shows dashboard for superuser and platform_manager only', () => {
const { rerender } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.queryByText('Dashboard')).toBeInTheDocument();
rerender(
<BrowserRouter>
<PlatformSidebar
user={mockPlatformManager}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.queryByText('Dashboard')).toBeInTheDocument();
rerender(
<BrowserRouter>
<PlatformSidebar
user={mockPlatformSupport}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
});
it('shows system section only for superuser', () => {
const { rerender } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.queryByText('System')).toBeInTheDocument();
expect(screen.queryByText('Staff')).toBeInTheDocument();
rerender(
<BrowserRouter>
<PlatformSidebar
user={mockPlatformManager}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.queryByText('System')).not.toBeInTheDocument();
expect(screen.queryByText('Staff')).not.toBeInTheDocument();
rerender(
<BrowserRouter>
<PlatformSidebar
user={mockPlatformSupport}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.queryByText('System')).not.toBeInTheDocument();
});
it('always shows common operations links for all roles', () => {
const roles: User[] = [mockSuperuser, mockPlatformManager, mockPlatformSupport];
roles.forEach((user) => {
const { unmount } = render(
<BrowserRouter>
<PlatformSidebar
user={user}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByText('Businesses')).toBeInTheDocument();
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Support')).toBeInTheDocument();
unmount();
});
});
});
describe('Accessibility', () => {
it('has semantic HTML structure with nav element', () => {
const { container } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const nav = container.querySelector('nav');
expect(nav).toBeInTheDocument();
});
it('provides proper button label for keyboard users', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const button = screen.getByRole('button', { name: /collapse sidebar/i });
expect(button).toHaveAccessibleName();
});
it('all links have accessible names', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const links = screen.getAllByRole('link');
links.forEach((link) => {
expect(link).toHaveAccessibleName();
});
});
it('maintains focus visibility for keyboard navigation', () => {
const { container } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const button = screen.getByRole('button', { name: /collapse sidebar/i });
expect(button).toHaveClass('focus:outline-none');
});
});
describe('Edge Cases', () => {
it('handles user with empty name gracefully', () => {
const userWithoutName: User = {
...mockSuperuser,
name: '',
};
render(
<BrowserRouter>
<PlatformSidebar
user={userWithoutName}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Should still render without crashing
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
});
it('handles missing translation gracefully', () => {
// Translation mock should return the key if translation is missing
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Should render without errors even with missing translations
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
});
it('handles rapid collapse/expand toggling', async () => {
const user = userEvent.setup();
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const button = screen.getByRole('button', { name: /collapse sidebar/i });
// Rapidly click multiple times
await user.click(button);
await user.click(button);
await user.click(button);
expect(mockToggleCollapse).toHaveBeenCalledTimes(3);
});
});
});

View File

@@ -0,0 +1,453 @@
/**
* Unit tests for Portal component
*
* Tests the Portal component which uses ReactDOM.createPortal to render
* children outside the parent DOM hierarchy. This is useful for modals,
* tooltips, and other UI elements that need to escape parent stacking contexts.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import Portal from '../Portal';
describe('Portal', () => {
afterEach(() => {
// Clean up any rendered components
cleanup();
});
describe('Basic Rendering', () => {
it('should render children', () => {
render(
<Portal>
<div data-testid="portal-content">Portal Content</div>
</Portal>
);
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
expect(screen.getByText('Portal Content')).toBeInTheDocument();
});
it('should render text content', () => {
render(<Portal>Simple text content</Portal>);
expect(screen.getByText('Simple text content')).toBeInTheDocument();
});
it('should render complex JSX children', () => {
render(
<Portal>
<div>
<h1>Title</h1>
<p>Description</p>
<button>Click me</button>
</div>
</Portal>
);
expect(screen.getByRole('heading', { name: 'Title' })).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
});
describe('Portal Behavior', () => {
it('should render content to document.body', () => {
const { container } = render(
<div id="root">
<Portal>
<div data-testid="portal-content">Portal Content</div>
</Portal>
</div>
);
const portalContent = screen.getByTestId('portal-content');
// Portal content should NOT be inside the container
expect(container.contains(portalContent)).toBe(false);
// Portal content SHOULD be inside document.body
expect(document.body.contains(portalContent)).toBe(true);
});
it('should escape parent DOM hierarchy', () => {
const { container } = render(
<div id="parent" style={{ position: 'relative', zIndex: 1 }}>
<div id="child">
<Portal>
<div data-testid="portal-content">Escaped Content</div>
</Portal>
</div>
</div>
);
const portalContent = screen.getByTestId('portal-content');
const parent = container.querySelector('#parent');
// Portal content should not be inside parent
expect(parent?.contains(portalContent)).toBe(false);
// Portal content should be direct child of body
expect(portalContent.parentElement).toBe(document.body);
});
});
describe('Multiple Children', () => {
it('should render multiple children', () => {
render(
<Portal>
<div data-testid="child-1">First child</div>
<div data-testid="child-2">Second child</div>
<div data-testid="child-3">Third child</div>
</Portal>
);
expect(screen.getByTestId('child-1')).toBeInTheDocument();
expect(screen.getByTestId('child-2')).toBeInTheDocument();
expect(screen.getByTestId('child-3')).toBeInTheDocument();
});
it('should render an array of children', () => {
const items = ['Item 1', 'Item 2', 'Item 3'];
render(
<Portal>
{items.map((item, index) => (
<div key={index} data-testid={`item-${index}`}>
{item}
</div>
))}
</Portal>
);
items.forEach((item, index) => {
expect(screen.getByTestId(`item-${index}`)).toBeInTheDocument();
expect(screen.getByText(item)).toBeInTheDocument();
});
});
it('should render nested components', () => {
const NestedComponent = () => (
<div data-testid="nested">
<span>Nested Component</span>
</div>
);
render(
<Portal>
<NestedComponent />
<div>Other content</div>
</Portal>
);
expect(screen.getByTestId('nested')).toBeInTheDocument();
expect(screen.getByText('Nested Component')).toBeInTheDocument();
expect(screen.getByText('Other content')).toBeInTheDocument();
});
});
describe('Mounting Behavior', () => {
it('should not render before component is mounted', () => {
// This test verifies the internal mounting state
const { rerender } = render(
<Portal>
<div data-testid="portal-content">Content</div>
</Portal>
);
// After initial render, content should be present
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
// Re-render should still show content
rerender(
<Portal>
<div data-testid="portal-content">Updated Content</div>
</Portal>
);
expect(screen.getByText('Updated Content')).toBeInTheDocument();
});
});
describe('Multiple Portals', () => {
it('should support multiple portal instances', () => {
render(
<div>
<Portal>
<div data-testid="portal-1">Portal 1</div>
</Portal>
<Portal>
<div data-testid="portal-2">Portal 2</div>
</Portal>
<Portal>
<div data-testid="portal-3">Portal 3</div>
</Portal>
</div>
);
expect(screen.getByTestId('portal-1')).toBeInTheDocument();
expect(screen.getByTestId('portal-2')).toBeInTheDocument();
expect(screen.getByTestId('portal-3')).toBeInTheDocument();
// All portals should be in document.body
expect(document.body.contains(screen.getByTestId('portal-1'))).toBe(true);
expect(document.body.contains(screen.getByTestId('portal-2'))).toBe(true);
expect(document.body.contains(screen.getByTestId('portal-3'))).toBe(true);
});
it('should keep portals separate from each other', () => {
render(
<div>
<Portal>
<div data-testid="portal-1">
<span data-testid="content-1">Content 1</span>
</div>
</Portal>
<Portal>
<div data-testid="portal-2">
<span data-testid="content-2">Content 2</span>
</div>
</Portal>
</div>
);
const portal1 = screen.getByTestId('portal-1');
const portal2 = screen.getByTestId('portal-2');
const content1 = screen.getByTestId('content-1');
const content2 = screen.getByTestId('content-2');
// Each portal should contain only its own content
expect(portal1.contains(content1)).toBe(true);
expect(portal1.contains(content2)).toBe(false);
expect(portal2.contains(content2)).toBe(true);
expect(portal2.contains(content1)).toBe(false);
});
});
describe('Cleanup', () => {
it('should remove content from body when unmounted', () => {
const { unmount } = render(
<Portal>
<div data-testid="portal-content">Temporary Content</div>
</Portal>
);
// Content should exist initially
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
// Unmount the component
unmount();
// Content should be removed from DOM
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument();
});
it('should clean up multiple portals on unmount', () => {
const { unmount } = render(
<div>
<Portal>
<div data-testid="portal-1">Portal 1</div>
</Portal>
<Portal>
<div data-testid="portal-2">Portal 2</div>
</Portal>
</div>
);
expect(screen.getByTestId('portal-1')).toBeInTheDocument();
expect(screen.getByTestId('portal-2')).toBeInTheDocument();
unmount();
expect(screen.queryByTestId('portal-1')).not.toBeInTheDocument();
expect(screen.queryByTestId('portal-2')).not.toBeInTheDocument();
});
});
describe('Re-rendering', () => {
it('should update content on re-render', () => {
const { rerender } = render(
<Portal>
<div data-testid="portal-content">Initial Content</div>
</Portal>
);
expect(screen.getByText('Initial Content')).toBeInTheDocument();
rerender(
<Portal>
<div data-testid="portal-content">Updated Content</div>
</Portal>
);
expect(screen.getByText('Updated Content')).toBeInTheDocument();
expect(screen.queryByText('Initial Content')).not.toBeInTheDocument();
});
it('should handle prop changes', () => {
const TestComponent = ({ message }: { message: string }) => (
<Portal>
<div data-testid="message">{message}</div>
</Portal>
);
const { rerender } = render(<TestComponent message="First message" />);
expect(screen.getByText('First message')).toBeInTheDocument();
rerender(<TestComponent message="Second message" />);
expect(screen.getByText('Second message')).toBeInTheDocument();
expect(screen.queryByText('First message')).not.toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle empty children', () => {
render(<Portal>{null}</Portal>);
// Should not throw error
expect(document.body).toBeInTheDocument();
});
it('should handle undefined children', () => {
render(<Portal>{undefined}</Portal>);
// Should not throw error
expect(document.body).toBeInTheDocument();
});
it('should handle boolean children', () => {
render(
<Portal>
{false && <div>Should not render</div>}
{true && <div data-testid="should-render">Should render</div>}
</Portal>
);
expect(screen.queryByText('Should not render')).not.toBeInTheDocument();
expect(screen.getByTestId('should-render')).toBeInTheDocument();
});
it('should handle conditional rendering', () => {
const { rerender } = render(
<Portal>
{false && <div data-testid="conditional">Conditional Content</div>}
</Portal>
);
expect(screen.queryByTestId('conditional')).not.toBeInTheDocument();
rerender(
<Portal>
{true && <div data-testid="conditional">Conditional Content</div>}
</Portal>
);
expect(screen.getByTestId('conditional')).toBeInTheDocument();
});
});
describe('Integration with Parent Components', () => {
it('should work inside modals', () => {
const Modal = ({ children }: { children: React.ReactNode }) => (
<div className="modal" data-testid="modal">
<Portal>{children}</Portal>
</div>
);
const { container } = render(
<Modal>
<div data-testid="modal-content">Modal Content</div>
</Modal>
);
const modalContent = screen.getByTestId('modal-content');
const modal = container.querySelector('[data-testid="modal"]');
// Modal content should not be inside modal container
expect(modal?.contains(modalContent)).toBe(false);
// Modal content should be in document.body
expect(document.body.contains(modalContent)).toBe(true);
});
it('should preserve event handlers', () => {
let clicked = false;
const handleClick = () => {
clicked = true;
};
render(
<Portal>
<button data-testid="button" onClick={handleClick}>
Click me
</button>
</Portal>
);
const button = screen.getByTestId('button');
button.click();
expect(clicked).toBe(true);
});
it('should preserve CSS classes and styles', () => {
render(
<Portal>
<div
data-testid="styled-content"
className="custom-class"
style={{ color: 'red', fontSize: '16px' }}
>
Styled Content
</div>
</Portal>
);
const styledContent = screen.getByTestId('styled-content');
expect(styledContent).toHaveClass('custom-class');
// Check styles individually - color may be normalized to rgb()
expect(styledContent.style.color).toBeTruthy();
expect(styledContent.style.fontSize).toBe('16px');
});
});
describe('Accessibility', () => {
it('should maintain ARIA attributes', () => {
render(
<Portal>
<div
data-testid="aria-content"
role="dialog"
aria-label="Test Dialog"
aria-describedby="description"
>
<div id="description">Dialog description</div>
</div>
</Portal>
);
const content = screen.getByTestId('aria-content');
expect(content).toHaveAttribute('role', 'dialog');
expect(content).toHaveAttribute('aria-label', 'Test Dialog');
expect(content).toHaveAttribute('aria-describedby', 'description');
});
it('should support semantic HTML inside portal', () => {
render(
<Portal>
<dialog open data-testid="dialog">
<h2>Dialog Title</h2>
<p>Dialog content</p>
</dialog>
</Portal>
);
expect(screen.getByTestId('dialog')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Dialog Title' })).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,681 @@
/**
* Unit tests for QuotaWarningBanner component
*
* Tests cover:
* - Rendering based on quota overage state
* - Critical, urgent, and warning severity levels
* - Display of correct percentage and usage information
* - Multiple overages display
* - Manage Quota button/link functionality
* - Dismiss button functionality
* - Date formatting
* - Internationalization (i18n)
* - Accessibility attributes
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import React from 'react';
import QuotaWarningBanner from '../QuotaWarningBanner';
import { QuotaOverage } from '../../api/auth';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback: string, options?: Record<string, unknown>) => {
// Handle interpolation for dynamic values
if (options) {
let result = fallback;
Object.entries(options).forEach(([key, value]) => {
result = result.replace(`{{${key}}}`, String(value));
});
return result;
}
return fallback;
},
}),
}));
// Test wrapper with Router
const createWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<BrowserRouter>{children}</BrowserRouter>
);
};
// Test data factories
const createMockOverage = (overrides?: Partial<QuotaOverage>): QuotaOverage => ({
id: 1,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 14,
grace_period_ends_at: '2025-12-21T00:00:00Z',
...overrides,
});
describe('QuotaWarningBanner', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering Conditions', () => {
it('should not render when overages array is empty', () => {
const { container } = render(
<QuotaWarningBanner overages={[]} />,
{ wrapper: createWrapper() }
);
expect(container).toBeEmptyDOMElement();
});
it('should not render when overages is null', () => {
const { container } = render(
<QuotaWarningBanner overages={null as any} />,
{ wrapper: createWrapper() }
);
expect(container).toBeEmptyDOMElement();
});
it('should not render when overages is undefined', () => {
const { container } = render(
<QuotaWarningBanner overages={undefined as any} />,
{ wrapper: createWrapper() }
);
expect(container).toBeEmptyDOMElement();
});
it('should render when quota is near limit (warning state)', () => {
const overages = [createMockOverage({ days_remaining: 14 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/quota exceeded/i)).toBeInTheDocument();
});
it('should render when quota is critical (1 day remaining)', () => {
const overages = [createMockOverage({ days_remaining: 1 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/urgent.*automatic archiving tomorrow/i)).toBeInTheDocument();
});
it('should render when quota is urgent (7 days remaining)', () => {
const overages = [createMockOverage({ days_remaining: 7 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/action required.*7 days left/i)).toBeInTheDocument();
});
});
describe('Severity Levels and Styling', () => {
it('should apply warning styles for normal overages (>7 days)', () => {
const overages = [createMockOverage({ days_remaining: 14 })];
const { container } = render(
<QuotaWarningBanner overages={overages} />,
{ wrapper: createWrapper() }
);
const banner = container.querySelector('div[class*="bg-amber-100"]');
expect(banner).toBeInTheDocument();
});
it('should apply urgent styles for 7 days or less', () => {
const overages = [createMockOverage({ days_remaining: 7 })];
const { container } = render(
<QuotaWarningBanner overages={overages} />,
{ wrapper: createWrapper() }
);
const banner = container.querySelector('div[class*="bg-amber-500"]');
expect(banner).toBeInTheDocument();
});
it('should apply critical styles for 1 day or less', () => {
const overages = [createMockOverage({ days_remaining: 1 })];
const { container } = render(
<QuotaWarningBanner overages={overages} />,
{ wrapper: createWrapper() }
);
const banner = container.querySelector('div[class*="bg-red-600"]');
expect(banner).toBeInTheDocument();
});
it('should apply critical styles for 0 days remaining', () => {
const overages = [createMockOverage({ days_remaining: 0 })];
const { container } = render(
<QuotaWarningBanner overages={overages} />,
{ wrapper: createWrapper() }
);
const banner = container.querySelector('div[class*="bg-red-600"]');
expect(banner).toBeInTheDocument();
});
});
describe('Usage and Percentage Display', () => {
it('should display correct overage amount', () => {
const overages = [
createMockOverage({
overage_amount: 5,
display_name: 'Resources',
}),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/you have 5 Resources over your plan limit/i)).toBeInTheDocument();
});
it('should display current usage and limit in multi-overage list', () => {
const overages = [
createMockOverage({
id: 1,
current_usage: 15,
allowed_limit: 10,
display_name: 'Staff Members',
}),
createMockOverage({
id: 2,
current_usage: 20,
allowed_limit: 15,
display_name: 'Resources',
}),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
// Usage/limit is shown in the "All overages" list when there are multiple
expect(screen.getByText(/Staff Members: 15\/10/)).toBeInTheDocument();
expect(screen.getByText(/Resources: 20\/15/)).toBeInTheDocument();
});
it('should display quota type name', () => {
const overages = [
createMockOverage({
display_name: 'Calendar Events',
overage_amount: 100,
}),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/you have 100 Calendar Events over your plan limit/i)).toBeInTheDocument();
});
it('should format and display grace period end date', () => {
const overages = [
createMockOverage({
grace_period_ends_at: '2025-12-25T00:00:00Z',
}),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
// Date formatting will depend on locale, but should contain the date components
const detailsText = screen.getByText(/grace period ends/i);
expect(detailsText).toBeInTheDocument();
});
});
describe('Multiple Overages', () => {
it('should display most urgent overage in main message', () => {
const overages = [
createMockOverage({ id: 1, days_remaining: 14, display_name: 'Resources' }),
createMockOverage({ id: 2, days_remaining: 3, display_name: 'Staff Members' }),
createMockOverage({ id: 3, days_remaining: 7, display_name: 'Events' }),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
// Should show the most urgent (3 days)
expect(screen.getByText(/action required.*3 days left/i)).toBeInTheDocument();
});
it('should show additional overages section when multiple overages exist', () => {
const overages = [
createMockOverage({ id: 1, days_remaining: 14, display_name: 'Resources', current_usage: 15, allowed_limit: 10, overage_amount: 5 }),
createMockOverage({ id: 2, days_remaining: 7, display_name: 'Staff', current_usage: 8, allowed_limit: 5, overage_amount: 3 }),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/all overages:/i)).toBeInTheDocument();
});
it('should list all overages with details in the additional section', () => {
const overages = [
createMockOverage({
id: 1,
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 14,
}),
createMockOverage({
id: 2,
display_name: 'Staff',
current_usage: 8,
allowed_limit: 5,
overage_amount: 3,
days_remaining: 7,
}),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/Resources: 15\/10/)).toBeInTheDocument();
expect(screen.getByText(/over by 5/)).toBeInTheDocument();
expect(screen.getByText(/Staff: 8\/5/)).toBeInTheDocument();
expect(screen.getByText(/over by 3/)).toBeInTheDocument();
});
it('should not show additional overages section for single overage', () => {
const overages = [createMockOverage()];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.queryByText(/all overages:/i)).not.toBeInTheDocument();
});
it('should display "expires today" for 0 days remaining in overage list', () => {
const overages = [
createMockOverage({ id: 1, days_remaining: 14 }),
createMockOverage({ id: 2, days_remaining: 0, display_name: 'Critical Item' }),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/expires today!/i)).toBeInTheDocument();
});
});
describe('Manage Quota Button', () => {
it('should render Manage Quota link', () => {
const overages = [createMockOverage()];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /manage quota/i });
expect(link).toBeInTheDocument();
});
it('should link to settings/quota page', () => {
const overages = [createMockOverage()];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /manage quota/i });
expect(link).toHaveAttribute('href', '/settings/quota');
});
it('should display external link icon', () => {
const overages = [createMockOverage()];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /manage quota/i });
const icon = link.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('should apply warning button styles for normal overages', () => {
const overages = [createMockOverage({ days_remaining: 14 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /manage quota/i });
expect(link).toHaveClass('bg-amber-600');
});
it('should apply urgent button styles for urgent/critical overages', () => {
const overages = [createMockOverage({ days_remaining: 7 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /manage quota/i });
expect(link).toHaveClass('bg-white/20');
});
});
describe('Dismiss Button', () => {
it('should render dismiss button when onDismiss prop is provided', () => {
const overages = [createMockOverage()];
const onDismiss = vi.fn();
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
wrapper: createWrapper(),
});
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
expect(dismissButton).toBeInTheDocument();
});
it('should not render dismiss button when onDismiss prop is not provided', () => {
const overages = [createMockOverage()];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
const dismissButton = screen.queryByRole('button', { name: /dismiss/i });
expect(dismissButton).not.toBeInTheDocument();
});
it('should call onDismiss when dismiss button is clicked', async () => {
const user = userEvent.setup();
const overages = [createMockOverage()];
const onDismiss = vi.fn();
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
wrapper: createWrapper(),
});
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
await user.click(dismissButton);
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it('should display X icon in dismiss button', () => {
const overages = [createMockOverage()];
const onDismiss = vi.fn();
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
wrapper: createWrapper(),
});
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
const icon = dismissButton.querySelector('svg');
expect(icon).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have alert icon with appropriate styling', () => {
const overages = [createMockOverage()];
const { container } = render(
<QuotaWarningBanner overages={overages} />,
{ wrapper: createWrapper() }
);
// AlertTriangle icon should be present
const icon = container.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('should have accessible label for dismiss button', () => {
const overages = [createMockOverage()];
const onDismiss = vi.fn();
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
wrapper: createWrapper(),
});
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
expect(dismissButton).toHaveAttribute('aria-label', 'Dismiss');
});
it('should use semantic HTML structure', () => {
const overages = [createMockOverage({ days_remaining: 14 })];
const { container } = render(
<QuotaWarningBanner overages={overages} />,
{ wrapper: createWrapper() }
);
// Should have proper div structure
expect(container.querySelector('div')).toBeInTheDocument();
});
it('should have accessible link for Manage Quota', () => {
const overages = [createMockOverage()];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /manage quota/i });
expect(link).toBeInTheDocument();
expect(link.tagName).toBe('A');
});
});
describe('Message Priority', () => {
it('should show critical message for 1 day remaining', () => {
const overages = [createMockOverage({ days_remaining: 1 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/urgent.*automatic archiving tomorrow/i)).toBeInTheDocument();
});
it('should show urgent message for 2-7 days remaining', () => {
const overages = [createMockOverage({ days_remaining: 5 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/action required.*5 days left/i)).toBeInTheDocument();
});
it('should show warning message for more than 7 days remaining', () => {
const overages = [createMockOverage({ days_remaining: 10 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/quota exceeded for 1 item/i)).toBeInTheDocument();
});
it('should show count of overages in warning message', () => {
const overages = [
createMockOverage({ id: 1, days_remaining: 14 }),
createMockOverage({ id: 2, days_remaining: 10 }),
createMockOverage({ id: 3, days_remaining: 12 }),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/quota exceeded for 3 item/i)).toBeInTheDocument();
});
});
describe('Integration', () => {
it('should render complete banner with all elements', () => {
const overages = [
createMockOverage({
id: 1,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 7,
grace_period_ends_at: '2025-12-21T00:00:00Z',
}),
];
const onDismiss = vi.fn();
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
wrapper: createWrapper(),
});
// Check main message
expect(screen.getByText(/action required.*7 days left/i)).toBeInTheDocument();
// Check details
expect(screen.getByText(/you have 5 Resources over your plan limit/i)).toBeInTheDocument();
// Check Manage Quota link
const link = screen.getByRole('link', { name: /manage quota/i });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', '/settings/quota');
// Check dismiss button
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
expect(dismissButton).toBeInTheDocument();
// Check icons are present (via SVG elements)
const { container } = render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
wrapper: createWrapper(),
});
const icons = container.querySelectorAll('svg');
expect(icons.length).toBeGreaterThan(0);
});
it('should handle complex multi-overage scenario', async () => {
const user = userEvent.setup();
const overages = [
createMockOverage({
id: 1,
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 14,
}),
createMockOverage({
id: 2,
display_name: 'Staff Members',
current_usage: 12,
allowed_limit: 8,
overage_amount: 4,
days_remaining: 2,
}),
createMockOverage({
id: 3,
display_name: 'Calendar Events',
current_usage: 500,
allowed_limit: 400,
overage_amount: 100,
days_remaining: 7,
}),
];
const onDismiss = vi.fn();
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
wrapper: createWrapper(),
});
// Should show most urgent (2 days)
expect(screen.getByText(/action required.*2 days left/i)).toBeInTheDocument();
// Should show all overages section
expect(screen.getByText(/all overages:/i)).toBeInTheDocument();
expect(screen.getByText(/Resources: 15\/10/)).toBeInTheDocument();
expect(screen.getByText(/Staff Members: 12\/8/)).toBeInTheDocument();
expect(screen.getByText(/Calendar Events: 500\/400/)).toBeInTheDocument();
// Should be able to dismiss
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
await user.click(dismissButton);
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
describe('Edge Cases', () => {
it('should handle negative days remaining', () => {
const overages = [createMockOverage({ days_remaining: -1 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
// Should treat as critical (0 or less)
const { container } = render(
<QuotaWarningBanner overages={overages} />,
{ wrapper: createWrapper() }
);
const banner = container.querySelector('div[class*="bg-red-600"]');
expect(banner).toBeInTheDocument();
});
it('should handle very large overage amounts', () => {
const overages = [
createMockOverage({
overage_amount: 999999,
display_name: 'Events',
}),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/you have 999999 Events over your plan limit/i)).toBeInTheDocument();
});
it('should handle zero overage amount', () => {
const overages = [
createMockOverage({
overage_amount: 0,
current_usage: 10,
allowed_limit: 10,
}),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/you have 0 Resources over your plan limit/i)).toBeInTheDocument();
});
});
});

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