98 Commits

Author SHA1 Message Date
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
poduck
093f6d9a62 fix(traefik): Add env_file to read Cloudflare token
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:53:33 -05:00
poduck
5bf2fc5319 fix(traefik): Use Cloudflare DNS provider instead of DigitalOcean
DNS is hosted on Cloudflare, not DigitalOcean.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:50:21 -05:00
poduck
33e4b6b9b5 feat(traefik): Add DNS challenge for wildcard SSL certificates
HostRegexp patterns don't work with HTTP challenge because Traefik
can't request certificates for dynamic subdomains. Switched to DNS
challenge using DigitalOcean provider for *.smoothschedule.com wildcard.

Requires DO_AUTH_TOKEN environment variable to be set.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:48:50 -05:00
poduck
434f874963 fix(traefik): Route tenant subdomains to nginx instead of django
The subdomain-router was incorrectly sending tenant subdomain requests
directly to Django (API server), causing 404 errors. Now routes to nginx
which serves the React SPA and proxies /api/ requests to Django.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:40:18 -05:00
poduck
0d3c97ea5f fix(onboarding): Improve loading indicator with elapsed time and better pacing
- Add elapsed time counter (MM:SS)
- Spread animation steps over ~30 seconds before final step
- Final step stays spinning (doesn't complete early)
- Progress bar caps at 90% until actually done, pulses on final step
- Show "Finalizing..." and helpful message during long final step
- Clear "45-90 seconds" time estimate upfront

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:37:34 -05:00
poduck
567fe0604a feat(onboarding): Add animated loading indicator and fix completion
- Add multi-step animated loading indicator during tenant creation
- Fix blank completion screen (was checking wrong step number)
- Auto-verify email for users accepting tenant invitations
- Show progress bar and step-by-step status during database setup

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:26:11 -05:00
poduck
5244e16279 fix(tenant): Defer plugin seeding until after transaction commits
The post_save signal was trying to seed plugins before the tenant's
schema migrations had completed, causing a 500 error when accepting
tenant invitations. Using transaction.on_commit() ensures the schema
and tables exist before seeding.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:11:51 -05:00
poduck
55cb97ca0d fix(deploy): Check if backup directory has content before restoring
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:05:04 -05:00
poduck
a170d6134b fix(invitations): Use tenant-onboard page for platform invitations
Platform/tenant invitations should redirect to /tenant-onboard which has
the full onboarding wizard, not /accept-invite which is for staff invitations.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:02:02 -05:00
poduck
d2c4cbe183 fix(deploy): Fix .envs and .ssh restore to copy contents not directory
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:57:34 -05:00
poduck
47f1a4d7b4 fix(deploy): Backup and restore .ssh keys during git-based deployments
SSH keys for mail server management are not in git, need to preserve
them like .envs secrets.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:51:08 -05:00
poduck
b455be0ac6 fix(docker): Update nginx context path for git-based deployments
Changed from ../smoothschedule-frontend to ../frontend to match
the git repository structure.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:50:31 -05:00
poduck
abf67a36ed fix(invitations): Support both platform and staff invitation types
- Update useInvitationDetails to try platform tenant invitation first,
  then fall back to staff invitation
- Update useAcceptInvitation to handle both invitation types
- Update useDeclineInvitation to handle both invitation types
- Pass invitation type from AcceptInvitePage to mutations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:49:59 -05:00
poduck
4f515c3710 feat: Quota enforcement UI and various improvements
- Add quota limit warnings to Resources, Services, and OwnerScheduler pages
- Add quotaUtils.ts for checking quota limits
- Update BusinessLayout with quota context
- Improve email receiver logging
- Update serializers

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:47:48 -05:00
poduck
fd751f02f8 refactor(deploy): Use git pull instead of rsync for deployments
- Requires changes to be committed and pushed before deploying
- Clones repo on first deploy, then uses git fetch/reset
- Preserves .envs secrets which are not in git
- Better for multi-developer workflows

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:46:01 -05:00
poduck
04bb9e3c14 fix(auth): Allow accept-invite on subdomains without redirect to login
Don't redirect unauthenticated users to login when accessing public paths
like /accept-invite, /verify-email, or /tenant-onboard on subdomains.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:28:17 -05:00
poduck
39a376b39b fix(email): Add SMTP configuration and fix invitation link routing
- Add SMTP email backend support to production settings (EMAIL_HOST, EMAIL_PORT, etc.)
- Falls back to console backend if SMTP not configured
- Fix AcceptInvitePage to support both path parameter (/accept-invite/:token) and
  query parameter (?token=...) formats for invitation tokens
- Add route for /accept-invite/:token in App.tsx

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:19:46 -05:00
poduck
85c4b835fd fix(mail): Copy SSH keys into Docker image instead of volume mount
Remove volume mount for SSH keys and rely on Dockerfile COPY instruction
to bake SSH keys into the image during build. This ensures proper
permissions and ownership for the django user.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 13:40:57 -05:00
poduck
bed0ba9304 feat(mail): Add mail server SSH configuration
- Mount SSH keys in Django container for mail server access
- Add mail server configuration environment variables
- Import mail server settings in production.py with env defaults
- Configure mail.talova.net SSH connection parameters

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 13:33:19 -05:00
poduck
dcb14503a2 feat: Dashboard redesign, plan permissions, and help docs improvements
Major updates including:
- Customizable dashboard with drag-and-drop widget grid layout
- Plan-based feature locking for plugins and tasks
- Comprehensive help documentation updates across all pages
- Plugin seeding in deployment process for all tenants
- Permission synchronization system for subscription plans
- QuotaOverageModal component and enhanced UX flows

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 13:02:44 -05:00
poduck
9444e26924 docs(help): Comprehensive rewrites for Resources, Services, Customers, Staff guides
HelpResources.tsx:
- Added resource types section with Staff/Room/Equipment
- Documented table columns and their meanings
- Added step-by-step resource creation guide
- Added staff autocomplete with keyboard navigation
- Detailed multilane mode for concurrent bookings
- Documented View Calendar and Edit features

HelpServices.tsx:
- Documented two-column layout with customer preview
- Added drag-and-drop reordering instructions
- Detailed service properties (name, duration, price, description)
- Added photo gallery section with upload, reorder, delete
- Documented customer preview mockup feature

HelpCustomers.tsx:
- Documented customer table columns
- Added search and sorting capabilities
- Step-by-step customer creation guide
- Documented customer statuses (Active, Inactive, Blocked)
- Added tags section for customer organization
- Documented masquerading feature for customer support

HelpStaff.tsx:
- Detailed staff roles (Owner, Manager, Staff) with badges
- Staff table columns documentation
- Step-by-step staff invitation workflow
- Pending invitations management (resend, cancel)
- Edit staff modal with permissions
- Make Bookable feature for linking to resources
- Inactive staff section with reactivation
- Masquerading as staff for training/troubleshooting

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 02:44:02 -05:00
poduck
445b2bb3fc fix(help): Correct pending appointments sidebar position to left
Fixed documentation that incorrectly stated the pending appointments
sidebar appears on the right side of the scheduler when it actually
appears on the left side.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 02:35:06 -05:00
poduck
baffe7e577 docs(help): Comprehensive Scheduler documentation with all features
Rewrote HelpScheduler.tsx to document actual scheduler features including:
- Drag-and-drop to reschedule, change resource, or delete appointments
- Resize appointments by dragging edges (start or end)
- Pending appointments sidebar with archive zone
- Undo/Redo with Ctrl+Z/Ctrl+Y (up to 50 actions)
- Zoom controls for timeline detail
- Status colors (blue/yellow/red/green/gray)
- Filtering by status, resource, and service
- Overlapping appointment lanes
- Real-time WebSocket updates
- Month view click-to-day and drag overlay features

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 02:27:39 -05:00
poduck
5aa49399d0 feat(help): Add floating help button to all pages
Replaced inline HelpButton components with a global FloatingHelpButton
that appears fixed in the top-right corner of all pages. The button:
- Automatically detects the current route and links to the appropriate help page
- Uses a consistent position across all pages (fixed, top-right)
- Is hidden on help pages themselves
- Works on both business and platform layouts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 02:23:28 -05:00
poduck
11bb83a85d feat: Add comprehensive help documentation system and plugin creation page
- Add CreatePlugin.tsx page for custom plugin creation with code editor
- Add HelpButton component for contextual help links
- Create 21 new help pages covering all dashboard features:
  - Core: Dashboard, Scheduler, Tasks
  - Manage: Customers, Services, Resources, Staff
  - Communicate: Messages (Ticketing already existed)
  - Money: Payments
  - Extend: Plugins overview and creation guide
  - Settings: General, Resource Types, Booking, Appearance, Email, Domains, API, Auth, Billing, Quota
- Update HelpGuide.tsx as main documentation hub with quick start guide
- Add routes for all help pages in App.tsx
- Add HelpButton to Dashboard, Customers, Services, and Tasks pages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 02:18:05 -05:00
804 changed files with 194376 additions and 6103 deletions

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)

179
PLAN_HELP_DOCS.md Normal file
View File

@@ -0,0 +1,179 @@
# Help Documentation Implementation Plan
## Overview
This plan covers creating comprehensive help documentation for the SmoothSchedule business dashboard, adding contextual help buttons to each page, and creating a monolithic help document.
## Phase 1: Create Plugin Page First (User Request)
### Task 1.1: Create CreatePlugin.tsx Page
- Create `/frontend/src/pages/CreatePlugin.tsx`
- Features:
- Name, description, short description fields
- Category dropdown (EMAIL, REPORTS, CUSTOMER, BOOKING, INTEGRATION, AUTOMATION, OTHER)
- Plugin code editor with syntax highlighting (using same Prism setup as HelpPluginDocs)
- Template variables preview (auto-extracted from code)
- Version field (default 1.0.0)
- Logo URL field (optional)
- Save as Private / Submit to Marketplace options
- Visibility selector (PRIVATE, PUBLIC)
- Uses API endpoint: `POST /api/plugin-templates/`
- Plan feature gate: `can_create_plugins`
### Task 1.2: Add Route for CreatePlugin
- Add lazy import: `const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin'));`
- Add route: `/plugins/create` pointing to CreatePlugin component
## Phase 2: Create Reusable HelpButton Component
### Task 2.1: Create HelpButton Component
- Create `/frontend/src/components/HelpButton.tsx`
- Props: `helpPath: string` (route to help page)
- Renders: HelpCircle icon button at fixed position (top-right of page)
- Styling: Circular button with question mark icon, tooltip on hover
- Uses Link from react-router-dom to navigate to help page
## Phase 3: Create Individual Help Pages
### 3.1 Core Pages Help
| Page | Help File | Route |
|------|-----------|-------|
| Dashboard | HelpDashboard.tsx | /help/dashboard |
| Scheduler | HelpScheduler.tsx | /help/scheduler |
| Tasks | HelpTasks.tsx | /help/tasks |
### 3.2 Manage Section Help
| Page | Help File | Route |
|------|-----------|-------|
| Customers | HelpCustomers.tsx | /help/customers |
| Services | HelpServices.tsx | /help/services |
| Resources | HelpResources.tsx | /help/resources |
| Staff | HelpStaff.tsx | /help/staff |
### 3.3 Communicate Section Help
| Page | Help File | Route |
|------|-----------|-------|
| Messages | HelpMessages.tsx | /help/messages |
| Tickets | HelpTicketing.tsx (exists) | /help/ticketing |
### 3.4 Money Section Help
| Page | Help File | Route |
|------|-----------|-------|
| Payments | HelpPayments.tsx | /help/payments |
### 3.5 Extend Section Help
| Page | Help File | Route |
|------|-----------|-------|
| Plugins | HelpPluginsOverview.tsx | /help/plugins-overview |
| Plugin Marketplace | (link to existing HelpPluginDocs) | /help/plugins |
| My Plugins | HelpMyPlugins.tsx | /help/my-plugins |
| Create Plugin | HelpCreatePlugin.tsx | /help/create-plugin |
### 3.6 Settings Section Help
| Page | Help File | Route |
|------|-----------|-------|
| General | HelpSettingsGeneral.tsx | /help/settings/general |
| Resource Types | HelpSettingsResourceTypes.tsx | /help/settings/resource-types |
| Booking | HelpSettingsBooking.tsx | /help/settings/booking |
| Appearance | HelpSettingsAppearance.tsx | /help/settings/appearance |
| Email Templates | HelpSettingsEmailTemplates.tsx | /help/settings/email-templates |
| Custom Domains | HelpSettingsCustomDomains.tsx | /help/settings/custom-domains |
| API & Webhooks | HelpSettingsApi.tsx | /help/settings/api |
| Authentication | HelpSettingsAuth.tsx | /help/settings/authentication |
| Email Setup | HelpEmailSettings.tsx (exists) | /help/email-settings |
| SMS & Calling | HelpSettingsSmsCalling.tsx | /help/settings/sms-calling |
| Plan & Billing | HelpSettingsBilling.tsx | /help/settings/billing |
| Quota Management | HelpSettingsQuota.tsx | /help/settings/quota |
## Phase 4: Add HelpButton to Each Page
Add the HelpButton component to the top-right of each dashboard page, linking to its corresponding help page.
## Phase 5: Update HelpPluginDocs
### Task 5.1: Review and Update Plugin Documentation
- Verify plugin documentation matches current codebase
- Add section for "Creating Custom Plugins"
- Add links to API documentation
- Ensure examples work with current API
## Phase 6: Create Monolithic Help Document
### Task 6.1: Create HelpGuideComplete.tsx
- Compile all help content into single comprehensive page
- Table of contents with anchor links
- Searchable content
- Organized by sections (Core, Manage, Communicate, Money, Extend, Settings)
### Task 6.2: Update HelpGuide.tsx
- Replace "Coming Soon" with actual compiled documentation
- Or redirect to HelpGuideComplete
## Phase 7: Register All Routes
Add all new help page routes to App.tsx in the business dashboard section.
## Help Page Template Structure
Each help page should follow this structure:
```tsx
- Header with icon and title
- Overview/Introduction
- Key Features section
- How to Use section (step-by-step)
- Benefits section
- Tips & Best Practices
- Related Features (links to other help pages)
- Need More Help? (link to support/tickets)
```
## Implementation Order
1. Create CreatePlugin.tsx page and route
2. Create HelpButton component
3. Create help pages for core pages (Dashboard, Scheduler, Tasks)
4. Create help pages for Manage section
5. Create help pages for Communicate section
6. Create help pages for Money section
7. Create help pages for Extend section (including plugin docs update)
8. Create help pages for Settings section
9. Add HelpButton to all pages
10. Create monolithic help document
11. Test all help pages and navigation
## Files to Create
### New Components
- `/frontend/src/components/HelpButton.tsx`
### New Pages
- `/frontend/src/pages/CreatePlugin.tsx`
- `/frontend/src/pages/help/HelpDashboard.tsx`
- `/frontend/src/pages/help/HelpScheduler.tsx`
- `/frontend/src/pages/help/HelpTasks.tsx`
- `/frontend/src/pages/help/HelpCustomers.tsx`
- `/frontend/src/pages/help/HelpServices.tsx`
- `/frontend/src/pages/help/HelpResources.tsx`
- `/frontend/src/pages/help/HelpStaff.tsx`
- `/frontend/src/pages/help/HelpMessages.tsx`
- `/frontend/src/pages/help/HelpPayments.tsx`
- `/frontend/src/pages/help/HelpPluginsOverview.tsx`
- `/frontend/src/pages/help/HelpMyPlugins.tsx`
- `/frontend/src/pages/help/HelpCreatePlugin.tsx`
- `/frontend/src/pages/help/HelpSettingsGeneral.tsx`
- `/frontend/src/pages/help/HelpSettingsResourceTypes.tsx`
- `/frontend/src/pages/help/HelpSettingsBooking.tsx`
- `/frontend/src/pages/help/HelpSettingsAppearance.tsx`
- `/frontend/src/pages/help/HelpSettingsEmailTemplates.tsx`
- `/frontend/src/pages/help/HelpSettingsCustomDomains.tsx`
- `/frontend/src/pages/help/HelpSettingsApi.tsx`
- `/frontend/src/pages/help/HelpSettingsAuth.tsx`
- `/frontend/src/pages/help/HelpSettingsSmsCalling.tsx`
- `/frontend/src/pages/help/HelpSettingsBilling.tsx`
- `/frontend/src/pages/help/HelpSettingsQuota.tsx`
- `/frontend/src/pages/help/HelpGuideComplete.tsx`
### Files to Modify
- `/frontend/src/App.tsx` - Add routes
- `/frontend/src/pages/HelpPluginDocs.tsx` - Update with current codebase info
- `/frontend/src/pages/HelpGuide.tsx` - Replace Coming Soon
- All dashboard pages - Add HelpButton component

655
README.md
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

212
deploy.sh
View File

@@ -1,7 +1,15 @@
#!/bin/bash
# SmoothSchedule Production Deployment Script
# Usage: ./deploy.sh [server_user@server_host]
# Example: ./deploy.sh poduck@smoothschedule.com
# Usage: ./deploy.sh [server_user@server_host] [services...]
# Example: ./deploy.sh poduck@smoothschedule.com # Build all
# Example: ./deploy.sh poduck@smoothschedule.com traefik # Build only traefik
# Example: ./deploy.sh poduck@smoothschedule.com django nginx # Build django and nginx
#
# Available services: django, traefik, nginx, postgres, celeryworker, celerybeat, flower, awscli
# Use --no-migrate to skip migrations (useful for config-only changes like traefik)
#
# This script deploys from git repository, not local files.
# Changes must be committed and pushed before deploying.
set -e
@@ -11,13 +19,38 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
SERVER=${1:-"poduck@smoothschedule.com"}
PROJECT_DIR="/home/poduck/Desktop/smoothschedule2"
# Parse arguments
SERVER=""
SERVICES=""
SKIP_MIGRATE=false
for arg in "$@"; do
if [[ "$arg" == "--no-migrate" ]]; then
SKIP_MIGRATE=true
elif [[ -z "$SERVER" ]]; then
SERVER="$arg"
else
SERVICES="$SERVICES $arg"
fi
done
SERVER=${SERVER:-"poduck@smoothschedule.com"}
SERVICES=$(echo "$SERVICES" | xargs) # Trim whitespace
REPO_URL="https://git.talova.net/poduck/smoothschedule.git"
REMOTE_DIR="/home/poduck/smoothschedule"
echo -e "${GREEN}==================================="
echo "SmoothSchedule Deployment"
echo "===================================${NC}"
echo "Target server: $SERVER"
if [[ -n "$SERVICES" ]]; then
echo "Services to rebuild: $SERVICES"
else
echo "Services to rebuild: ALL"
fi
if [[ "$SKIP_MIGRATE" == "true" ]]; then
echo "Migrations: SKIPPED"
fi
echo ""
# Function to print status
@@ -33,80 +66,141 @@ print_error() {
echo -e "${RED}>>> $1${NC}"
}
# Step 1: Build frontend
print_status "Step 1: Skipping local build (building on server)..."
# cd "$PROJECT_DIR/frontend"
# if [ ! -d "node_modules" ]; then
# print_warning "Installing frontend dependencies..."
# npm install
# fi
# npm run build
# print_status "Frontend build complete!"
# Step 1: Check for uncommitted changes
print_status "Step 1: Checking for uncommitted changes..."
if [[ -n $(git status --porcelain) ]]; then
print_error "You have uncommitted changes. Please commit and push before deploying."
git status --short
exit 1
fi
# Step 2: Prepare deployment package
print_status "Step 2: Preparing deployment package..."
cd "$PROJECT_DIR"
mkdir -p /tmp/smoothschedule-deploy
# Check if local is ahead of remote
LOCAL_COMMIT=$(git rev-parse HEAD)
REMOTE_COMMIT=$(git rev-parse @{u} 2>/dev/null || echo "")
# Copy backend
rsync -av --exclude='.venv' --exclude='__pycache__' --exclude='*.pyc' \
--exclude='.git' --exclude='node_modules' \
"$PROJECT_DIR/smoothschedule/" /tmp/smoothschedule-deploy/backend/
if [[ -z "$REMOTE_COMMIT" ]]; then
print_error "No upstream branch configured. Please push your changes first."
exit 1
fi
# Copy frontend source
rsync -av --exclude='node_modules' --exclude='dist' --exclude='.git' \
"$PROJECT_DIR/frontend/" /tmp/smoothschedule-deploy/frontend/
if [[ "$LOCAL_COMMIT" != "$REMOTE_COMMIT" ]]; then
print_warning "Local branch differs from remote. Checking if ahead..."
AHEAD=$(git rev-list --count @{u}..HEAD)
if [[ "$AHEAD" -gt 0 ]]; then
print_error "You have $AHEAD unpushed commit(s). Please push before deploying."
exit 1
fi
fi
print_status "Deployment package prepared!"
print_status "All changes committed and pushed!"
# Step 3: Upload to server
print_status "Step 3: Uploading to server..."
ssh "$SERVER" "mkdir -p ~/smoothschedule"
# Step 2: Deploy on server
print_status "Step 2: Deploying on server..."
# Upload backend
rsync -avz --delete /tmp/smoothschedule-deploy/backend/ "$SERVER:~/smoothschedule/"
# Upload nginx config
scp "$PROJECT_DIR/frontend/nginx.conf" "$SERVER:~/smoothschedule/nginx.conf"
# Upload frontend
rsync -avz --delete /tmp/smoothschedule-deploy/frontend/ "$SERVER:~/smoothschedule-frontend/"
print_status "Files uploaded!"
# Step 4: Deploy on server
print_status "Step 4: Deploying on server..."
ssh "$SERVER" 'bash -s' << 'ENDSSH'
ssh "$SERVER" "bash -s" << ENDSSH
set -e
echo ">>> Navigating to project directory..."
cd ~/smoothschedule
echo ">>> Setting up project directory..."
echo ">>> Building Docker images..."
docker compose -f docker-compose.production.yml build
# Backup .envs if they exist (secrets not in git)
if [ -d "$REMOTE_DIR/smoothschedule/.envs" ]; then
echo ">>> Backing up .envs secrets..."
cp -r "$REMOTE_DIR/smoothschedule/.envs" /tmp/.envs-backup
elif [ -d "$REMOTE_DIR/.envs" ]; then
# Old structure - .envs was at root level
echo ">>> Backing up .envs secrets (old location)..."
cp -r "$REMOTE_DIR/.envs" /tmp/.envs-backup
fi
# Backup .ssh if it exists (SSH keys not in git)
if [ -d "$REMOTE_DIR/smoothschedule/.ssh" ]; then
echo ">>> Backing up .ssh keys..."
cp -r "$REMOTE_DIR/smoothschedule/.ssh" /tmp/.ssh-backup
elif [ -d "$REMOTE_DIR/.ssh" ]; then
# Old structure
echo ">>> Backing up .ssh keys (old location)..."
cp -r "$REMOTE_DIR/.ssh" /tmp/.ssh-backup
fi
if [ ! -d "$REMOTE_DIR/.git" ]; then
echo ">>> Cloning repository for the first time..."
# Remove old non-git deployment if exists
if [ -d "$REMOTE_DIR" ]; then
rm -rf "$REMOTE_DIR"
fi
git clone "$REPO_URL" "$REMOTE_DIR"
else
echo ">>> Repository exists, pulling latest changes..."
cd "$REMOTE_DIR"
git fetch origin
git reset --hard origin/main
fi
cd "$REMOTE_DIR"
# Restore .envs secrets
if [ -d /tmp/.envs-backup ] && [ "$(ls -A /tmp/.envs-backup 2>/dev/null)" ]; then
echo ">>> Restoring .envs secrets..."
mkdir -p "$REMOTE_DIR/smoothschedule/.envs"
cp -r /tmp/.envs-backup/* "$REMOTE_DIR/smoothschedule/.envs/"
rm -rf /tmp/.envs-backup
fi
# Restore .ssh keys
if [ -d /tmp/.ssh-backup ] && [ "$(ls -A /tmp/.ssh-backup 2>/dev/null)" ]; then
echo ">>> Restoring .ssh keys..."
mkdir -p "$REMOTE_DIR/smoothschedule/.ssh"
cp -r /tmp/.ssh-backup/* "$REMOTE_DIR/smoothschedule/.ssh/"
rm -rf /tmp/.ssh-backup
fi
echo ">>> Current commit:"
git log -1 --oneline
cd smoothschedule
# Build images (all or specific services)
if [[ -n "$SERVICES" ]]; then
echo ">>> Building Docker images: $SERVICES..."
docker compose -f docker-compose.production.yml build $SERVICES
else
echo ">>> Building all Docker images..."
docker compose -f docker-compose.production.yml build
fi
echo ">>> Starting containers..."
docker compose -f docker-compose.production.yml up -d
echo ">>> Waiting for containers to start..."
sleep 10
sleep 5
echo ">>> Running database migrations..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} && python manage.py migrate'
# Run migrations unless skipped
if [[ "$SKIP_MIGRATE" != "true" ]]; then
echo ">>> Running database migrations..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} && python manage.py migrate'
echo ">>> Collecting static files..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} && python manage.py collectstatic --noinput'
echo ">>> Collecting static files..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} && python manage.py collectstatic --noinput'
echo ">>> Checking container status..."
docker compose -f docker-compose.production.yml ps
echo ">>> Seeding/updating platform plugins for all tenants..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} && python -c "
import django
django.setup()
from django_tenants.utils import get_tenant_model
from django.core.management import call_command
Tenant = get_tenant_model()
for tenant in Tenant.objects.exclude(schema_name=\"public\"):
print(f\" Seeding plugins for {tenant.schema_name}...\")
call_command(\"tenant_command\", \"seed_platform_plugins\", schema=tenant.schema_name, verbosity=0)
print(\" Done!\")
"'
else
echo ">>> Skipping migrations (--no-migrate flag used)"
fi
echo ">>> Deployment complete!"
ENDSSH
# Cleanup
rm -rf /tmp/smoothschedule-deploy
echo ""
print_status "==================================="
print_status "Deployment Complete!"
@@ -118,7 +212,7 @@ echo " - https://platform.smoothschedule.com"
echo " - https://*.smoothschedule.com (tenant subdomains)"
echo ""
echo "To view logs:"
echo " ssh $SERVER 'cd ~/smoothschedule && docker compose -f docker-compose.production.yml logs -f'"
echo " ssh $SERVER 'cd ~/smoothschedule/smoothschedule && docker compose -f docker-compose.production.yml logs -f'"
echo ""
echo "To check status:"
echo " ssh $SERVER 'cd ~/smoothschedule && docker compose -f docker-compose.production.yml ps'"
echo " ssh $SERVER 'cd ~/smoothschedule/smoothschedule && docker compose -f docker-compose.production.yml ps'"

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,11 +6,14 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@measured/puck": "^0.20.2",
"@react-google-maps/api": "^2.20.7",
"@stripe/connect-js": "^3.3.31",
"@stripe/react-connect-js": "^3.3.31",
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.5.3",
"@tanstack/react-query": "^5.90.10",
"@types/react-grid-layout": "^1.3.6",
"@types/react-syntax-highlighter": "^15.5.13",
"axios": "^1.13.2",
"date-fns": "^4.1.0",
@@ -21,6 +24,7 @@
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-grid-layout": "^1.5.2",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.3.5",
"react-phone-number-input": "^3.4.14",
@@ -32,27 +36,38 @@
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.48.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.15",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^27.2.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^4.0.15"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed"
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed"
}
}

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,17 +35,23 @@ const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfSer
// Import pages
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const StaffDashboard = React.lazy(() => import('./pages/StaffDashboard'));
const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule'));
const Scheduler = React.lazy(() => import('./pages/Scheduler'));
const Customers = React.lazy(() => import('./pages/Customers'));
const Settings = React.lazy(() => import('./pages/Settings'));
const Payments = React.lazy(() => import('./pages/Payments'));
const Messages = React.lazy(() => import('./pages/Messages'));
const Resources = React.lazy(() => import('./pages/Resources'));
const Services = React.lazy(() => import('./pages/Services'));
const Staff = React.lazy(() => import('./pages/Staff'));
const TimeBlocks = React.lazy(() => import('./pages/TimeBlocks'));
const MyAvailability = React.lazy(() => import('./pages/MyAvailability'));
const CustomerDashboard = React.lazy(() => import('./pages/customer/CustomerDashboard'));
const CustomerSupport = React.lazy(() => import('./pages/customer/CustomerSupport'));
const ResourceDashboard = React.lazy(() => import('./pages/resource/ResourceDashboard'));
const BookingPage = React.lazy(() => import('./pages/customer/BookingPage'));
const CustomerBilling = React.lazy(() => import('./pages/customer/CustomerBilling'));
const TrialExpired = React.lazy(() => import('./pages/TrialExpired'));
const Upgrade = React.lazy(() => import('./pages/Upgrade'));
@@ -61,17 +68,50 @@ const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
const AcceptInvitePage = React.lazy(() => import('./pages/AcceptInvitePage'));
const TenantOnboardPage = React.lazy(() => import('./pages/TenantOnboardPage'));
const TenantLandingPage = React.lazy(() => import('./pages/TenantLandingPage'));
const Tickets = React.lazy(() => import('./pages/Tickets')); // Import Tickets page
const HelpGuide = React.lazy(() => import('./pages/HelpGuide')); // Import Platform Guide page
const HelpTicketing = React.lazy(() => import('./pages/HelpTicketing')); // Import Help page for ticketing
const HelpApiDocs = React.lazy(() => import('./pages/HelpApiDocs')); // Import API documentation page
const HelpPluginDocs = React.lazy(() => import('./pages/HelpPluginDocs')); // Import Plugin documentation page
const HelpPluginDocs = React.lazy(() => import('./pages/help/HelpPluginDocs')); // Import Plugin documentation page
const HelpEmailSettings = React.lazy(() => import('./pages/HelpEmailSettings')); // Import Email settings help page
// Import new help pages
const HelpDashboard = React.lazy(() => import('./pages/help/HelpDashboard'));
const HelpScheduler = React.lazy(() => import('./pages/help/HelpScheduler'));
const HelpTasks = React.lazy(() => import('./pages/help/HelpTasks'));
const HelpCustomers = React.lazy(() => import('./pages/help/HelpCustomers'));
const HelpServices = React.lazy(() => import('./pages/help/HelpServices'));
const HelpResources = React.lazy(() => import('./pages/help/HelpResources'));
const HelpStaff = React.lazy(() => import('./pages/help/HelpStaff'));
const HelpTimeBlocks = React.lazy(() => import('./pages/HelpTimeBlocks'));
const HelpMessages = React.lazy(() => import('./pages/help/HelpMessages'));
const HelpPayments = React.lazy(() => import('./pages/help/HelpPayments'));
const HelpContracts = React.lazy(() => import('./pages/help/HelpContracts'));
const HelpPlugins = React.lazy(() => import('./pages/help/HelpPlugins'));
const HelpSettingsGeneral = React.lazy(() => import('./pages/help/HelpSettingsGeneral'));
const HelpSettingsResourceTypes = React.lazy(() => import('./pages/help/HelpSettingsResourceTypes'));
const HelpSettingsBooking = React.lazy(() => import('./pages/help/HelpSettingsBooking'));
const HelpSettingsAppearance = React.lazy(() => import('./pages/help/HelpSettingsAppearance'));
const HelpSettingsEmail = React.lazy(() => import('./pages/help/HelpSettingsEmail'));
const HelpSettingsDomains = React.lazy(() => import('./pages/help/HelpSettingsDomains'));
const HelpSettingsApi = React.lazy(() => import('./pages/help/HelpSettingsApi'));
const HelpSettingsAuth = React.lazy(() => import('./pages/help/HelpSettingsAuth'));
const HelpSettingsBilling = React.lazy(() => import('./pages/help/HelpSettingsBilling'));
const HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuota'));
const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive'));
const StaffHelp = React.lazy(() => import('./pages/help/StaffHelp'));
const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule)
const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin')); // Import Create Plugin page
const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled plugin executions
const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Import Email Templates page
const Contracts = React.lazy(() => import('./pages/Contracts')); // Import Contracts page
const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates')); // Import Contract Templates page
const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public)
const PageEditor = React.lazy(() => import('./pages/PageEditor')); // Import PageEditor
const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import PublicPage
// Settings pages
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
@@ -159,6 +199,7 @@ const AppContent: React.FC = () => {
const updateBusinessMutation = useUpdateBusiness();
const masqueradeMutation = useMasquerade();
const logoutMutation = useLogout();
const { canUse } = usePlanFeatures();
// Apply dark mode class and persist to localStorage
React.useEffect(() => {
@@ -166,6 +207,30 @@ const AppContent: React.FC = () => {
localStorage.setItem('darkMode', JSON.stringify(darkMode));
}, [darkMode]);
// Set noindex/nofollow for app subdomains (platform, business subdomains)
// Only the root domain marketing pages should be indexed
React.useEffect(() => {
const hostname = window.location.hostname;
const parts = hostname.split('.');
const hasSubdomain = parts.length > 2 || (parts.length === 2 && parts[0] !== 'localhost');
// Check if we're on a subdomain (platform.*, demo.*, etc.)
const isSubdomain = hostname !== 'localhost' && hostname !== '127.0.0.1' && parts.length > 2;
if (isSubdomain) {
// Always noindex/nofollow on subdomains (app areas)
let metaRobots = document.querySelector('meta[name="robots"]');
if (metaRobots) {
metaRobots.setAttribute('content', 'noindex, nofollow');
} else {
metaRobots = document.createElement('meta');
metaRobots.setAttribute('name', 'robots');
metaRobots.setAttribute('content', 'noindex, nofollow');
document.head.appendChild(metaRobots);
}
}
}, []);
// Handle tokens in URL (from login or masquerade redirect)
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
@@ -255,31 +320,50 @@ const AppContent: React.FC = () => {
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
);
}
// Not authenticated - redirect to root domain for login if on subdomain
// Not authenticated - show appropriate page based on subdomain
if (!user) {
// If on a subdomain, redirect to root domain login page
const currentHostname = window.location.hostname;
const hostnameParts = currentHostname.split('.');
const baseDomain = hostnameParts.length >= 2
? hostnameParts.slice(-2).join('.')
: currentHostname;
const isRootDomainForUnauthUser = currentHostname === baseDomain || currentHostname === 'localhost';
const isPlatformSubdomain = hostnameParts[0] === 'platform';
const currentSubdomain = hostnameParts[0];
if (!isRootDomainForUnauthUser) {
// Redirect to root domain login (preserve port)
const protocol = window.location.protocol;
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `${protocol}//${baseDomain}${port}/login`;
return <LoadingScreen />;
// Check if we're on a business subdomain (not root, not platform, not api)
const isBusinessSubdomain = !isRootDomainForUnauthUser && !isPlatformSubdomain && currentSubdomain !== 'api';
// For business subdomains, show the tenant landing page with login option
if (isBusinessSubdomain) {
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/" element={<PublicPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
);
}
// For root domain or platform subdomain, show marketing site / login
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
@@ -298,7 +382,9 @@ const AppContent: React.FC = () => {
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
@@ -470,7 +556,7 @@ const AppContent: React.FC = () => {
>
<Route path="/" element={<CustomerDashboard />} />
<Route path="/book" element={<BookingPage />} />
<Route path="/payments" element={<Payments />} />
<Route path="/payments" element={<CustomerBilling />} />
<Route path="/support" element={<CustomerSupport />} />
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
@@ -585,15 +671,59 @@ const AppContent: React.FC = () => {
{/* Regular Routes */}
<Route
path="/"
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
/>
{/* Staff Schedule - vertical timeline view */}
<Route
path="/my-schedule"
element={
hasAccess(['staff']) ? (
<StaffSchedule user={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/tickets" element={<Tickets />} />
<Route
path="/help"
element={
user.role === 'staff' ? (
<StaffHelp user={user} />
) : (
<HelpComprehensive />
)
}
/>
<Route path="/help/guide" element={<HelpGuide />} />
<Route path="/help/ticketing" element={<HelpTicketing />} />
<Route path="/help/api" element={<HelpApiDocs />} />
<Route path="/help/plugins" element={<HelpPluginDocs />} />
<Route path="/help/plugins/docs" element={<HelpPluginDocs />} />
<Route path="/help/email" element={<HelpEmailSettings />} />
{/* New help pages */}
<Route path="/help/dashboard" element={<HelpDashboard />} />
<Route path="/help/scheduler" element={<HelpScheduler />} />
<Route path="/help/tasks" element={<HelpTasks />} />
<Route path="/help/customers" element={<HelpCustomers />} />
<Route path="/help/services" element={<HelpServices />} />
<Route path="/help/resources" element={<HelpResources />} />
<Route path="/help/staff" element={<HelpStaff />} />
<Route path="/help/time-blocks" element={<HelpTimeBlocks />} />
<Route path="/help/messages" element={<HelpMessages />} />
<Route path="/help/payments" element={<HelpPayments />} />
<Route path="/help/contracts" element={<HelpContracts />} />
<Route path="/help/plugins" element={<HelpPlugins />} />
<Route path="/help/settings/general" element={<HelpSettingsGeneral />} />
<Route path="/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
<Route path="/help/settings/booking" element={<HelpSettingsBooking />} />
<Route path="/help/settings/appearance" element={<HelpSettingsAppearance />} />
<Route path="/help/settings/email" element={<HelpSettingsEmail />} />
<Route path="/help/settings/domains" element={<HelpSettingsDomains />} />
<Route path="/help/settings/api" element={<HelpSettingsApi />} />
<Route path="/help/settings/auth" element={<HelpSettingsAuth />} />
<Route path="/help/settings/billing" element={<HelpSettingsBilling />} />
<Route path="/help/settings/quota" element={<HelpSettingsQuota />} />
<Route
path="/plugins/marketplace"
element={
@@ -614,6 +744,16 @@ const AppContent: React.FC = () => {
)
}
/>
<Route
path="/plugins/create"
element={
hasAccess(['owner', 'manager']) ? (
<CreatePlugin />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/tasks"
element={
@@ -638,7 +778,7 @@ const AppContent: React.FC = () => {
<Route
path="/customers"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
hasAccess(['owner', 'manager']) ? (
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
@@ -648,7 +788,7 @@ const AppContent: React.FC = () => {
<Route
path="/services"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
hasAccess(['owner', 'manager']) ? (
<Services />
) : (
<Navigate to="/" />
@@ -658,7 +798,7 @@ const AppContent: React.FC = () => {
<Route
path="/resources"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
hasAccess(['owner', 'manager']) ? (
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
@@ -675,6 +815,46 @@ const AppContent: React.FC = () => {
)
}
/>
<Route
path="/time-blocks"
element={
hasAccess(['owner', 'manager']) ? (
<TimeBlocks />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/my-availability"
element={
hasAccess(['staff', 'resource']) ? (
<MyAvailability user={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/contracts"
element={
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
<Contracts />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/contracts/templates"
element={
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
<ContractTemplates />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/payments"
element={
@@ -683,12 +863,19 @@ const AppContent: React.FC = () => {
/>
<Route
path="/messages"
element={
hasAccess(['owner', 'manager']) && user?.can_send_messages ? (
<Messages />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/site-editor"
element={
hasAccess(['owner', 'manager']) ? (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Messages</h1>
<p className="text-gray-600">Messages feature coming soon...</p>
</div>
<PageEditor />
) : (
<Navigate to="/" />
)

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

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

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

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

View File

@@ -33,6 +33,24 @@ export interface PlatformBusiness {
can_use_custom_domain: boolean;
can_white_label: boolean;
can_api_access: boolean;
// Feature permissions (optional - returned by API but may not always be present in tests)
can_add_video_conferencing?: boolean;
can_connect_to_api?: boolean;
can_book_repeated_events?: boolean;
can_require_2fa?: boolean;
can_download_logs?: boolean;
can_delete_data?: boolean;
can_use_sms_reminders?: boolean;
can_use_masked_phone_numbers?: boolean;
can_use_pos?: boolean;
can_use_mobile_app?: boolean;
can_export_data?: boolean;
can_use_plugins?: boolean;
can_use_tasks?: boolean;
can_create_plugins?: boolean;
can_use_webhooks?: boolean;
can_use_calendar_sync?: boolean;
can_use_contracts?: boolean;
}
export interface PlatformBusinessUpdate {
@@ -41,11 +59,38 @@ export interface PlatformBusinessUpdate {
subscription_tier?: string;
max_users?: number;
max_resources?: number;
// Platform permissions
can_manage_oauth_credentials?: boolean;
can_accept_payments?: boolean;
can_use_custom_domain?: boolean;
can_white_label?: boolean;
can_api_access?: boolean;
// Feature permissions
can_add_video_conferencing?: boolean;
can_connect_to_api?: boolean;
can_book_repeated_events?: boolean;
can_require_2fa?: boolean;
can_download_logs?: boolean;
can_delete_data?: boolean;
can_use_sms_reminders?: boolean;
can_use_masked_phone_numbers?: boolean;
can_use_pos?: boolean;
can_use_mobile_app?: boolean;
can_export_data?: boolean;
can_use_plugins?: boolean;
can_use_tasks?: boolean;
can_create_plugins?: boolean;
can_use_webhooks?: boolean;
can_use_calendar_sync?: boolean;
can_use_contracts?: boolean;
can_process_refunds?: boolean;
can_create_packages?: boolean;
can_use_email_templates?: boolean;
can_customize_booking_page?: boolean;
advanced_reporting?: boolean;
priority_support?: boolean;
dedicated_support?: boolean;
sso_enabled?: boolean;
}
export interface PlatformBusinessCreate {
@@ -116,6 +161,14 @@ export const createBusiness = async (
return response.data;
};
/**
* Delete a business/tenant (platform admin only)
* This permanently deletes the tenant and all associated data
*/
export const deleteBusiness = async (businessId: number): Promise<void> => {
await apiClient.delete(`/platform/businesses/${businessId}/`);
};
/**
* Get all users (platform admin only)
*/

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

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

@@ -0,0 +1,98 @@
/**
* FloatingHelpButton Component
*
* A floating help button fixed in the top-right corner of the screen.
* Automatically determines the help path based on the current route.
*/
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { HelpCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
// Map routes to their help paths
const routeToHelpPath: Record<string, string> = {
'/': '/help/dashboard',
'/dashboard': '/help/dashboard',
'/scheduler': '/help/scheduler',
'/tasks': '/help/tasks',
'/customers': '/help/customers',
'/services': '/help/services',
'/resources': '/help/resources',
'/staff': '/help/staff',
'/time-blocks': '/help/time-blocks',
'/my-availability': '/help/time-blocks',
'/messages': '/help/messages',
'/tickets': '/help/ticketing',
'/payments': '/help/payments',
'/contracts': '/help/contracts',
'/contracts/templates': '/help/contracts',
'/plugins': '/help/plugins',
'/plugins/marketplace': '/help/plugins',
'/plugins/my-plugins': '/help/plugins',
'/plugins/create': '/help/plugins/create',
'/settings': '/help/settings/general',
'/settings/general': '/help/settings/general',
'/settings/resource-types': '/help/settings/resource-types',
'/settings/booking': '/help/settings/booking',
'/settings/appearance': '/help/settings/appearance',
'/settings/email': '/help/settings/email',
'/settings/domains': '/help/settings/domains',
'/settings/api': '/help/settings/api',
'/settings/auth': '/help/settings/auth',
'/settings/billing': '/help/settings/billing',
'/settings/quota': '/help/settings/quota',
// Platform routes
'/platform/dashboard': '/help/dashboard',
'/platform/businesses': '/help/dashboard',
'/platform/users': '/help/staff',
'/platform/tickets': '/help/ticketing',
};
const FloatingHelpButton: React.FC = () => {
const { t } = useTranslation();
const location = useLocation();
// Get the help path for the current route
const getHelpPath = (): string => {
// Exact match first
if (routeToHelpPath[location.pathname]) {
return routeToHelpPath[location.pathname];
}
// Try matching with a prefix (for dynamic routes like /customers/:id)
const pathSegments = location.pathname.split('/').filter(Boolean);
if (pathSegments.length > 0) {
// Try progressively shorter paths
for (let i = pathSegments.length; i > 0; i--) {
const testPath = '/' + pathSegments.slice(0, i).join('/');
if (routeToHelpPath[testPath]) {
return routeToHelpPath[testPath];
}
}
}
// Default to the main help guide
return '/help';
};
const helpPath = getHelpPath();
// Don't show on help pages themselves
if (location.pathname.startsWith('/help')) {
return null;
}
return (
<Link
to={helpPath}
className="fixed top-20 right-4 z-50 inline-flex items-center justify-center w-10 h-10 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full shadow-lg border border-gray-200 dark:border-gray-700 transition-all duration-200 hover:scale-110"
title={t('common.help', 'Help')}
aria-label={t('common.help', 'Help')}
>
<HelpCircle size={20} />
</Link>
);
};
export default FloatingHelpButton;

View File

@@ -0,0 +1,33 @@
/**
* HelpButton Component
*
* A contextual help button that appears at the top-right of pages
* and links to the relevant help documentation.
*/
import React from 'react';
import { Link } from 'react-router-dom';
import { HelpCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface HelpButtonProps {
helpPath: string;
className?: string;
}
const HelpButton: React.FC<HelpButtonProps> = ({ helpPath, className = '' }) => {
const { t } = useTranslation();
return (
<Link
to={helpPath}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors ${className}`}
title={t('common.help', 'Help')}
>
<HelpCircle size={18} />
<span className="hidden sm:inline">{t('common.help', 'Help')}</span>
</Link>
);
};
export default HelpButton;

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('/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`}>

View File

@@ -0,0 +1,268 @@
/**
* QuotaOverageModal Component
*
* Modal that appears on login/masquerade when the tenant has exceeded quotas.
* Shows warning about grace period and what will happen when it expires.
* Uses sessionStorage to only show once per session.
*/
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
AlertTriangle,
X,
Clock,
Archive,
ChevronRight,
Users,
Layers,
Briefcase,
Mail,
Zap,
} from 'lucide-react';
import { QuotaOverage } from '../api/auth';
interface QuotaOverageModalProps {
overages: QuotaOverage[];
onDismiss: () => void;
}
const QUOTA_ICONS: Record<string, React.ReactNode> = {
'MAX_ADDITIONAL_USERS': <Users className="w-5 h-5" />,
'MAX_RESOURCES': <Layers className="w-5 h-5" />,
'MAX_SERVICES': <Briefcase className="w-5 h-5" />,
'MAX_EMAIL_TEMPLATES': <Mail className="w-5 h-5" />,
'MAX_AUTOMATED_TASKS': <Zap className="w-5 h-5" />,
};
const SESSION_STORAGE_KEY = 'quota_overage_modal_dismissed';
const QuotaOverageModal: React.FC<QuotaOverageModalProps> = ({ overages, onDismiss }) => {
const { t } = useTranslation();
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// Check if already dismissed this session
const dismissed = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (!dismissed && overages && overages.length > 0) {
setIsVisible(true);
}
}, [overages]);
const handleDismiss = () => {
sessionStorage.setItem(SESSION_STORAGE_KEY, 'true');
setIsVisible(false);
onDismiss();
};
if (!isVisible || !overages || overages.length === 0) {
return null;
}
// Find the most urgent overage (least days remaining)
const mostUrgent = overages.reduce((prev, curr) =>
curr.days_remaining < prev.days_remaining ? curr : prev
);
const isCritical = mostUrgent.days_remaining <= 1;
const isUrgent = mostUrgent.days_remaining <= 7;
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-lg w-full overflow-hidden">
{/* Header */}
<div className={`px-6 py-4 ${
isCritical
? 'bg-red-600'
: isUrgent
? 'bg-amber-500'
: 'bg-amber-100 dark:bg-amber-900/30'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-full ${
isCritical || isUrgent
? 'bg-white/20'
: 'bg-amber-200 dark:bg-amber-800'
}`}>
<AlertTriangle className={`w-6 h-6 ${
isCritical || isUrgent
? 'text-white'
: 'text-amber-700 dark:text-amber-300'
}`} />
</div>
<div>
<h2 className={`text-lg font-bold ${
isCritical || isUrgent
? 'text-white'
: 'text-amber-900 dark:text-amber-100'
}`}>
{isCritical
? t('quota.modal.titleCritical', 'Action Required Immediately!')
: isUrgent
? t('quota.modal.titleUrgent', 'Action Required Soon')
: t('quota.modal.title', 'Quota Exceeded')
}
</h2>
<p className={`text-sm ${
isCritical || isUrgent
? 'text-white/90'
: 'text-amber-700 dark:text-amber-200'
}`}>
{mostUrgent.days_remaining <= 0
? t('quota.modal.subtitleExpired', 'Grace period has expired')
: mostUrgent.days_remaining === 1
? t('quota.modal.subtitleOneDay', '1 day remaining')
: t('quota.modal.subtitle', '{{days}} days remaining', { days: mostUrgent.days_remaining })
}
</p>
</div>
</div>
<button
onClick={handleDismiss}
className={`p-2 rounded-lg transition-colors ${
isCritical || isUrgent
? 'hover:bg-white/20 text-white'
: 'hover:bg-amber-200 dark:hover:bg-amber-800 text-amber-700 dark:text-amber-300'
}`}
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-5">
{/* Main message */}
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Clock className="w-5 h-5 text-gray-500 dark:text-gray-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-gray-700 dark:text-gray-300">
<p className="font-medium mb-1">
{t('quota.modal.gracePeriodEnds', 'Grace period ends on {{date}}', {
date: formatDate(mostUrgent.grace_period_ends_at)
})}
</p>
<p>
{t('quota.modal.explanation',
'Your account has exceeded its plan limits. Please remove or archive excess items before the grace period ends, or they will be automatically archived.'
)}
</p>
</div>
</div>
{/* Overage list */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('quota.modal.overagesTitle', 'Items Over Quota')}
</h3>
<div className="space-y-2">
{overages.map((overage) => (
<div
key={overage.id}
className={`flex items-center justify-between p-3 rounded-lg border ${
overage.days_remaining <= 1
? 'border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
: overage.days_remaining <= 7
? 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20'
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/30'
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${
overage.days_remaining <= 1
? 'bg-red-100 dark:bg-red-800/50 text-red-600 dark:text-red-400'
: overage.days_remaining <= 7
? 'bg-amber-100 dark:bg-amber-800/50 text-amber-600 dark:text-amber-400'
: 'bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300'
}`}>
{QUOTA_ICONS[overage.quota_type] || <Layers className="w-5 h-5" />}
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">
{overage.display_name}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('quota.modal.usageInfo', '{{current}} used / {{limit}} allowed', {
current: overage.current_usage,
limit: overage.allowed_limit
})}
</p>
</div>
</div>
<div className="text-right">
<p className={`font-bold ${
overage.days_remaining <= 1
? 'text-red-600 dark:text-red-400'
: overage.days_remaining <= 7
? 'text-amber-600 dark:text-amber-400'
: 'text-gray-600 dark:text-gray-300'
}`}>
+{overage.overage_amount}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('quota.modal.overLimit', 'over limit')}
</p>
</div>
</div>
))}
</div>
</div>
{/* What happens section */}
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<Archive className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">
{t('quota.modal.whatHappens', 'What happens if I don\'t take action?')}
</p>
<p>
{t('quota.modal.autoArchiveExplanation',
'After the grace period ends, the oldest items over your limit will be automatically archived. Archived items remain in your account but cannot be used until you upgrade or remove other items.'
)}
</p>
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between gap-3">
<button
onClick={handleDismiss}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
{t('quota.modal.dismissButton', 'Remind Me Later')}
</button>
<Link
to="/settings/quota"
onClick={handleDismiss}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
{t('quota.modal.manageButton', 'Manage Quota')}
<ChevronRight className="w-4 h-4" />
</Link>
</div>
</div>
</div>
);
};
export default QuotaOverageModal;
/**
* Clear the session storage dismissal flag
* Call this when user logs out or masquerade changes
*/
export const resetQuotaOverageModalDismissal = () => {
sessionStorage.removeItem(SESSION_STORAGE_KEY);
};

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,14 @@ import {
HelpCircle,
Clock,
Plug,
FileSignature,
CalendarOff,
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
import { usePlanFeatures } from '../hooks/usePlanFeatures';
import SmoothScheduleLogo from './SmoothScheduleLogo';
import UnfinishedBadge from './ui/UnfinishedBadge';
import {
SidebarSection,
SidebarItem,
@@ -40,9 +43,11 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
const { canUse } = usePlanFeatures();
const canViewAdminPages = role === 'owner' || role === 'manager';
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
const canViewManagementPages = role === 'owner' || role === 'manager';
const isStaff = role === 'staff';
const canViewSettings = role === 'owner';
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
const canSendMessages = user.can_send_messages === true;
const handleSignOut = () => {
logoutMutation.mutate();
@@ -59,7 +64,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
<button
onClick={toggleCollapse}
className={`flex items-center gap-3 w-full text-left px-6 py-6 ${isCollapsed ? 'justify-center' : ''} hover:bg-white/5 transition-colors focus:outline-none`}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-label={isCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar')}
>
{business.logoDisplayMode === 'logo-only' && business.logoUrl ? (
<div className="flex items-center justify-center w-full">
@@ -108,18 +113,40 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
isCollapsed={isCollapsed}
exact
/>
<SidebarItem
to="/scheduler"
icon={CalendarDays}
label={t('nav.scheduler')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/tasks"
icon={Clock}
label={t('nav.tasks', 'Tasks')}
isCollapsed={isCollapsed}
/>
{!isStaff && (
<SidebarItem
to="/scheduler"
icon={CalendarDays}
label={t('nav.scheduler')}
isCollapsed={isCollapsed}
/>
)}
{!isStaff && (
<SidebarItem
to="/tasks"
icon={Clock}
label={t('nav.tasks', 'Tasks')}
isCollapsed={isCollapsed}
locked={!canUse('plugins') || !canUse('tasks')}
badgeElement={<UnfinishedBadge />}
/>
)}
{isStaff && (
<SidebarItem
to="/my-schedule"
icon={CalendarDays}
label={t('nav.mySchedule', 'My Schedule')}
isCollapsed={isCollapsed}
/>
)}
{(role === 'staff' || role === 'resource') && (
<SidebarItem
to="/my-availability"
icon={CalendarOff}
label={t('nav.myAvailability', 'My Availability')}
isCollapsed={isCollapsed}
/>
)}
</SidebarSection>
{/* Manage Section - Staff+ */}
@@ -130,6 +157,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
icon={Users}
label={t('nav.customers')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
<SidebarItem
to="/services"
@@ -144,20 +172,38 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
isCollapsed={isCollapsed}
/>
{canViewAdminPages && (
<SidebarItem
to="/staff"
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
/>
<>
<SidebarItem
to="/staff"
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
{canUse('contracts') && (
<SidebarItem
to="/contracts"
icon={FileSignature}
label={t('nav.contracts', 'Contracts')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
)}
<SidebarItem
to="/time-blocks"
icon={CalendarOff}
label={t('nav.timeBlocks', 'Time Blocks')}
isCollapsed={isCollapsed}
/>
</>
)}
</SidebarSection>
)}
{/* Communicate Section - Tickets + Messages */}
{(canViewTickets || canViewAdminPages) && (
{(canViewTickets || canSendMessages) && (
<SidebarSection title={t('nav.sections.communicate', 'Communicate')} isCollapsed={isCollapsed}>
{canViewAdminPages && (
{canSendMessages && (
<SidebarItem
to="/messages"
icon={MessageSquare}
@@ -193,11 +239,12 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{canViewAdminPages && (
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
<SidebarItem
to="/plugins/marketplace"
to="/plugins/my-plugins"
icon={Plug}
label={t('nav.plugins', 'Plugins')}
isCollapsed={isCollapsed}
locked={!canUse('plugins')}
badgeElement={<UnfinishedBadge />}
/>
</SidebarSection>
)}
@@ -233,7 +280,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
>
<SmoothScheduleLogo className="w-5 h-5 text-white" />
{!isCollapsed && (
<span className="text-white/60">Smooth Schedule</span>
<span className="text-white/60">{t('nav.smoothSchedule')}</span>
)}
</a>
<button

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

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

View File

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

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

View File

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

View File

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

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

View File

@@ -0,0 +1,511 @@
/**
* Unit tests for TrialBanner component
*
* Tests the trial status banner that appears at the top of the business layout.
* Covers:
* - Rendering with different days remaining
* - Urgent state (3 days or less)
* - Upgrade button navigation
* - Dismiss functionality
* - Hidden states (dismissed, not active, no days left)
* - Trial end date formatting
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import TrialBanner from '../TrialBanner';
import { Business } from '../../types';
// Mock react-router-dom's useNavigate
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Mock i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
// Simulate translation behavior
const translations: Record<string, string> = {
'trial.banner.title': 'Trial Active',
'trial.banner.daysLeft': `${params?.days} days left in trial`,
'trial.banner.expiresOn': `Trial expires on ${params?.date}`,
'trial.banner.upgradeNow': 'Upgrade Now',
'trial.banner.dismiss': 'Dismiss',
};
return translations[key] || key;
},
}),
}));
// Test data factory for Business objects
const createMockBusiness = (overrides?: Partial<Business>): Business => ({
id: '1',
name: 'Test Business',
subdomain: 'testbiz',
primaryColor: '#3B82F6',
secondaryColor: '#1E40AF',
whitelabelEnabled: false,
paymentsEnabled: true,
requirePaymentMethodToBook: false,
cancellationWindowHours: 24,
lateCancellationFeePercent: 50,
isTrialActive: true,
daysLeftInTrial: 10,
trialEnd: '2025-12-17T23:59:59Z',
...overrides,
});
// Wrapper component that provides router context
const renderWithRouter = (ui: React.ReactElement) => {
return render(<BrowserRouter>{ui}</BrowserRouter>);
};
describe('TrialBanner', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render banner with trial information when trial is active', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
trialEnd: '2025-12-17T23:59:59Z',
});
renderWithRouter(<TrialBanner business={business} />);
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
expect(screen.getByText(/10 days left in trial/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /upgrade now/i })).toBeInTheDocument();
});
it('should display the trial end date', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 5,
trialEnd: '2025-12-17T00:00:00Z',
});
renderWithRouter(<TrialBanner business={business} />);
// Check that the date is displayed (format may vary by locale)
expect(screen.getByText(/trial expires on/i)).toBeInTheDocument();
});
it('should render Sparkles icon when more than 3 days left', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 7,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// The Sparkles icon should be rendered (not the Clock icon)
// Check for the non-urgent styling
const banner = container.querySelector('.bg-gradient-to-r.from-blue-600');
expect(banner).toBeInTheDocument();
});
it('should render Clock icon with pulse animation when 3 days or less left', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 3,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// Check for urgent styling
const banner = container.querySelector('.bg-gradient-to-r.from-red-500');
expect(banner).toBeInTheDocument();
// Check for pulse animation on the icon
const pulsingIcon = container.querySelector('.animate-pulse');
expect(pulsingIcon).toBeInTheDocument();
});
it('should render Upgrade Now button with arrow icon', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
expect(upgradeButton).toBeInTheDocument();
expect(upgradeButton).toHaveClass('bg-white', 'text-blue-600');
});
it('should render dismiss button with aria-label', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
expect(dismissButton).toBeInTheDocument();
expect(dismissButton).toHaveAttribute('aria-label', 'Dismiss');
});
});
describe('Urgent State (3 days or less)', () => {
it('should apply urgent styling when 3 days left', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 3,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
const banner = container.querySelector('.bg-gradient-to-r.from-red-500.to-orange-500');
expect(banner).toBeInTheDocument();
});
it('should apply urgent styling when 2 days left', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 2,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
const banner = container.querySelector('.bg-gradient-to-r.from-red-500.to-orange-500');
expect(banner).toBeInTheDocument();
});
it('should apply urgent styling when 1 day left', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 1,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
expect(screen.getByText(/1 days left in trial/i)).toBeInTheDocument();
const banner = container.querySelector('.bg-gradient-to-r.from-red-500.to-orange-500');
expect(banner).toBeInTheDocument();
});
it('should NOT apply urgent styling when 4 days left', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 4,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
const banner = container.querySelector('.bg-gradient-to-r.from-blue-600.to-blue-500');
expect(banner).toBeInTheDocument();
expect(container.querySelector('.from-red-500')).not.toBeInTheDocument();
});
});
describe('User Interactions', () => {
it('should navigate to /upgrade when Upgrade Now button is clicked', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
fireEvent.click(upgradeButton);
expect(mockNavigate).toHaveBeenCalledWith('/upgrade');
expect(mockNavigate).toHaveBeenCalledTimes(1);
});
it('should hide banner when dismiss button is clicked', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
// Banner should be visible initially
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
// Click dismiss button
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
fireEvent.click(dismissButton);
// Banner should be hidden
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
it('should keep banner hidden after dismissing even when multiple clicks', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
fireEvent.click(dismissButton);
// Banner should remain hidden
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
});
describe('Hidden States', () => {
it('should not render when trial is not active', () => {
const business = createMockBusiness({
isTrialActive: false,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
it('should not render when daysLeftInTrial is undefined', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: undefined,
});
renderWithRouter(<TrialBanner business={business} />);
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
it('should not render when daysLeftInTrial is 0', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 0,
});
renderWithRouter(<TrialBanner business={business} />);
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
it('should not render when daysLeftInTrial is null', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: null as unknown as number,
});
renderWithRouter(<TrialBanner business={business} />);
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
it('should not render when already dismissed', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
// Dismiss the banner
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
fireEvent.click(dismissButton);
// Banner should not be visible
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle missing trialEnd date gracefully', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 5,
trialEnd: undefined,
});
renderWithRouter(<TrialBanner business={business} />);
// Banner should still render
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
expect(screen.getByText(/5 days left in trial/i)).toBeInTheDocument();
});
it('should handle invalid trialEnd date gracefully', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 5,
trialEnd: 'invalid-date',
});
renderWithRouter(<TrialBanner business={business} />);
// Banner should still render despite invalid date
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
});
it('should display correct styling for boundary case of exactly 3 days', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 3,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// Should use urgent styling at exactly 3 days
const banner = container.querySelector('.bg-gradient-to-r.from-red-500');
expect(banner).toBeInTheDocument();
});
it('should handle very large number of days remaining', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 999,
});
renderWithRouter(<TrialBanner business={business} />);
expect(screen.getByText(/999 days left in trial/i)).toBeInTheDocument();
// Should use non-urgent styling
const { container } = render(<TrialBanner business={business} />, { wrapper: BrowserRouter });
const banner = container.querySelector('.bg-gradient-to-r.from-blue-600');
expect(banner).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have proper button roles and labels', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
expect(upgradeButton).toBeInTheDocument();
expect(dismissButton).toBeInTheDocument();
expect(dismissButton).toHaveAttribute('aria-label');
});
it('should have readable text content for screen readers', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 7,
trialEnd: '2025-12-24T23:59:59Z',
});
renderWithRouter(<TrialBanner business={business} />);
// All important text should be accessible
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
expect(screen.getByText(/7 days left in trial/i)).toBeInTheDocument();
expect(screen.getByText(/trial expires on/i)).toBeInTheDocument();
});
});
describe('Responsive Behavior', () => {
it('should render trial end date with hidden class for small screens', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
trialEnd: '2025-12-17T23:59:59Z',
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// The trial end date paragraph should have 'hidden sm:block' classes
const endDateElement = container.querySelector('.hidden.sm\\:block');
expect(endDateElement).toBeInTheDocument();
});
it('should render all key elements in the banner', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// Icon container
const iconContainer = container.querySelector('.p-2.rounded-full');
expect(iconContainer).toBeInTheDocument();
// Buttons container
const buttonsContainer = screen.getByRole('button', { name: /upgrade now/i }).parentElement;
expect(buttonsContainer).toBeInTheDocument();
});
});
describe('Component Integration', () => {
it('should work with different business configurations', () => {
const businesses = [
createMockBusiness({ daysLeftInTrial: 1, isTrialActive: true }),
createMockBusiness({ daysLeftInTrial: 7, isTrialActive: true }),
createMockBusiness({ daysLeftInTrial: 14, isTrialActive: true }),
];
businesses.forEach((business) => {
const { unmount } = renderWithRouter(<TrialBanner business={business} />);
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
unmount();
});
});
it('should maintain state across re-renders when not dismissed', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { rerender } = renderWithRouter(<TrialBanner business={business} />);
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
// Re-render with updated days
const updatedBusiness = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 9,
});
rerender(
<BrowserRouter>
<TrialBanner business={updatedBusiness} />
</BrowserRouter>
);
expect(screen.getByText(/9 days left in trial/i)).toBeInTheDocument();
});
it('should reset dismissed state on component unmount and remount', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { unmount } = renderWithRouter(<TrialBanner business={business} />);
// Dismiss the banner
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
fireEvent.click(dismissButton);
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
// Unmount and remount
unmount();
renderWithRouter(<TrialBanner business={business} />);
// Banner should reappear (dismissed state is not persisted)
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
});
});
});

View File

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

View File

@@ -0,0 +1,142 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical, X, Users, User } from 'lucide-react';
import { Appointment, Resource } from '../../types';
import { startOfWeek, endOfWeek, isWithinInterval } from 'date-fns';
interface CapacityWidgetProps {
appointments: Appointment[];
resources: Resource[];
isEditing?: boolean;
onRemove?: () => void;
}
const CapacityWidget: React.FC<CapacityWidgetProps> = ({
appointments,
resources,
isEditing,
onRemove,
}) => {
const { t } = useTranslation();
const capacityData = useMemo(() => {
const now = new Date();
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
const weekEnd = endOfWeek(now, { weekStartsOn: 1 });
// Calculate for each resource
const resourceStats = resources.map((resource) => {
// Filter appointments for this resource this week
const resourceAppointments = appointments.filter(
(appt) =>
appt.resourceId === resource.id &&
isWithinInterval(new Date(appt.startTime), { start: weekStart, end: weekEnd }) &&
appt.status !== 'CANCELLED'
);
// Calculate total booked minutes
const bookedMinutes = resourceAppointments.reduce(
(sum, appt) => sum + appt.durationMinutes,
0
);
// Assume 8 hours/day, 5 days/week = 2400 minutes capacity
const totalCapacityMinutes = 8 * 60 * 5;
const utilization = Math.min((bookedMinutes / totalCapacityMinutes) * 100, 100);
return {
id: resource.id,
name: resource.name,
utilization: Math.round(utilization),
bookedHours: Math.round(bookedMinutes / 60),
};
});
// Calculate overall utilization
const totalBooked = resourceStats.reduce((sum, r) => sum + r.bookedHours, 0);
const totalCapacity = resources.length * 40; // 40 hours/week per resource
const overallUtilization = totalCapacity > 0 ? Math.round((totalBooked / totalCapacity) * 100) : 0;
return {
overall: overallUtilization,
resources: resourceStats.sort((a, b) => b.utilization - a.utilization),
};
}, [appointments, resources]);
const getUtilizationColor = (utilization: number) => {
if (utilization >= 80) return 'bg-green-500';
if (utilization >= 50) return 'bg-yellow-500';
return 'bg-gray-300 dark:bg-gray-600';
};
const getUtilizationTextColor = (utilization: number) => {
if (utilization >= 80) return 'text-green-600 dark:text-green-400';
if (utilization >= 50) return 'text-yellow-600 dark:text-yellow-400';
return 'text-gray-500 dark:text-gray-400';
};
return (
<div className="h-full p-3 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group flex flex-col">
{isEditing && (
<>
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
<GripVertical size={16} />
</div>
<button
onClick={onRemove}
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={14} />
</button>
</>
)}
<div className={`flex items-center justify-between mb-3 ${isEditing ? 'pl-5' : ''}`}>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
Capacity This Week
</h3>
<div className="flex items-center gap-1.5">
<Users size={14} className="text-gray-400" />
<span className="text-lg font-bold text-gray-900 dark:text-white">
{capacityData.overall}%
</span>
</div>
</div>
{capacityData.resources.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
<Users size={32} className="mb-2 opacity-50" />
<p className="text-sm">{t('dashboard.noResourcesConfigured')}</p>
</div>
) : (
<div className="flex-1 grid grid-cols-2 gap-2 auto-rows-min">
{capacityData.resources.map((resource) => (
<div
key={resource.id}
className="p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
>
<div className="flex items-center gap-1.5 mb-1.5">
<User size={12} className="text-gray-400 flex-shrink-0" />
<span className="text-xs text-gray-600 dark:text-gray-300 truncate">
{resource.name}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
<div
className={`h-full ${getUtilizationColor(resource.utilization)} transition-all duration-300`}
style={{ width: `${resource.utilization}%` }}
/>
</div>
<span className={`text-xs font-semibold ${getUtilizationTextColor(resource.utilization)}`}>
{resource.utilization}%
</span>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default CapacityWidget;

View File

@@ -0,0 +1,105 @@
import React from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
} from 'recharts';
import { GripVertical, X } from 'lucide-react';
interface ChartData {
name: string;
value: number;
}
interface ChartWidgetProps {
title: string;
data: ChartData[];
type: 'bar' | 'line';
color?: string;
valuePrefix?: string;
isEditing?: boolean;
onRemove?: () => void;
}
const ChartWidget: React.FC<ChartWidgetProps> = ({
title,
data,
type,
color = '#3b82f6',
valuePrefix = '',
isEditing,
onRemove,
}) => {
const formatValue = (value: number) => `${valuePrefix}${value}`;
return (
<div className="h-full p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group flex flex-col">
{isEditing && (
<>
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
<GripVertical size={16} />
</div>
<button
onClick={onRemove}
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={14} />
</button>
</>
)}
<h3 className={`text-lg font-semibold text-gray-900 dark:text-white mb-4 ${isEditing ? 'pl-5' : ''}`}>
{title}
</h3>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
{type === 'bar' ? (
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
<YAxis axisLine={false} tickLine={false} tickFormatter={formatValue} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
<Tooltip
cursor={{ fill: 'rgba(107, 114, 128, 0.1)' }}
contentStyle={{
borderRadius: '8px',
border: 'none',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
backgroundColor: '#1F2937',
color: '#F3F4F6',
}}
formatter={(value: number) => [formatValue(value), title]}
/>
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
</BarChart>
) : (
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
<Tooltip
contentStyle={{
borderRadius: '8px',
border: 'none',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
backgroundColor: '#1F2937',
color: '#F3F4F6',
}}
formatter={(value: number) => [value, title]}
/>
<Line type="monotone" dataKey="value" stroke={color} strokeWidth={3} dot={{ r: 4, fill: color }} />
</LineChart>
)}
</ResponsiveContainer>
</div>
</div>
);
};
export default ChartWidget;

View File

@@ -0,0 +1,136 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical, X, Users, UserPlus, UserCheck } from 'lucide-react';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
import { Customer } from '../../types';
interface CustomerBreakdownWidgetProps {
customers: Customer[];
isEditing?: boolean;
onRemove?: () => void;
}
const CustomerBreakdownWidget: React.FC<CustomerBreakdownWidgetProps> = ({
customers,
isEditing,
onRemove,
}) => {
const { t } = useTranslation();
const breakdownData = useMemo(() => {
// Customers with lastVisit are returning, without are new
const returning = customers.filter((c) => c.lastVisit !== null).length;
const newCustomers = customers.filter((c) => c.lastVisit === null).length;
const total = customers.length;
return {
new: newCustomers,
returning,
total,
newPercentage: total > 0 ? Math.round((newCustomers / total) * 100) : 0,
returningPercentage: total > 0 ? Math.round((returning / total) * 100) : 0,
chartData: [
{ name: 'New', value: newCustomers, color: '#8b5cf6' },
{ name: 'Returning', value: returning, color: '#10b981' },
],
};
}, [customers]);
return (
<div className="h-full p-3 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group flex flex-col">
{isEditing && (
<>
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
<GripVertical size={16} />
</div>
<button
onClick={onRemove}
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={14} />
</button>
</>
)}
<h3 className={`text-base font-semibold text-gray-900 dark:text-white mb-2 ${isEditing ? 'pl-5' : ''}`}>
Customers This Month
</h3>
<div className="flex-1 flex items-center gap-3 min-h-0">
{/* Pie Chart */}
<div className="w-20 h-20 flex-shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={breakdownData.chartData}
cx="50%"
cy="50%"
innerRadius={20}
outerRadius={35}
paddingAngle={2}
dataKey="value"
>
{breakdownData.chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
borderRadius: '8px',
border: 'none',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
backgroundColor: '#1F2937',
color: '#F3F4F6',
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Stats */}
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<div className="p-1 rounded-lg bg-purple-100 dark:bg-purple-900/30">
<UserPlus size={12} className="text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">New</p>
<p className="text-base font-semibold text-gray-900 dark:text-white">
{breakdownData.new}{' '}
<span className="text-xs font-normal text-gray-400">
({breakdownData.newPercentage}%)
</span>
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="p-1 rounded-lg bg-green-100 dark:bg-green-900/30">
<UserCheck size={12} className="text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Returning</p>
<p className="text-base font-semibold text-gray-900 dark:text-white">
{breakdownData.returning}{' '}
<span className="text-xs font-normal text-gray-400">
({breakdownData.returningPercentage}%)
</span>
</p>
</div>
</div>
</div>
</div>
<div className="mt-2 pt-2 border-t border-gray-100 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-400">
<Users size={12} />
<span>{t('dashboard.totalCustomers')}</span>
</div>
<span className="font-semibold text-gray-900 dark:text-white">{breakdownData.total}</span>
</div>
</div>
</div>
);
};
export default CustomerBreakdownWidget;

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { TrendingUp, TrendingDown, Minus, GripVertical, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface GrowthData {
weekly: { value: number; change: number };
monthly: { value: number; change: number };
}
interface MetricWidgetProps {
title: string;
value: number | string;
growth: GrowthData;
icon?: React.ReactNode;
isEditing?: boolean;
onRemove?: () => void;
}
const MetricWidget: React.FC<MetricWidgetProps> = ({
title,
value,
growth,
icon,
isEditing,
onRemove,
}) => {
const { t } = useTranslation();
const formatChange = (change: number) => {
if (change === 0) return '0%';
return change > 0 ? `+${change.toFixed(1)}%` : `${change.toFixed(1)}%`;
};
const getTrendIcon = (change: number) => {
if (change > 0) return <TrendingUp size={12} className="mr-1" />;
if (change < 0) return <TrendingDown size={12} className="mr-1" />;
return <Minus size={12} className="mr-1" />;
};
const getTrendClass = (change: number) => {
if (change > 0) return 'text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-400';
if (change < 0) return 'text-red-700 bg-red-50 dark:bg-red-900/30 dark:text-red-400';
return 'text-gray-700 bg-gray-50 dark:bg-gray-700 dark:text-gray-300';
};
return (
<div className="h-full p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group">
{isEditing && (
<>
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
<GripVertical size={16} />
</div>
<button
onClick={onRemove}
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={14} />
</button>
</>
)}
<div className={isEditing ? 'pl-5' : ''}>
<div className="flex items-center gap-2 mb-2">
{icon && <span className="text-brand-500">{icon}</span>}
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{title}</p>
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
{value}
</div>
<div className="flex flex-wrap gap-2 text-xs">
<div className="flex items-center gap-1">
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.weekLabel')}</span>
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(growth.weekly.change)}`}>
{getTrendIcon(growth.weekly.change)}
{formatChange(growth.weekly.change)}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.monthLabel')}</span>
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(growth.monthly.change)}`}>
{getTrendIcon(growth.monthly.change)}
{formatChange(growth.monthly.change)}
</span>
</div>
</div>
</div>
</div>
);
};
export default MetricWidget;

View File

@@ -0,0 +1,147 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical, X, UserX, TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { Appointment } from '../../types';
import { subDays, subMonths, isAfter } from 'date-fns';
interface NoShowRateWidgetProps {
appointments: Appointment[];
isEditing?: boolean;
onRemove?: () => void;
}
const NoShowRateWidget: React.FC<NoShowRateWidgetProps> = ({
appointments,
isEditing,
onRemove,
}) => {
const { t } = useTranslation();
const noShowData = useMemo(() => {
const now = new Date();
const oneWeekAgo = subDays(now, 7);
const twoWeeksAgo = subDays(now, 14);
const oneMonthAgo = subMonths(now, 1);
const twoMonthsAgo = subMonths(now, 2);
// Calculate rates for different periods
const calculateRate = (appts: Appointment[]) => {
const completed = appts.filter(
(a) => a.status === 'COMPLETED' || a.status === 'NO_SHOW' || a.status === 'CANCELLED'
);
const noShows = completed.filter((a) => a.status === 'NO_SHOW');
return completed.length > 0 ? (noShows.length / completed.length) * 100 : 0;
};
// Current week
const thisWeekAppts = appointments.filter((a) => isAfter(new Date(a.startTime), oneWeekAgo));
const currentWeekRate = calculateRate(thisWeekAppts);
// Last week
const lastWeekAppts = appointments.filter(
(a) => isAfter(new Date(a.startTime), twoWeeksAgo) && !isAfter(new Date(a.startTime), oneWeekAgo)
);
const lastWeekRate = calculateRate(lastWeekAppts);
// Current month
const thisMonthAppts = appointments.filter((a) => isAfter(new Date(a.startTime), oneMonthAgo));
const currentMonthRate = calculateRate(thisMonthAppts);
// Last month
const lastMonthAppts = appointments.filter(
(a) => isAfter(new Date(a.startTime), twoMonthsAgo) && !isAfter(new Date(a.startTime), oneMonthAgo)
);
const lastMonthRate = calculateRate(lastMonthAppts);
// Calculate changes (negative is good for no-show rate)
const weeklyChange = lastWeekRate !== 0 ? ((currentWeekRate - lastWeekRate) / lastWeekRate) * 100 : 0;
const monthlyChange = lastMonthRate !== 0 ? ((currentMonthRate - lastMonthRate) / lastMonthRate) * 100 : 0;
// Count total no-shows this month
const noShowsThisMonth = thisMonthAppts.filter((a) => a.status === 'NO_SHOW').length;
return {
currentRate: currentMonthRate,
noShowCount: noShowsThisMonth,
weeklyChange,
monthlyChange,
};
}, [appointments]);
const formatChange = (change: number) => {
if (change === 0) return '0%';
return change > 0 ? `+${change.toFixed(1)}%` : `${change.toFixed(1)}%`;
};
// For no-show rate, down is good (green), up is bad (red)
const getTrendIcon = (change: number) => {
if (change < 0) return <TrendingDown size={12} className="mr-1" />;
if (change > 0) return <TrendingUp size={12} className="mr-1" />;
return <Minus size={12} className="mr-1" />;
};
const getTrendClass = (change: number) => {
if (change < 0) return 'text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-400';
if (change > 0) return 'text-red-700 bg-red-50 dark:bg-red-900/30 dark:text-red-400';
return 'text-gray-700 bg-gray-50 dark:bg-gray-700 dark:text-gray-300';
};
const getRateColor = (rate: number) => {
if (rate <= 5) return 'text-green-600 dark:text-green-400';
if (rate <= 10) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
};
return (
<div className="h-full p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group">
{isEditing && (
<>
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
<GripVertical size={16} />
</div>
<button
onClick={onRemove}
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={14} />
</button>
</>
)}
<div className={isEditing ? 'pl-5' : ''}>
<div className="flex items-center gap-2 mb-2">
<UserX size={18} className="text-gray-400" />
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{t('dashboard.noShowRate')}</p>
</div>
<div className="flex items-baseline gap-2 mb-1">
<span className={`text-2xl font-bold ${getRateColor(noShowData.currentRate)}`}>
{noShowData.currentRate.toFixed(1)}%
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
({noShowData.noShowCount} {t('dashboard.thisMonth')})
</span>
</div>
<div className="flex flex-wrap gap-2 text-xs mt-2">
<div className="flex items-center gap-1">
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.week')}:</span>
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(noShowData.weeklyChange)}`}>
{getTrendIcon(noShowData.weeklyChange)}
{formatChange(noShowData.weeklyChange)}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.month')}:</span>
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(noShowData.monthlyChange)}`}>
{getTrendIcon(noShowData.monthlyChange)}
{formatChange(noShowData.monthlyChange)}
</span>
</div>
</div>
</div>
</div>
);
};
export default NoShowRateWidget;

View File

@@ -0,0 +1,123 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { GripVertical, X, AlertCircle, Clock, ChevronRight } from 'lucide-react';
import { Ticket } from '../../types';
import { formatDistanceToNow } from 'date-fns';
interface OpenTicketsWidgetProps {
tickets: Ticket[];
isEditing?: boolean;
onRemove?: () => void;
}
const OpenTicketsWidget: React.FC<OpenTicketsWidgetProps> = ({
tickets,
isEditing,
onRemove,
}) => {
const { t } = useTranslation();
const openTickets = tickets.filter(ticket => ticket.status === 'open' || ticket.status === 'in_progress');
const urgentCount = openTickets.filter(t => t.priority === 'urgent' || t.isOverdue).length;
const getPriorityColor = (priority: string, isOverdue?: boolean) => {
if (isOverdue) return 'text-red-600 dark:text-red-400';
switch (priority) {
case 'urgent': return 'text-red-600 dark:text-red-400';
case 'high': return 'text-orange-600 dark:text-orange-400';
case 'medium': return 'text-yellow-600 dark:text-yellow-400';
default: return 'text-gray-600 dark:text-gray-400';
}
};
const getPriorityBg = (priority: string, isOverdue?: boolean) => {
if (isOverdue) return 'bg-red-50 dark:bg-red-900/20';
switch (priority) {
case 'urgent': return 'bg-red-50 dark:bg-red-900/20';
case 'high': return 'bg-orange-50 dark:bg-orange-900/20';
case 'medium': return 'bg-yellow-50 dark:bg-yellow-900/20';
default: return 'bg-gray-50 dark:bg-gray-700/50';
}
};
return (
<div className="h-full p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group flex flex-col">
{isEditing && (
<>
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
<GripVertical size={16} />
</div>
<button
onClick={onRemove}
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={14} />
</button>
</>
)}
<div className={`flex items-center justify-between mb-4 ${isEditing ? 'pl-5' : ''}`}>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Open Tickets
</h3>
<div className="flex items-center gap-2">
{urgentCount > 0 && (
<span className="flex items-center gap-1 text-xs font-medium px-2 py-1 rounded-full bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400">
<AlertCircle size={12} />
{urgentCount} urgent
</span>
)}
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
{openTickets.length} open
</span>
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-2">
{openTickets.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500">
<AlertCircle size={32} className="mb-2 opacity-50" />
<p className="text-sm">{t('dashboard.noOpenTickets')}</p>
</div>
) : (
openTickets.slice(0, 5).map((ticket) => (
<Link
key={ticket.id}
to="/tickets"
className={`block p-3 rounded-lg ${getPriorityBg(ticket.priority, ticket.isOverdue)} hover:opacity-80 transition-opacity`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{ticket.subject}
</p>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500 dark:text-gray-400">
<span className={getPriorityColor(ticket.priority, ticket.isOverdue)}>
{ticket.isOverdue ? 'Overdue' : ticket.priority}
</span>
<span className="flex items-center gap-1">
<Clock size={10} />
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
</span>
</div>
</div>
<ChevronRight size={16} className="text-gray-400 flex-shrink-0" />
</div>
</Link>
))
)}
</div>
{openTickets.length > 5 && (
<Link
to="/tickets"
className="mt-3 text-sm text-brand-600 dark:text-brand-400 hover:underline text-center"
>
View all {openTickets.length} tickets
</Link>
)}
</div>
);
};
export default OpenTicketsWidget;

View File

@@ -0,0 +1,146 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical, X, Calendar, UserPlus, XCircle, CheckCircle, DollarSign } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { Appointment, Customer } from '../../types';
interface ActivityItem {
id: string;
type: 'booking' | 'cancellation' | 'completion' | 'new_customer' | 'payment';
title: string;
description: string;
timestamp: Date;
icon: React.ReactNode;
iconBg: string;
}
interface RecentActivityWidgetProps {
appointments: Appointment[];
customers: Customer[];
isEditing?: boolean;
onRemove?: () => void;
}
const RecentActivityWidget: React.FC<RecentActivityWidgetProps> = ({
appointments,
customers,
isEditing,
onRemove,
}) => {
const { t } = useTranslation();
const activities = useMemo(() => {
const items: ActivityItem[] = [];
// Add appointments as activity
appointments.forEach((appt) => {
const timestamp = new Date(appt.startTime);
if (appt.status === 'CONFIRMED' || appt.status === 'PENDING') {
items.push({
id: `booking-${appt.id}`,
type: 'booking',
title: 'New Booking',
description: `${appt.customerName} booked an appointment`,
timestamp,
icon: <Calendar size={14} />,
iconBg: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
});
} else if (appt.status === 'CANCELLED') {
items.push({
id: `cancel-${appt.id}`,
type: 'cancellation',
title: 'Cancellation',
description: `${appt.customerName} cancelled their appointment`,
timestamp,
icon: <XCircle size={14} />,
iconBg: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
});
} else if (appt.status === 'COMPLETED') {
items.push({
id: `complete-${appt.id}`,
type: 'completion',
title: 'Completed',
description: `${appt.customerName}'s appointment completed`,
timestamp,
icon: <CheckCircle size={14} />,
iconBg: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
});
}
});
// Add recent customers (those with no lastVisit are new)
customers
.filter(c => !c.lastVisit)
.slice(0, 5)
.forEach((customer) => {
items.push({
id: `customer-${customer.id}`,
type: 'new_customer',
title: 'New Customer',
description: `${customer.name} signed up`,
timestamp: new Date(), // Approximate - would need createdAt field
icon: <UserPlus size={14} />,
iconBg: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400',
});
});
// Sort by timestamp descending
items.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
return items.slice(0, 10);
}, [appointments, customers]);
return (
<div className="h-full p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group flex flex-col">
{isEditing && (
<>
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
<GripVertical size={16} />
</div>
<button
onClick={onRemove}
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={14} />
</button>
</>
)}
<h3 className={`text-lg font-semibold text-gray-900 dark:text-white mb-4 ${isEditing ? 'pl-5' : ''}`}>
Recent Activity
</h3>
<div className="flex-1 overflow-y-auto">
{activities.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500">
<Calendar size={32} className="mb-2 opacity-50" />
<p className="text-sm">{t('dashboard.noRecentActivity')}</p>
</div>
) : (
<div className="space-y-3">
{activities.map((activity) => (
<div key={activity.id} className="flex items-start gap-3">
<div className={`p-1.5 rounded-lg ${activity.iconBg} flex-shrink-0`}>
{activity.icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{activity.title}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{activity.description}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{formatDistanceToNow(activity.timestamp, { addSuffix: true })}
</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default RecentActivityWidget;

View File

@@ -0,0 +1,131 @@
import React from 'react';
import { X, Plus, Check, LayoutDashboard, BarChart2, Ticket, Activity, Users, UserX, PieChart } from 'lucide-react';
import { WIDGET_DEFINITIONS, WidgetType } from './types';
interface WidgetConfigModalProps {
isOpen: boolean;
onClose: () => void;
activeWidgets: string[];
onToggleWidget: (widgetId: string) => void;
onResetLayout: () => void;
}
const WIDGET_ICONS: Record<WidgetType, React.ReactNode> = {
'appointments-metric': <LayoutDashboard size={18} />,
'customers-metric': <Users size={18} />,
'services-metric': <LayoutDashboard size={18} />,
'resources-metric': <LayoutDashboard size={18} />,
'revenue-chart': <BarChart2 size={18} />,
'appointments-chart': <BarChart2 size={18} />,
'open-tickets': <Ticket size={18} />,
'recent-activity': <Activity size={18} />,
'capacity-utilization': <Users size={18} />,
'no-show-rate': <UserX size={18} />,
'customer-breakdown': <PieChart size={18} />,
};
const WidgetConfigModal: React.FC<WidgetConfigModalProps> = ({
isOpen,
onClose,
activeWidgets,
onToggleWidget,
onResetLayout,
}) => {
if (!isOpen) return null;
const widgets = Object.values(WIDGET_DEFINITIONS);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Configure Dashboard Widgets
</h2>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Select which widgets to show on your dashboard. You can drag widgets to reposition them.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{widgets.map((widget) => {
const isActive = activeWidgets.includes(widget.id);
return (
<button
key={widget.id}
onClick={() => onToggleWidget(widget.id)}
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors text-left ${
isActive
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div
className={`p-2 rounded-lg ${
isActive
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400'
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
{WIDGET_ICONS[widget.type]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p
className={`text-sm font-medium ${
isActive
? 'text-brand-700 dark:text-brand-300'
: 'text-gray-900 dark:text-white'
}`}
>
{widget.title}
</p>
{isActive && (
<Check size={14} className="text-brand-600 dark:text-brand-400" />
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{widget.description}
</p>
</div>
</button>
);
})}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onResetLayout}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
Reset to Default
</button>
<button
onClick={onClose}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
>
Done
</button>
</div>
</div>
</div>
);
};
export default WidgetConfigModal;

View File

@@ -0,0 +1,895 @@
/**
* Unit tests for ChartWidget component
*
* Tests cover:
* - Chart container rendering
* - Title display
* - Bar chart rendering
* - Line chart rendering
* - Data visualization
* - Custom colors
* - Value prefixes
* - Edit mode with drag handle and remove button
* - Tooltip formatting
* - Responsive container
* - Accessibility
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import ChartWidget from '../ChartWidget';
// Mock Recharts components to avoid rendering issues in tests
vi.mock('recharts', () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
BarChart: ({ data, children }: { data: any[]; children: React.ReactNode }) => (
<div data-testid="bar-chart" data-chart-data={JSON.stringify(data)}>
{children}
</div>
),
LineChart: ({ data, children }: { data: any[]; children: React.ReactNode }) => (
<div data-testid="line-chart" data-chart-data={JSON.stringify(data)}>
{children}
</div>
),
Bar: ({ dataKey, fill }: { dataKey: string; fill: string }) => (
<div data-testid="bar" data-key={dataKey} data-fill={fill} />
),
Line: ({ dataKey, stroke }: { dataKey: string; stroke: string }) => (
<div data-testid="line" data-key={dataKey} data-stroke={stroke} />
),
XAxis: ({ dataKey }: { dataKey: string }) => (
<div data-testid="x-axis" data-key={dataKey} />
),
YAxis: () => <div data-testid="y-axis" />,
CartesianGrid: () => <div data-testid="cartesian-grid" />,
Tooltip: () => <div data-testid="tooltip" />,
}));
describe('ChartWidget', () => {
const mockChartData = [
{ name: 'Mon', value: 100 },
{ name: 'Tue', value: 150 },
{ name: 'Wed', value: 120 },
{ name: 'Thu', value: 180 },
{ name: 'Fri', value: 200 },
];
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render the component', () => {
render(
<ChartWidget
title="Revenue Chart"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByText('Revenue Chart')).toBeInTheDocument();
});
it('should render chart container', () => {
render(
<ChartWidget
title="Revenue Chart"
data={mockChartData}
type="bar"
/>
);
const container = screen.getByTestId('responsive-container');
expect(container).toBeInTheDocument();
});
it('should render with different titles', () => {
const { rerender } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByText('Revenue')).toBeInTheDocument();
rerender(
<ChartWidget
title="Appointments"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByText('Appointments')).toBeInTheDocument();
});
it('should render with empty data array', () => {
render(
<ChartWidget
title="Empty Chart"
data={[]}
type="bar"
/>
);
expect(screen.getByText('Empty Chart')).toBeInTheDocument();
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
});
});
describe('Title', () => {
it('should display title with correct styling', () => {
render(
<ChartWidget
title="Weekly Revenue"
data={mockChartData}
type="bar"
/>
);
const title = screen.getByText('Weekly Revenue');
expect(title).toBeInTheDocument();
expect(title).toHaveClass('text-lg', 'font-semibold', 'text-gray-900');
});
it('should apply dark mode styles to title', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const title = screen.getByText('Revenue');
expect(title).toHaveClass('dark:text-white');
});
it('should handle long titles', () => {
const longTitle = 'Very Long Chart Title That Should Still Display Properly Without Breaking Layout';
render(
<ChartWidget
title={longTitle}
data={mockChartData}
type="bar"
/>
);
expect(screen.getByText(longTitle)).toBeInTheDocument();
});
});
describe('Bar Chart', () => {
it('should render bar chart when type is "bar"', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
expect(screen.queryByTestId('line-chart')).not.toBeInTheDocument();
});
it('should pass data to bar chart', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const barChart = screen.getByTestId('bar-chart');
const chartData = JSON.parse(barChart.getAttribute('data-chart-data') || '[]');
expect(chartData).toEqual(mockChartData);
});
it('should render bar with correct dataKey', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const bar = screen.getByTestId('bar');
expect(bar).toHaveAttribute('data-key', 'value');
});
it('should render bar with default color', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const bar = screen.getByTestId('bar');
expect(bar).toHaveAttribute('data-fill', '#3b82f6');
});
it('should render bar with custom color', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
color="#10b981"
/>
);
const bar = screen.getByTestId('bar');
expect(bar).toHaveAttribute('data-fill', '#10b981');
});
it('should render CartesianGrid for bar chart', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByTestId('cartesian-grid')).toBeInTheDocument();
});
it('should render XAxis with name dataKey', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const xAxis = screen.getByTestId('x-axis');
expect(xAxis).toHaveAttribute('data-key', 'name');
});
it('should render YAxis', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByTestId('y-axis')).toBeInTheDocument();
});
it('should render Tooltip', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByTestId('tooltip')).toBeInTheDocument();
});
});
describe('Line Chart', () => {
it('should render line chart when type is "line"', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="line"
/>
);
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
});
it('should pass data to line chart', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="line"
/>
);
const lineChart = screen.getByTestId('line-chart');
const chartData = JSON.parse(lineChart.getAttribute('data-chart-data') || '[]');
expect(chartData).toEqual(mockChartData);
});
it('should render line with correct dataKey', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="line"
/>
);
const line = screen.getByTestId('line');
expect(line).toHaveAttribute('data-key', 'value');
});
it('should render line with default color', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="line"
/>
);
const line = screen.getByTestId('line');
expect(line).toHaveAttribute('data-stroke', '#3b82f6');
});
it('should render line with custom color', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="line"
color="#ef4444"
/>
);
const line = screen.getByTestId('line');
expect(line).toHaveAttribute('data-stroke', '#ef4444');
});
it('should render CartesianGrid for line chart', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="line"
/>
);
expect(screen.getByTestId('cartesian-grid')).toBeInTheDocument();
});
it('should switch between chart types', () => {
const { rerender } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
rerender(
<ChartWidget
title="Revenue"
data={mockChartData}
type="line"
/>
);
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
});
});
describe('Value Prefix', () => {
it('should use empty prefix by default', () => {
render(
<ChartWidget
title="Appointments"
data={mockChartData}
type="bar"
/>
);
// Component renders successfully without prefix
expect(screen.getByText('Appointments')).toBeInTheDocument();
});
it('should accept custom value prefix', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
valuePrefix="$"
/>
);
// Component renders successfully with prefix
expect(screen.getByText('Revenue')).toBeInTheDocument();
});
it('should accept different prefixes', () => {
const { rerender } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
valuePrefix="$"
/>
);
expect(screen.getByText('Revenue')).toBeInTheDocument();
rerender(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
valuePrefix="€"
/>
);
expect(screen.getByText('Revenue')).toBeInTheDocument();
});
});
describe('Edit Mode', () => {
it('should not show edit controls when isEditing is false', () => {
const { container } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={false}
/>
);
const dragHandle = container.querySelector('.drag-handle');
expect(dragHandle).not.toBeInTheDocument();
});
it('should show drag handle when in edit mode', () => {
const { container } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={true}
/>
);
const dragHandle = container.querySelector('.drag-handle');
expect(dragHandle).toBeInTheDocument();
});
it('should show remove button when in edit mode', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={true}
onRemove={vi.fn()}
/>
);
const removeButton = screen.getByRole('button');
expect(removeButton).toBeInTheDocument();
});
it('should call onRemove when remove button is clicked', async () => {
const user = userEvent.setup();
const handleRemove = vi.fn();
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={true}
onRemove={handleRemove}
/>
);
const removeButton = screen.getByRole('button');
await user.click(removeButton);
expect(handleRemove).toHaveBeenCalledTimes(1);
});
it('should apply padding to title when in edit mode', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={true}
/>
);
const title = screen.getByText('Revenue');
expect(title).toHaveClass('pl-5');
});
it('should not apply padding to title when not in edit mode', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={false}
/>
);
const title = screen.getByText('Revenue');
expect(title).not.toHaveClass('pl-5');
});
it('should have grab cursor on drag handle', () => {
const { container } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={true}
/>
);
const dragHandle = container.querySelector('.drag-handle');
expect(dragHandle).toHaveClass('cursor-grab', 'active:cursor-grabbing');
});
});
describe('Responsive Container', () => {
it('should render ResponsiveContainer', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
});
it('should wrap chart in responsive container', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const container = screen.getByTestId('responsive-container');
const barChart = screen.getByTestId('bar-chart');
expect(container).toContainElement(barChart);
});
it('should have flex layout for proper sizing', () => {
const { container } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const widget = container.firstChild;
expect(widget).toHaveClass('flex', 'flex-col');
});
});
describe('Styling', () => {
it('should apply container styles', () => {
const { container } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const widget = container.firstChild;
expect(widget).toHaveClass(
'h-full',
'p-4',
'bg-white',
'rounded-xl',
'border',
'border-gray-200',
'shadow-sm',
'relative',
'group'
);
});
it('should apply dark mode styles', () => {
const { container } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const widget = container.firstChild;
expect(widget).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
});
it('should have proper spacing for title', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const title = screen.getByText('Revenue');
expect(title).toHaveClass('mb-4');
});
it('should use flex-1 for chart container', () => {
const { container } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const chartContainer = container.querySelector('.flex-1');
expect(chartContainer).toBeInTheDocument();
expect(chartContainer).toHaveClass('min-h-0');
});
});
describe('Data Handling', () => {
it('should handle single data point', () => {
const singlePoint = [{ name: 'Mon', value: 100 }];
render(
<ChartWidget
title="Revenue"
data={singlePoint}
type="bar"
/>
);
const barChart = screen.getByTestId('bar-chart');
const chartData = JSON.parse(barChart.getAttribute('data-chart-data') || '[]');
expect(chartData).toEqual(singlePoint);
});
it('should handle large datasets', () => {
const largeData = Array.from({ length: 100 }, (_, i) => ({
name: `Day ${i + 1}`,
value: Math.random() * 1000,
}));
render(
<ChartWidget
title="Revenue"
data={largeData}
type="line"
/>
);
const lineChart = screen.getByTestId('line-chart');
const chartData = JSON.parse(lineChart.getAttribute('data-chart-data') || '[]');
expect(chartData).toHaveLength(100);
});
it('should handle zero values', () => {
const zeroData = [
{ name: 'Mon', value: 0 },
{ name: 'Tue', value: 0 },
];
render(
<ChartWidget
title="Revenue"
data={zeroData}
type="bar"
/>
);
const barChart = screen.getByTestId('bar-chart');
const chartData = JSON.parse(barChart.getAttribute('data-chart-data') || '[]');
expect(chartData).toEqual(zeroData);
});
it('should handle negative values', () => {
const negativeData = [
{ name: 'Mon', value: -50 },
{ name: 'Tue', value: 100 },
{ name: 'Wed', value: -30 },
];
render(
<ChartWidget
title="Profit/Loss"
data={negativeData}
type="line"
/>
);
const lineChart = screen.getByTestId('line-chart');
const chartData = JSON.parse(lineChart.getAttribute('data-chart-data') || '[]');
expect(chartData).toEqual(negativeData);
});
});
describe('Accessibility', () => {
it('should have semantic heading for title', () => {
render(
<ChartWidget
title="Revenue Chart"
data={mockChartData}
type="bar"
/>
);
const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveTextContent('Revenue Chart');
});
it('should be keyboard accessible in edit mode', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={true}
onRemove={vi.fn()}
/>
);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
it('should have proper color contrast', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const title = screen.getByText('Revenue');
expect(title).toHaveClass('text-gray-900');
});
});
describe('Integration', () => {
it('should render correctly with all props', () => {
const handleRemove = vi.fn();
render(
<ChartWidget
title="Weekly Revenue"
data={mockChartData}
type="bar"
color="#10b981"
valuePrefix="$"
isEditing={true}
onRemove={handleRemove}
/>
);
expect(screen.getByText('Weekly Revenue')).toBeInTheDocument();
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
expect(screen.getByTestId('bar')).toHaveAttribute('data-fill', '#10b981');
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should work with minimal props', () => {
render(
<ChartWidget
title="Simple Chart"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByText('Simple Chart')).toBeInTheDocument();
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
});
it('should maintain layout with varying data lengths', () => {
const shortData = [{ name: 'A', value: 1 }];
const { rerender } = render(
<ChartWidget
title="Data"
data={shortData}
type="bar"
/>
);
expect(screen.getByText('Data')).toBeInTheDocument();
const longData = Array.from({ length: 50 }, (_, i) => ({
name: `Item ${i}`,
value: i * 10,
}));
rerender(
<ChartWidget
title="Data"
data={longData}
type="bar"
/>
);
expect(screen.getByText('Data')).toBeInTheDocument();
});
it('should support different color schemes', () => {
const colors = ['#3b82f6', '#10b981', '#ef4444', '#f59e0b', '#8b5cf6'];
const { rerender } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
color={colors[0]}
/>
);
colors.forEach((color) => {
rerender(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
color={color}
/>
);
const bar = screen.getByTestId('bar');
expect(bar).toHaveAttribute('data-fill', color);
});
});
it('should handle rapid data updates', () => {
const { rerender } = render(
<ChartWidget
title="Live Data"
data={mockChartData}
type="line"
/>
);
for (let i = 0; i < 10; i++) {
const newData = mockChartData.map((item) => ({
...item,
value: item.value + Math.random() * 50,
}));
rerender(
<ChartWidget
title="Live Data"
data={newData}
type="line"
/>
);
expect(screen.getByText('Live Data')).toBeInTheDocument();
}
});
});
});

View File

@@ -0,0 +1,702 @@
/**
* Unit tests for MetricWidget component
*
* Tests cover:
* - Component rendering with title and value
* - Growth/trend indicators (positive, negative, neutral)
* - Change percentage formatting
* - Weekly and monthly metrics display
* - Icon rendering
* - Edit mode with drag handle and remove button
* - Internationalization (i18n)
* - Accessibility
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import MetricWidget from '../MetricWidget';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'dashboard.weekLabel': 'Week:',
'dashboard.monthLabel': 'Month:',
};
return translations[key] || key;
},
}),
}));
describe('MetricWidget', () => {
const mockGrowthData = {
weekly: { value: 100, change: 5.5 },
monthly: { value: 400, change: -2.3 },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render the component', () => {
render(
<MetricWidget
title="Total Revenue"
value="$12,345"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Total Revenue')).toBeInTheDocument();
});
it('should render title correctly', () => {
render(
<MetricWidget
title="Total Customers"
value={150}
growth={mockGrowthData}
/>
);
const title = screen.getByText('Total Customers');
expect(title).toBeInTheDocument();
expect(title).toHaveClass('text-sm', 'font-medium', 'text-gray-500');
});
it('should render numeric value', () => {
render(
<MetricWidget
title="Total Appointments"
value={42}
growth={mockGrowthData}
/>
);
const value = screen.getByText('42');
expect(value).toBeInTheDocument();
expect(value).toHaveClass('text-2xl', 'font-bold', 'text-gray-900');
});
it('should render string value', () => {
render(
<MetricWidget
title="Revenue"
value="$25,000"
growth={mockGrowthData}
/>
);
const value = screen.getByText('$25,000');
expect(value).toBeInTheDocument();
});
it('should render with custom icon', () => {
const CustomIcon = () => <span data-testid="custom-icon">💰</span>;
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
icon={<CustomIcon />}
/>
);
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
});
it('should render without icon', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const iconContainer = container.querySelector('.text-brand-500');
expect(iconContainer).not.toBeInTheDocument();
});
});
describe('Trend Indicators', () => {
describe('Positive Change', () => {
it('should show positive trend icon for weekly growth', () => {
const positiveGrowth = {
weekly: { value: 100, change: 10.5 },
monthly: { value: 400, change: 0 },
};
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={positiveGrowth}
/>
);
const changeText = screen.getByText('+10.5%');
expect(changeText).toBeInTheDocument();
// Check for TrendingUp icon (lucide-react renders as SVG)
const svgs = container.querySelectorAll('svg');
expect(svgs.length).toBeGreaterThan(0);
});
it('should apply positive change styling', () => {
const positiveGrowth = {
weekly: { value: 100, change: 15 },
monthly: { value: 400, change: 0 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={positiveGrowth}
/>
);
const changeElement = screen.getByText('+15.0%').closest('span');
expect(changeElement).toHaveClass('text-green-700', 'bg-green-50');
});
it('should format positive change with plus sign', () => {
const positiveGrowth = {
weekly: { value: 100, change: 7.8 },
monthly: { value: 400, change: 3.2 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={positiveGrowth}
/>
);
expect(screen.getByText('+7.8%')).toBeInTheDocument();
expect(screen.getByText('+3.2%')).toBeInTheDocument();
});
});
describe('Negative Change', () => {
it('should show negative trend icon for monthly growth', () => {
const negativeGrowth = {
weekly: { value: 100, change: 0 },
monthly: { value: 400, change: -5.5 },
};
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={negativeGrowth}
/>
);
const changeText = screen.getByText('-5.5%');
expect(changeText).toBeInTheDocument();
// Check for TrendingDown icon
const svgs = container.querySelectorAll('svg');
expect(svgs.length).toBeGreaterThan(0);
});
it('should apply negative change styling', () => {
const negativeGrowth = {
weekly: { value: 100, change: -12.3 },
monthly: { value: 400, change: 0 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={negativeGrowth}
/>
);
const changeElement = screen.getByText('-12.3%').closest('span');
expect(changeElement).toHaveClass('text-red-700', 'bg-red-50');
});
it('should format negative change without extra minus sign', () => {
const negativeGrowth = {
weekly: { value: 100, change: -8.9 },
monthly: { value: 400, change: -15.2 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={negativeGrowth}
/>
);
expect(screen.getByText('-8.9%')).toBeInTheDocument();
expect(screen.getByText('-15.2%')).toBeInTheDocument();
});
});
describe('Zero Change', () => {
it('should show neutral trend icon for zero change', () => {
const zeroGrowth = {
weekly: { value: 100, change: 0 },
monthly: { value: 400, change: 0 },
};
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={zeroGrowth}
/>
);
const changeTexts = screen.getAllByText('0%');
expect(changeTexts).toHaveLength(2);
// Check for Minus icon
const svgs = container.querySelectorAll('svg');
expect(svgs.length).toBeGreaterThan(0);
});
it('should apply neutral change styling', () => {
const zeroGrowth = {
weekly: { value: 100, change: 0 },
monthly: { value: 400, change: 0 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={zeroGrowth}
/>
);
const changeElements = screen.getAllByText('0%');
changeElements.forEach((element) => {
const spanElement = element.closest('span');
expect(spanElement).toHaveClass('text-gray-700', 'bg-gray-50');
});
});
it('should format zero change as 0%', () => {
const zeroGrowth = {
weekly: { value: 100, change: 0 },
monthly: { value: 400, change: 0 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={zeroGrowth}
/>
);
const changeTexts = screen.getAllByText('0%');
expect(changeTexts).toHaveLength(2);
});
});
});
describe('Weekly and Monthly Metrics', () => {
it('should display weekly label', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Week:')).toBeInTheDocument();
});
it('should display monthly label', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Month:')).toBeInTheDocument();
});
it('should display weekly change percentage', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('+5.5%')).toBeInTheDocument();
});
it('should display monthly change percentage', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('-2.3%')).toBeInTheDocument();
});
it('should handle different weekly and monthly trends', () => {
const mixedGrowth = {
weekly: { value: 100, change: 12.5 },
monthly: { value: 400, change: -8.2 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mixedGrowth}
/>
);
expect(screen.getByText('+12.5%')).toBeInTheDocument();
expect(screen.getByText('-8.2%')).toBeInTheDocument();
});
it('should format change values to one decimal place', () => {
const preciseGrowth = {
weekly: { value: 100, change: 5.456 },
monthly: { value: 400, change: -3.789 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={preciseGrowth}
/>
);
expect(screen.getByText('+5.5%')).toBeInTheDocument();
expect(screen.getByText('-3.8%')).toBeInTheDocument();
});
});
describe('Edit Mode', () => {
it('should not show edit controls when isEditing is false', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={false}
/>
);
const dragHandle = container.querySelector('.drag-handle');
expect(dragHandle).not.toBeInTheDocument();
});
it('should show drag handle when in edit mode', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
/>
);
const dragHandle = container.querySelector('.drag-handle');
expect(dragHandle).toBeInTheDocument();
});
it('should show remove button when in edit mode', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
onRemove={vi.fn()}
/>
);
const removeButton = screen.getByRole('button');
expect(removeButton).toBeInTheDocument();
});
it('should call onRemove when remove button is clicked', async () => {
const user = userEvent.setup();
const handleRemove = vi.fn();
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
onRemove={handleRemove}
/>
);
const removeButton = screen.getByRole('button');
await user.click(removeButton);
expect(handleRemove).toHaveBeenCalledTimes(1);
});
it('should apply padding when in edit mode', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
/>
);
const contentContainer = container.querySelector('.pl-5');
expect(contentContainer).toBeInTheDocument();
});
it('should not apply padding when not in edit mode', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={false}
/>
);
const contentContainer = container.querySelector('.pl-5');
expect(contentContainer).not.toBeInTheDocument();
});
});
describe('Styling', () => {
it('should apply container styles', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const widget = container.firstChild;
expect(widget).toHaveClass(
'h-full',
'p-4',
'bg-white',
'rounded-xl',
'border',
'border-gray-200',
'shadow-sm',
'relative',
'group'
);
});
it('should apply dark mode styles', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const widget = container.firstChild;
expect(widget).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
});
it('should apply trend badge styles', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const badges = container.querySelectorAll('.rounded-full');
expect(badges.length).toBeGreaterThan(0);
});
});
describe('Accessibility', () => {
it('should have semantic HTML structure', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const paragraphs = container.querySelectorAll('p');
const divs = container.querySelectorAll('div');
expect(paragraphs.length).toBeGreaterThan(0);
expect(divs.length).toBeGreaterThan(0);
});
it('should have readable text contrast', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const title = screen.getByText('Revenue');
expect(title).toHaveClass('text-gray-500');
});
it('should make remove button accessible when in edit mode', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
onRemove={vi.fn()}
/>
);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
});
describe('Internationalization', () => {
it('should use translation for week label', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Week:')).toBeInTheDocument();
});
it('should use translation for month label', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Month:')).toBeInTheDocument();
});
});
describe('Integration', () => {
it('should render correctly with all props', () => {
const CustomIcon = () => <span data-testid="icon">📊</span>;
const handleRemove = vi.fn();
const fullGrowth = {
weekly: { value: 150, change: 10 },
monthly: { value: 600, change: -5 },
};
render(
<MetricWidget
title="Total Revenue"
value="$15,000"
growth={fullGrowth}
icon={<CustomIcon />}
isEditing={true}
onRemove={handleRemove}
/>
);
expect(screen.getByText('Total Revenue')).toBeInTheDocument();
expect(screen.getByText('$15,000')).toBeInTheDocument();
expect(screen.getByTestId('icon')).toBeInTheDocument();
expect(screen.getByText('+10.0%')).toBeInTheDocument();
expect(screen.getByText('-5.0%')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should handle edge case values', () => {
const edgeCaseGrowth = {
weekly: { value: 0, change: 0 },
monthly: { value: 1000000, change: 99.9 },
};
render(
<MetricWidget
title="Edge Case"
value={0}
growth={edgeCaseGrowth}
/>
);
expect(screen.getByText('Edge Case')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('0%')).toBeInTheDocument();
expect(screen.getByText('+99.9%')).toBeInTheDocument();
});
it('should maintain layout with long titles', () => {
render(
<MetricWidget
title="Very Long Metric Title That Should Still Display Properly"
value="$1000"
growth={mockGrowthData}
/>
);
const title = screen.getByText('Very Long Metric Title That Should Still Display Properly');
expect(title).toBeInTheDocument();
expect(title).toHaveClass('text-sm');
});
it('should handle large numeric values', () => {
render(
<MetricWidget
title="Revenue"
value="$1,234,567,890"
growth={mockGrowthData}
/>
);
expect(screen.getByText('$1,234,567,890')).toBeInTheDocument();
});
it('should display multiple trend indicators simultaneously', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
// Should have trend indicators for both weekly and monthly
const trendBadges = container.querySelectorAll('.rounded-full');
expect(trendBadges.length).toBeGreaterThanOrEqual(2);
});
});
});

View File

@@ -0,0 +1,9 @@
export * from './types';
export { default as MetricWidget } from './MetricWidget';
export { default as ChartWidget } from './ChartWidget';
export { default as OpenTicketsWidget } from './OpenTicketsWidget';
export { default as RecentActivityWidget } from './RecentActivityWidget';
export { default as CapacityWidget } from './CapacityWidget';
export { default as NoShowRateWidget } from './NoShowRateWidget';
export { default as CustomerBreakdownWidget } from './CustomerBreakdownWidget';
export { default as WidgetConfigModal } from './WidgetConfigModal';

View File

@@ -0,0 +1,146 @@
import { Layout } from 'react-grid-layout';
export type WidgetType =
| 'appointments-metric'
| 'customers-metric'
| 'services-metric'
| 'resources-metric'
| 'revenue-chart'
| 'appointments-chart'
| 'open-tickets'
| 'recent-activity'
| 'capacity-utilization'
| 'no-show-rate'
| 'customer-breakdown';
export interface WidgetConfig {
id: string;
type: WidgetType;
title: string;
description: string;
defaultSize: { w: number; h: number };
minSize?: { w: number; h: number };
}
export interface DashboardLayout {
widgets: string[]; // Widget IDs that are visible
layout: Layout[];
}
// Widget definitions with metadata
export const WIDGET_DEFINITIONS: Record<WidgetType, WidgetConfig> = {
'appointments-metric': {
id: 'appointments-metric',
type: 'appointments-metric',
title: 'Total Appointments',
description: 'Shows appointment count with weekly and monthly growth',
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 2 },
},
'customers-metric': {
id: 'customers-metric',
type: 'customers-metric',
title: 'Active Customers',
description: 'Shows customer count with weekly and monthly growth',
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 2 },
},
'services-metric': {
id: 'services-metric',
type: 'services-metric',
title: 'Services',
description: 'Shows number of services offered',
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 2 },
},
'resources-metric': {
id: 'resources-metric',
type: 'resources-metric',
title: 'Resources',
description: 'Shows number of resources available',
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 2 },
},
'revenue-chart': {
id: 'revenue-chart',
type: 'revenue-chart',
title: 'Revenue',
description: 'Weekly revenue bar chart',
defaultSize: { w: 6, h: 4 },
minSize: { w: 4, h: 3 },
},
'appointments-chart': {
id: 'appointments-chart',
type: 'appointments-chart',
title: 'Appointments Trend',
description: 'Weekly appointments line chart',
defaultSize: { w: 6, h: 4 },
minSize: { w: 4, h: 3 },
},
'open-tickets': {
id: 'open-tickets',
type: 'open-tickets',
title: 'Open Tickets',
description: 'Shows open support tickets requiring attention',
defaultSize: { w: 4, h: 4 },
minSize: { w: 3, h: 3 },
},
'recent-activity': {
id: 'recent-activity',
type: 'recent-activity',
title: 'Recent Activity',
description: 'Timeline of recent business events',
defaultSize: { w: 4, h: 5 },
minSize: { w: 3, h: 3 },
},
'capacity-utilization': {
id: 'capacity-utilization',
type: 'capacity-utilization',
title: 'Capacity Utilization',
description: 'Shows how booked your resources are this week',
defaultSize: { w: 4, h: 4 },
minSize: { w: 3, h: 3 },
},
'no-show-rate': {
id: 'no-show-rate',
type: 'no-show-rate',
title: 'No-Show Rate',
description: 'Percentage of appointments marked as no-show',
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 2 },
},
'customer-breakdown': {
id: 'customer-breakdown',
type: 'customer-breakdown',
title: 'New vs Returning',
description: 'Customer breakdown this month',
defaultSize: { w: 4, h: 4 },
minSize: { w: 3, h: 3 },
},
};
// Default layout for new users
export const DEFAULT_LAYOUT: DashboardLayout = {
widgets: [
'appointments-metric',
'customers-metric',
'no-show-rate',
'revenue-chart',
'appointments-chart',
'open-tickets',
'recent-activity',
'capacity-utilization',
'customer-breakdown',
],
layout: [
{ i: 'appointments-metric', x: 0, y: 0, w: 4, h: 2 },
{ i: 'customers-metric', x: 4, y: 0, w: 4, h: 2 },
{ i: 'no-show-rate', x: 8, y: 0, w: 4, h: 2 },
{ i: 'revenue-chart', x: 0, y: 2, w: 6, h: 4 },
{ i: 'appointments-chart', x: 6, y: 2, w: 6, h: 4 },
{ i: 'open-tickets', x: 0, y: 6, w: 4, h: 4 },
{ i: 'recent-activity', x: 4, y: 6, w: 4, h: 4 },
{ i: 'capacity-utilization', x: 8, y: 6, w: 4, h: 4 },
{ i: 'customer-breakdown', x: 0, y: 10, w: 4, h: 4 },
],
};

View File

@@ -1,33 +1,36 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Rocket, Shield, Zap, Headphones } from 'lucide-react';
const BenefitsSection: React.FC = () => {
const { t } = useTranslation();
const benefits = [
{
icon: Rocket,
title: 'Rapid Deployment',
description: 'Launch your branded booking portal in minutes with our pre-configured industry templates.',
title: t('marketing.benefits.rapidDeployment.title'),
description: t('marketing.benefits.rapidDeployment.description'),
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
},
{
icon: Shield,
title: 'Enterprise Security',
description: 'Sleep soundly knowing your data is physically isolated in its own dedicated secure vault.',
title: t('marketing.benefits.enterpriseSecurity.title'),
description: t('marketing.benefits.enterpriseSecurity.description'),
color: 'text-green-600 dark:text-green-400',
bgColor: 'bg-green-100 dark:bg-green-900/30',
},
{
icon: Zap,
title: 'High Performance',
description: 'Built on a modern, edge-cached architecture to ensure instant loading times globally.',
title: t('marketing.benefits.highPerformance.title'),
description: t('marketing.benefits.highPerformance.description'),
color: 'text-purple-600 dark:text-purple-400',
bgColor: 'bg-purple-100 dark:bg-purple-900/30',
},
{
icon: Headphones,
title: 'Expert Support',
description: 'Our team of scheduling experts is available to help you optimize your automation workflows.',
title: t('marketing.benefits.expertSupport.title'),
description: t('marketing.benefits.expertSupport.description'),
color: 'text-orange-600 dark:text-orange-400',
bgColor: 'bg-orange-100 dark:bg-orange-900/30',
},

View File

@@ -41,7 +41,7 @@ const Footer: React.FC = () => {
<Link to="/" className="flex items-center gap-2 mb-4 group">
<SmoothScheduleLogo className="h-12 w-12 text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors" />
<span className="text-lg font-bold text-gray-900 dark:text-white">
Smooth Schedule
{t('marketing.footer.brandName')}
</span>
</Link>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">

View File

@@ -21,16 +21,16 @@ const Hero: React.FC = () => {
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-50 dark:bg-brand-900/30 border border-brand-100 dark:border-brand-800 mb-6">
<span className="flex h-2 w-2 rounded-full bg-brand-600 dark:bg-brand-400 animate-pulse" />
<span className="text-sm font-medium text-brand-700 dark:text-brand-300">
New: Automation Marketplace
{t('marketing.hero.badge')}
</span>
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-gray-900 dark:text-white mb-6">
The Operating System for <span className="text-brand-600 dark:text-brand-400">Service Businesses</span>
{t('marketing.hero.title')} <span className="text-brand-600 dark:text-brand-400">{t('marketing.hero.titleHighlight')}</span>
</h1>
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-400 mb-8 max-w-2xl mx-auto lg:mx-0">
Orchestrate your entire operation with intelligent scheduling and powerful automation. No coding required.
{t('marketing.hero.description')}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start mb-10">
@@ -38,7 +38,7 @@ const Hero: React.FC = () => {
to="/signup"
className="inline-flex items-center justify-center px-6 py-3 text-base font-medium text-white bg-brand-600 hover:bg-brand-700 rounded-lg transition-colors shadow-lg shadow-brand-600/20"
>
Start Free Trial
{t('marketing.hero.startFreeTrial')}
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
<Link
@@ -46,22 +46,22 @@ const Hero: React.FC = () => {
className="inline-flex items-center justify-center px-6 py-3 text-base font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<Play className="mr-2 h-5 w-5 fill-current" />
Watch Demo
{t('marketing.hero.watchDemo')}
</Link>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-4 justify-center lg:justify-start text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>No credit card required</span>
<span>{t('marketing.hero.noCreditCard')}</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>14-day free trial</span>
<span>{t('marketing.hero.freeTrial')}</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>Cancel anytime</span>
<span>{t('marketing.hero.cancelAnytime')}</span>
</div>
</div>
</div>
@@ -74,17 +74,17 @@ const Hero: React.FC = () => {
<div className="inline-flex p-4 bg-brand-500/20 rounded-2xl mb-6">
<CheckCircle2 className="w-16 h-16 text-brand-400" />
</div>
<h3 className="text-2xl font-bold text-white mb-2">Automated Success</h3>
<p className="text-gray-400">Your business, running on autopilot.</p>
<h3 className="text-2xl font-bold text-white mb-2">{t('marketing.hero.visualContent.automatedSuccess')}</h3>
<p className="text-gray-400">{t('marketing.hero.visualContent.autopilot')}</p>
<div className="mt-8 grid grid-cols-2 gap-4">
<div className="bg-gray-800/50 p-3 rounded-lg border border-gray-700">
<div className="text-green-400 font-bold">+24%</div>
<div className="text-xs text-gray-500">Revenue</div>
<div className="text-xs text-gray-500">{t('marketing.hero.visualContent.revenue')}</div>
</div>
<div className="bg-gray-800/50 p-3 rounded-lg border border-gray-700">
<div className="text-blue-400 font-bold">-40%</div>
<div className="text-xs text-gray-500">No-Shows</div>
<div className="text-xs text-gray-500">{t('marketing.hero.visualContent.noShows')}</div>
</div>
</div>
</div>
@@ -96,8 +96,8 @@ const Hero: React.FC = () => {
<CheckCircle2 className="w-6 h-6" />
</div>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">Revenue Optimized</div>
<div className="text-xs text-gray-500 dark:text-gray-400">+$2,400 this week</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{t('marketing.hero.visualContent.revenueOptimized')}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('marketing.hero.visualContent.thisWeek')}</div>
</div>
</div>
</div>

View File

@@ -70,7 +70,7 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme, user }) => {
<Link to="/" className="flex items-center gap-2 group">
<SmoothScheduleLogo className="h-12 w-12 text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors" />
<span className="text-xl font-bold text-gray-900 dark:text-white hidden sm:block">
Smooth Schedule
{t('marketing.nav.brandName')}
</span>
</Link>
@@ -102,7 +102,7 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme, user }) => {
<button
onClick={toggleTheme}
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={darkMode ? t('marketing.nav.switchToLightMode') : t('marketing.nav.switchToDarkMode')}
>
{darkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
@@ -136,7 +136,7 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme, user }) => {
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="lg:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Toggle menu"
aria-label={t('marketing.nav.toggleMenu')}
>
{isMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>

View File

@@ -1,9 +1,11 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Mail, Calendar, Bell, ArrowRight, Zap, CheckCircle2, Code, LayoutGrid } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import CodeBlock from './CodeBlock';
const PluginShowcase: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(0);
const [viewMode, setViewMode] = useState<'marketplace' | 'code'>('marketplace');
@@ -11,69 +13,29 @@ const PluginShowcase: React.FC = () => {
{
id: 'winback',
icon: Mail,
title: 'Client Win-Back',
description: 'Automatically re-engage customers who haven\'t visited in 60 days.',
stats: ['+15% Retention', '$4k/mo Revenue'],
title: t('marketing.plugins.examples.winback.title'),
description: t('marketing.plugins.examples.winback.description'),
stats: [t('marketing.plugins.examples.winback.stats.retention'), t('marketing.plugins.examples.winback.stats.revenue')],
marketplaceImage: 'bg-gradient-to-br from-pink-500 to-rose-500',
code: `# Win back lost customers
days_inactive = 60
discount = "20%"
# Find inactive customers
inactive = api.get_customers(
last_visit_lt=days_ago(days_inactive)
)
# Send personalized offer
for customer in inactive:
api.send_email(
to=customer.email,
subject="We miss you!",
body=f"Come back for {discount} off!"
)`,
code: t('marketing.plugins.examples.winback.code'),
},
{
id: 'noshow',
icon: Bell,
title: 'No-Show Prevention',
description: 'Send SMS reminders 2 hours before appointments to reduce no-shows.',
stats: ['-40% No-Shows', 'Better Utilization'],
title: t('marketing.plugins.examples.noshow.title'),
description: t('marketing.plugins.examples.noshow.description'),
stats: [t('marketing.plugins.examples.noshow.stats.reduction'), t('marketing.plugins.examples.noshow.stats.utilization')],
marketplaceImage: 'bg-gradient-to-br from-blue-500 to-cyan-500',
code: `# Prevent no-shows
hours_before = 2
# Find upcoming appointments
upcoming = api.get_appointments(
start_time__within=hours(hours_before)
)
# Send SMS reminder
for appt in upcoming:
api.send_sms(
to=appt.customer.phone,
body=f"Reminder: Appointment in 2h at {appt.time}"
)`,
code: t('marketing.plugins.examples.noshow.code'),
},
{
id: 'report',
icon: Calendar,
title: 'Daily Reports',
description: 'Get a summary of tomorrow\'s schedule sent to your inbox every evening.',
stats: ['Save 30min/day', 'Full Visibility'],
title: t('marketing.plugins.examples.report.title'),
description: t('marketing.plugins.examples.report.description'),
stats: [t('marketing.plugins.examples.report.stats.timeSaved'), t('marketing.plugins.examples.report.stats.visibility')],
marketplaceImage: 'bg-gradient-to-br from-purple-500 to-indigo-500',
code: `# Daily Manager Report
tomorrow = date.today() + timedelta(days=1)
# Get schedule stats
stats = api.get_schedule_stats(date=tomorrow)
revenue = api.forecast_revenue(date=tomorrow)
# Email manager
api.send_email(
to="manager@business.com",
subject=f"Schedule for {tomorrow}",
body=f"Bookings: {stats.count}, Est. Rev: \${revenue}"
)`,
code: t('marketing.plugins.examples.report.code'),
},
];
@@ -88,16 +50,15 @@ api.send_email(
<div>
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 text-sm font-medium mb-6">
<Zap className="w-4 h-4" />
<span>Limitless Automation</span>
<span>{t('marketing.plugins.badge')}</span>
</div>
<h2 className="text-4xl font-bold text-gray-900 dark:text-white mb-6">
Choose from our Marketplace, or build your own.
{t('marketing.plugins.headline')}
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-10">
Browse hundreds of pre-built plugins to automate your workflows instantly.
Need something custom? Developers can write Python scripts to extend the platform endlessly.
{t('marketing.plugins.subheadline')}
</p>
<div className="space-y-4">
@@ -147,7 +108,7 @@ api.send_email(
}`}
>
<LayoutGrid className="w-4 h-4" />
Marketplace
{t('marketing.plugins.viewToggle.marketplace')}
</button>
<button
onClick={() => setViewMode('code')}
@@ -157,7 +118,7 @@ api.send_email(
}`}
>
<Code className="w-4 h-4" />
Developer
{t('marketing.plugins.viewToggle.developer')}
</button>
</div>
@@ -190,10 +151,10 @@ api.send_email(
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white">{examples[activeTab].title}</h3>
<div className="text-sm text-gray-500">by SmoothSchedule Team</div>
<div className="text-sm text-gray-500">{t('marketing.plugins.marketplaceCard.author')}</div>
</div>
<button className="px-4 py-2 bg-brand-600 text-white rounded-lg font-medium text-sm hover:bg-brand-700 transition-colors">
Install Plugin
{t('marketing.plugins.marketplaceCard.installButton')}
</button>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-6">
@@ -205,7 +166,7 @@ api.send_email(
<div key={i} className="w-6 h-6 rounded-full bg-gray-300 border-2 border-white dark:border-gray-800" />
))}
</div>
<span>Used by 1,200+ businesses</span>
<span>{t('marketing.plugins.marketplaceCard.usedBy')}</span>
</div>
</div>
</div>
@@ -220,7 +181,7 @@ api.send_email(
{/* CTA */}
<div className="mt-6 text-right">
<a href="/features" className="inline-flex items-center gap-2 text-brand-600 dark:text-brand-400 font-medium hover:underline">
Explore the Marketplace <ArrowRight className="w-4 h-4" />
{t('marketing.plugins.cta')} <ArrowRight className="w-4 h-4" />
</a>
</div>
</motion.div>

View File

@@ -1,69 +1,72 @@
import React from 'react';
import { Check, X } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const PricingTable: React.FC = () => {
const { t } = useTranslation();
const tiers = [
{
name: 'Starter',
name: t('marketing.pricing.tiers.starter.name'),
price: '$0',
period: '/month',
description: 'Perfect for solo practitioners and small studios.',
period: t('marketing.pricing.perMonth'),
description: t('marketing.pricing.tiers.starter.description'),
features: [
'1 User',
'Unlimited Appointments',
'1 Active Automation',
'Basic Reporting',
'Email Support',
t('marketing.pricing.tiers.starter.features.0'),
t('marketing.pricing.tiers.starter.features.1'),
t('marketing.pricing.tiers.starter.features.2'),
t('marketing.pricing.tiers.starter.features.3'),
t('marketing.pricing.tiers.starter.features.4'),
],
notIncluded: [
'Custom Domain',
'Python Scripting',
'White-Labeling',
'Priority Support',
t('marketing.pricing.tiers.starter.notIncluded.0'),
t('marketing.pricing.tiers.starter.notIncluded.1'),
t('marketing.pricing.tiers.starter.notIncluded.2'),
t('marketing.pricing.tiers.starter.notIncluded.3'),
],
cta: 'Start Free',
cta: t('marketing.pricing.tiers.starter.cta'),
ctaLink: '/signup',
popular: false,
},
{
name: 'Pro',
name: t('marketing.pricing.tiers.pro.name'),
price: '$29',
period: '/month',
description: 'For growing businesses that need automation.',
period: t('marketing.pricing.perMonth'),
description: t('marketing.pricing.tiers.pro.description'),
features: [
'5 Users',
'Unlimited Appointments',
'5 Active Automations',
'Advanced Reporting',
'Priority Email Support',
'SMS Reminders',
t('marketing.pricing.tiers.pro.features.0'),
t('marketing.pricing.tiers.pro.features.1'),
t('marketing.pricing.tiers.pro.features.2'),
t('marketing.pricing.tiers.pro.features.3'),
t('marketing.pricing.tiers.pro.features.4'),
t('marketing.pricing.tiers.pro.features.5'),
],
notIncluded: [
'Custom Domain',
'Python Scripting',
'White-Labeling',
t('marketing.pricing.tiers.pro.notIncluded.0'),
t('marketing.pricing.tiers.pro.notIncluded.1'),
t('marketing.pricing.tiers.pro.notIncluded.2'),
],
cta: 'Start Trial',
cta: t('marketing.pricing.tiers.pro.cta'),
ctaLink: '/signup?plan=pro',
popular: true,
},
{
name: 'Business',
name: t('marketing.pricing.tiers.business.name'),
price: '$99',
period: '/month',
description: 'Full power of the platform for serious operations.',
period: t('marketing.pricing.perMonth'),
description: t('marketing.pricing.tiers.business.description'),
features: [
'Unlimited Users',
'Unlimited Appointments',
'Unlimited Automations',
'Custom Python Scripts',
'Custom Domain (White-Label)',
'Dedicated Support',
'API Access',
t('marketing.pricing.tiers.business.features.0'),
t('marketing.pricing.tiers.business.features.1'),
t('marketing.pricing.tiers.business.features.2'),
t('marketing.pricing.tiers.business.features.3'),
t('marketing.pricing.tiers.business.features.4'),
t('marketing.pricing.tiers.business.features.5'),
t('marketing.pricing.tiers.business.features.6'),
],
notIncluded: [],
cta: 'Contact Sales',
cta: t('marketing.pricing.contactSales'),
ctaLink: '/contact',
popular: false,
},
@@ -81,7 +84,7 @@ const PricingTable: React.FC = () => {
>
{tier.popular && (
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 px-4 py-1 bg-brand-500 text-white text-sm font-medium rounded-full">
Most Popular
{t('marketing.pricing.mostPopular')}
</div>
)}

View File

@@ -0,0 +1,533 @@
/**
* Unit tests for CTASection component
*
* Tests cover:
* - Component rendering in both variants (default and minimal)
* - CTA text rendering
* - Button/link presence and navigation
* - Click navigation behavior
* - Icon display
* - Internationalization (i18n)
* - Accessibility
* - Styling variations
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import React from 'react';
import CTASection from '../CTASection';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'marketing.cta.ready': 'Ready to get started?',
'marketing.cta.readySubtitle': 'Join thousands of businesses already using SmoothSchedule.',
'marketing.cta.startFree': 'Get Started Free',
'marketing.cta.talkToSales': 'Talk to Sales',
'marketing.cta.noCredit': 'No credit card required',
};
return translations[key] || key;
},
}),
}));
// Test wrapper with Router
const createWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<BrowserRouter>{children}</BrowserRouter>
);
};
describe('CTASection', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Default Variant', () => {
describe('Rendering', () => {
it('should render the CTA section', () => {
render(<CTASection />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading).toBeInTheDocument();
});
it('should render CTA text elements', () => {
render(<CTASection />, { wrapper: createWrapper() });
// Main heading
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading).toBeInTheDocument();
// Subtitle
const subtitle = screen.getByText(/join thousands of businesses/i);
expect(subtitle).toBeInTheDocument();
// No credit card required
const disclaimer = screen.getByText(/no credit card required/i);
expect(disclaimer).toBeInTheDocument();
});
it('should render with correct text hierarchy', () => {
render(<CTASection />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading.tagName).toBe('H2');
});
});
describe('Button/Link Presence', () => {
it('should render the signup button', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toBeInTheDocument();
});
it('should render the talk to sales button', () => {
render(<CTASection />, { wrapper: createWrapper() });
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
expect(salesButton).toBeInTheDocument();
});
it('should render both CTA buttons', () => {
render(<CTASection />, { wrapper: createWrapper() });
const links = screen.getAllByRole('link');
expect(links).toHaveLength(2);
});
});
describe('Navigation', () => {
it('should have correct href for signup button', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toHaveAttribute('href', '/signup');
});
it('should have correct href for sales button', () => {
render(<CTASection />, { wrapper: createWrapper() });
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
expect(salesButton).toHaveAttribute('href', '/contact');
});
it('should navigate when signup button is clicked', async () => {
const user = userEvent.setup();
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
// Click should not throw error
await expect(user.click(signupButton)).resolves.not.toThrow();
});
it('should navigate when sales button is clicked', async () => {
const user = userEvent.setup();
render(<CTASection />, { wrapper: createWrapper() });
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
// Click should not throw error
await expect(user.click(salesButton)).resolves.not.toThrow();
});
});
describe('Icon Display', () => {
it('should display ArrowRight icon on signup button', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
const icon = signupButton.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('should have correct icon size', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
const icon = signupButton.querySelector('svg');
expect(icon).toHaveClass('h-5', 'w-5');
});
});
describe('Styling', () => {
it('should apply gradient background', () => {
const { container } = render(<CTASection />, { wrapper: createWrapper() });
const section = container.querySelector('section');
expect(section).toHaveClass('bg-gradient-to-br', 'from-brand-600', 'to-brand-700');
});
it('should apply correct padding', () => {
const { container } = render(<CTASection />, { wrapper: createWrapper() });
const section = container.querySelector('section');
expect(section).toHaveClass('py-20', 'lg:py-28');
});
it('should style signup button as primary CTA', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toHaveClass('bg-white', 'text-brand-600');
expect(signupButton).toHaveClass('hover:bg-brand-50');
});
it('should style sales button as secondary CTA', () => {
render(<CTASection />, { wrapper: createWrapper() });
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
expect(salesButton).toHaveClass('bg-white/10', 'text-white');
expect(salesButton).toHaveClass('hover:bg-white/20');
});
it('should have responsive button layout', () => {
const { container } = render(<CTASection />, { wrapper: createWrapper() });
const buttonContainer = container.querySelector('.flex.flex-col.sm\\:flex-row');
expect(buttonContainer).toBeInTheDocument();
});
it('should apply shadow to signup button', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toHaveClass('shadow-lg', 'shadow-black/10');
});
});
describe('Background Pattern', () => {
it('should render decorative background elements', () => {
const { container } = render(<CTASection />, { wrapper: createWrapper() });
const backgroundPattern = container.querySelector('.absolute.inset-0');
expect(backgroundPattern).toBeInTheDocument();
});
});
});
describe('Minimal Variant', () => {
describe('Rendering', () => {
it('should render the minimal CTA section', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading).toBeInTheDocument();
});
it('should render CTA text in minimal variant', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading).toBeInTheDocument();
const subtitle = screen.getByText(/join thousands of businesses/i);
expect(subtitle).toBeInTheDocument();
});
it('should only render one button in minimal variant', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const links = screen.getAllByRole('link');
expect(links).toHaveLength(1);
});
});
describe('Button/Link Presence', () => {
it('should render only the signup button', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toBeInTheDocument();
});
it('should not render the sales button', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const salesButton = screen.queryByRole('link', { name: /talk to sales/i });
expect(salesButton).not.toBeInTheDocument();
});
it('should not render the disclaimer text', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const disclaimer = screen.queryByText(/no credit card required/i);
expect(disclaimer).not.toBeInTheDocument();
});
});
describe('Navigation', () => {
it('should have correct href for signup button', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toHaveAttribute('href', '/signup');
});
it('should navigate when button is clicked', async () => {
const user = userEvent.setup();
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
// Click should not throw error
await expect(user.click(signupButton)).resolves.not.toThrow();
});
});
describe('Icon Display', () => {
it('should display ArrowRight icon', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
const icon = signupButton.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('should have correct icon size', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
const icon = signupButton.querySelector('svg');
expect(icon).toHaveClass('h-5', 'w-5');
});
});
describe('Styling', () => {
it('should apply white background', () => {
const { container } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const section = container.querySelector('section');
expect(section).toHaveClass('bg-white', 'dark:bg-gray-900');
});
it('should apply minimal padding', () => {
const { container } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const section = container.querySelector('section');
expect(section).toHaveClass('py-16');
});
it('should use brand colors for button', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toHaveClass('bg-brand-600', 'text-white');
expect(signupButton).toHaveClass('hover:bg-brand-700');
});
it('should have smaller heading size', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading).toHaveClass('text-2xl', 'sm:text-3xl');
});
it('should not have gradient background', () => {
const { container } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const section = container.querySelector('section');
expect(section).not.toHaveClass('bg-gradient-to-br');
});
});
});
describe('Variant Comparison', () => {
it('should render different layouts for different variants', () => {
const { container: defaultContainer } = render(<CTASection />, { wrapper: createWrapper() });
const { container: minimalContainer } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const defaultSection = defaultContainer.querySelector('section');
const minimalSection = minimalContainer.querySelector('section');
expect(defaultSection?.className).not.toEqual(minimalSection?.className);
});
it('should use default variant when no variant prop provided', () => {
render(<CTASection />, { wrapper: createWrapper() });
// Check for elements unique to default variant
const salesButton = screen.queryByRole('link', { name: /talk to sales/i });
expect(salesButton).toBeInTheDocument();
});
it('should switch variants correctly', () => {
const { rerender } = render(<CTASection />, { wrapper: createWrapper() });
// Should have 2 buttons in default
let links = screen.getAllByRole('link');
expect(links).toHaveLength(2);
rerender(<CTASection variant="minimal" />);
// Should have 1 button in minimal
links = screen.getAllByRole('link');
expect(links).toHaveLength(1);
});
});
describe('Internationalization', () => {
it('should use translation for heading', () => {
render(<CTASection />, { wrapper: createWrapper() });
const heading = screen.getByText('Ready to get started?');
expect(heading).toBeInTheDocument();
});
it('should use translation for subtitle', () => {
render(<CTASection />, { wrapper: createWrapper() });
const subtitle = screen.getByText('Join thousands of businesses already using SmoothSchedule.');
expect(subtitle).toBeInTheDocument();
});
it('should use translation for button text', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toHaveTextContent('Get Started Free');
});
it('should use translation for sales button text', () => {
render(<CTASection />, { wrapper: createWrapper() });
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
expect(salesButton).toHaveTextContent('Talk to Sales');
});
it('should use translation for disclaimer', () => {
render(<CTASection />, { wrapper: createWrapper() });
const disclaimer = screen.getByText('No credit card required');
expect(disclaimer).toBeInTheDocument();
});
it('should translate all text in minimal variant', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
expect(screen.getByText('Ready to get started?')).toBeInTheDocument();
expect(screen.getByText('Join thousands of businesses already using SmoothSchedule.')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /get started free/i })).toHaveTextContent('Get Started Free');
});
});
describe('Accessibility', () => {
it('should have semantic section element', () => {
const { container } = render(<CTASection />, { wrapper: createWrapper() });
const section = container.querySelector('section');
expect(section).toBeInTheDocument();
});
it('should have heading hierarchy', () => {
render(<CTASection />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { level: 2 });
expect(heading).toBeInTheDocument();
});
it('should have keyboard accessible links', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
expect(signupButton.tagName).toBe('A');
expect(salesButton.tagName).toBe('A');
});
it('should have descriptive link text', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
expect(signupButton).toHaveAccessibleName();
expect(salesButton).toHaveAccessibleName();
});
it('should maintain accessibility in minimal variant', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { level: 2 });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(heading).toBeInTheDocument();
expect(signupButton).toHaveAccessibleName();
});
});
describe('Responsive Design', () => {
it('should have responsive heading sizes', () => {
render(<CTASection />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading).toHaveClass('text-3xl', 'sm:text-4xl', 'lg:text-5xl');
});
it('should have responsive subtitle size', () => {
render(<CTASection />, { wrapper: createWrapper() });
const subtitle = screen.getByText(/join thousands of businesses/i);
expect(subtitle).toHaveClass('text-lg', 'sm:text-xl');
});
it('should have responsive button layout', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toHaveClass('w-full', 'sm:w-auto');
});
it('should have responsive padding in minimal variant', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading).toHaveClass('text-2xl', 'sm:text-3xl');
});
});
describe('Integration', () => {
it('should render correctly with default variant', () => {
render(<CTASection />, { wrapper: createWrapper() });
expect(screen.getByRole('heading', { name: /ready to get started/i })).toBeInTheDocument();
expect(screen.getByText(/join thousands of businesses/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /get started free/i })).toHaveAttribute('href', '/signup');
expect(screen.getByRole('link', { name: /talk to sales/i })).toHaveAttribute('href', '/contact');
expect(screen.getByText(/no credit card required/i)).toBeInTheDocument();
});
it('should render correctly with minimal variant', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
expect(screen.getByRole('heading', { name: /ready to get started/i })).toBeInTheDocument();
expect(screen.getByText(/join thousands of businesses/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /get started free/i })).toHaveAttribute('href', '/signup');
expect(screen.queryByRole('link', { name: /talk to sales/i })).not.toBeInTheDocument();
expect(screen.queryByText(/no credit card required/i)).not.toBeInTheDocument();
});
it('should maintain structure with all elements in place', () => {
const { container } = render(<CTASection />, { wrapper: createWrapper() });
const section = container.querySelector('section');
const heading = screen.getByRole('heading');
const subtitle = screen.getByText(/join thousands/i);
const buttons = screen.getAllByRole('link');
expect(section).toContainElement(heading);
expect(section).toContainElement(subtitle);
buttons.forEach(button => {
expect(section).toContainElement(button);
});
});
});
});

View File

@@ -0,0 +1,366 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import CodeBlock from '../CodeBlock';
describe('CodeBlock', () => {
// Mock clipboard API
const originalClipboard = navigator.clipboard;
const mockWriteText = vi.fn();
beforeEach(() => {
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
});
vi.useFakeTimers();
});
afterEach(() => {
Object.assign(navigator, {
clipboard: originalClipboard,
});
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('Rendering', () => {
it('renders code content correctly', () => {
const code = 'print("Hello, World!")';
const { container } = render(<CodeBlock code={code} />);
// Check that the code content is rendered (text is within code element)
const codeElement = container.querySelector('code');
expect(codeElement?.textContent).toContain('print(');
// Due to string splitting in regex, checking for function call
expect(container.querySelector('.text-blue-400')?.textContent).toContain('print(');
});
it('renders multi-line code with line numbers', () => {
const code = 'line 1\nline 2\nline 3';
render(<CodeBlock code={code} />);
// Check line numbers
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
// Check content
expect(screen.getByText(/line 1/)).toBeInTheDocument();
expect(screen.getByText(/line 2/)).toBeInTheDocument();
expect(screen.getByText(/line 3/)).toBeInTheDocument();
});
it('renders terminal-style dots', () => {
render(<CodeBlock code="test code" />);
const container = screen.getByRole('button', { name: /copy code/i }).closest('div');
expect(container).toBeInTheDocument();
// Check for the presence of the terminal-style dots container
const dotsContainer = container?.querySelector('.flex.gap-1\\.5');
expect(dotsContainer).toBeInTheDocument();
expect(dotsContainer?.children).toHaveLength(3);
});
});
describe('Language and Filename', () => {
it('applies default language class when no language specified', () => {
const code = 'test code';
render(<CodeBlock code={code} />);
const codeElement = screen.getByText(/test code/).closest('code');
expect(codeElement).toHaveClass('language-python');
});
it('applies custom language class when specified', () => {
const code = 'const x = 1;';
render(<CodeBlock code={code} language="javascript" />);
const codeElement = screen.getByText(/const x = 1/).closest('code');
expect(codeElement).toHaveClass('language-javascript');
});
it('displays filename when provided', () => {
const code = 'test code';
const filename = 'example.py';
render(<CodeBlock code={code} filename={filename} />);
expect(screen.getByText(filename)).toBeInTheDocument();
});
it('does not display filename when not provided', () => {
const code = 'test code';
render(<CodeBlock code={code} />);
// The filename element should not exist in the DOM
const filenameElement = screen.queryByText(/\.py$/);
expect(filenameElement).not.toBeInTheDocument();
});
});
describe('Copy Functionality', () => {
it('renders copy button', () => {
render(<CodeBlock code="test code" />);
const copyButton = screen.getByRole('button', { name: /copy code/i });
expect(copyButton).toBeInTheDocument();
});
it('copies code to clipboard when copy button is clicked', async () => {
const code = 'print("Copy me!")';
mockWriteText.mockResolvedValue(undefined);
render(<CodeBlock code={code} />);
const copyButton = screen.getByRole('button', { name: /copy code/i });
fireEvent.click(copyButton);
expect(mockWriteText).toHaveBeenCalledWith(code);
});
it('shows check icon after successful copy', async () => {
const code = 'test code';
mockWriteText.mockResolvedValue(undefined);
const { container } = render(<CodeBlock code={code} />);
const copyButton = screen.getByRole('button', { name: /copy code/i });
// Initially should show Copy icon
let copyIcon = copyButton.querySelector('svg');
expect(copyIcon).toBeInTheDocument();
// Click to copy
fireEvent.click(copyButton);
// Should immediately show Check icon (synchronous state update)
const checkIcon = container.querySelector('.text-green-400');
expect(checkIcon).toBeInTheDocument();
});
it('reverts to copy icon after 2 seconds', async () => {
const code = 'test code';
mockWriteText.mockResolvedValue(undefined);
const { container } = render(<CodeBlock code={code} />);
const copyButton = screen.getByRole('button', { name: /copy code/i });
// Click to copy
await act(async () => {
fireEvent.click(copyButton);
});
// Should show Check icon
let checkIcon = container.querySelector('.text-green-400');
expect(checkIcon).toBeInTheDocument();
// Fast-forward 2 seconds using act to wrap state updates
await act(async () => {
vi.advanceTimersByTime(2000);
});
// Should revert to Copy icon (check icon should be gone)
checkIcon = container.querySelector('.text-green-400');
expect(checkIcon).not.toBeInTheDocument();
});
});
describe('Syntax Highlighting', () => {
it('highlights Python comments', () => {
const code = '# This is a comment';
render(<CodeBlock code={code} language="python" />);
const commentElement = screen.getByText(/This is a comment/);
expect(commentElement).toBeInTheDocument();
expect(commentElement).toHaveClass('text-gray-500');
});
it('highlights JavaScript comments', () => {
const code = '// This is a comment';
render(<CodeBlock code={code} language="javascript" />);
const commentElement = screen.getByText(/This is a comment/);
expect(commentElement).toBeInTheDocument();
expect(commentElement).toHaveClass('text-gray-500');
});
it('highlights string literals', () => {
const code = 'print("Hello World")';
const { container } = render(<CodeBlock code={code} />);
const stringElements = container.querySelectorAll('.text-green-400');
expect(stringElements.length).toBeGreaterThan(0);
});
it('highlights Python keywords', () => {
const code = 'def my_function():';
const { container } = render(<CodeBlock code={code} language="python" />);
const keywordElements = container.querySelectorAll('.text-purple-400');
expect(keywordElements.length).toBeGreaterThan(0);
});
it('highlights function calls', () => {
const code = 'print("test")';
const { container } = render(<CodeBlock code={code} />);
const functionElements = container.querySelectorAll('.text-blue-400');
expect(functionElements.length).toBeGreaterThan(0);
});
it('highlights multiple keywords in a line', () => {
const code = 'if True return None';
const { container } = render(<CodeBlock code={code} />);
const keywordElements = container.querySelectorAll('.text-purple-400');
// Should highlight 'if', 'True', 'return', and 'None'
expect(keywordElements.length).toBeGreaterThanOrEqual(3);
});
it('does not highlight non-keyword words', () => {
const code = 'my_variable = 42';
render(<CodeBlock code={code} />);
const codeText = screen.getByText(/my_variable/);
expect(codeText).toBeInTheDocument();
});
});
describe('Complex Code Examples', () => {
it('handles Python code with multiple syntax elements', () => {
const code = `def greet(name):
# Print a greeting
return "Hello, " + name`;
render(<CodeBlock code={code} language="python" />);
// Check that all lines are rendered
expect(screen.getByText(/def/)).toBeInTheDocument();
expect(screen.getByText(/Print a greeting/)).toBeInTheDocument();
expect(screen.getByText(/return/)).toBeInTheDocument();
});
it('handles JavaScript code', () => {
const code = `const greeting = "Hello";
// Log the greeting
console.log(greeting);`;
render(<CodeBlock code={code} language="javascript" />);
expect(screen.getByText(/const greeting =/)).toBeInTheDocument();
expect(screen.getByText(/Log the greeting/)).toBeInTheDocument();
expect(screen.getByText(/console.log/)).toBeInTheDocument();
});
it('preserves indentation and whitespace', () => {
const code = `def test():
if True:
return 1`;
const { container } = render(<CodeBlock code={code} />);
// Check for whitespace-pre class which preserves whitespace
const codeLines = container.querySelectorAll('.whitespace-pre');
expect(codeLines.length).toBeGreaterThan(0);
});
});
describe('Edge Cases', () => {
it('handles empty code string', () => {
render(<CodeBlock code="" />);
const copyButton = screen.getByRole('button', { name: /copy code/i });
expect(copyButton).toBeInTheDocument();
});
it('handles code with only whitespace', () => {
const code = ' \n \n ';
render(<CodeBlock code={code} />);
// Should still render line numbers
expect(screen.getByText('1')).toBeInTheDocument();
});
it('handles very long single line', () => {
const code = 'x = ' + 'a'.repeat(1000);
render(<CodeBlock code={code} />);
expect(screen.getByText('1')).toBeInTheDocument();
});
it('handles special characters in code', () => {
const code = 'const regex = /[a-z]+/g;';
render(<CodeBlock code={code} language="javascript" />);
expect(screen.getByText(/regex/)).toBeInTheDocument();
});
it('handles quotes within strings', () => {
const code = 'const msg = "test message";';
const { container } = render(<CodeBlock code={code} language="javascript" />);
// Code should be rendered
expect(container.querySelector('code')).toBeInTheDocument();
// Should have string highlighting
expect(container.querySelectorAll('.text-green-400').length).toBeGreaterThan(0);
});
});
describe('Accessibility', () => {
it('has accessible copy button with title', () => {
render(<CodeBlock code="test" />);
const copyButton = screen.getByRole('button', { name: /copy code/i });
expect(copyButton).toHaveAttribute('title', 'Copy code');
});
it('uses semantic HTML elements', () => {
const { container } = render(<CodeBlock code="test" />);
const preElement = container.querySelector('pre');
const codeElement = container.querySelector('code');
expect(preElement).toBeInTheDocument();
expect(codeElement).toBeInTheDocument();
});
it('line numbers are not selectable', () => {
const { container } = render(<CodeBlock code="line 1\nline 2" />);
const lineNumbers = container.querySelectorAll('.select-none');
expect(lineNumbers.length).toBeGreaterThan(0);
});
});
describe('Styling', () => {
it('applies dark theme styling', () => {
const { container } = render(<CodeBlock code="test" />);
const mainContainer = container.querySelector('.bg-gray-900');
expect(mainContainer).toBeInTheDocument();
});
it('applies proper border and shadow', () => {
const { container } = render(<CodeBlock code="test" />);
const mainContainer = container.querySelector('.border-gray-800.shadow-2xl');
expect(mainContainer).toBeInTheDocument();
});
it('applies monospace font to code', () => {
const { container } = render(<CodeBlock code="test" />);
const preElement = container.querySelector('pre.font-mono');
expect(preElement).toBeInTheDocument();
});
it('applies correct text colors', () => {
const { container } = render(<CodeBlock code="test" />);
const codeText = container.querySelector('.text-gray-300');
expect(codeText).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,431 @@
/**
* Unit tests for FAQAccordion component
*
* Tests the FAQ accordion functionality including:
* - Rendering questions and answers
* - Expanding and collapsing items
* - Single-item accordion behavior (only one open at a time)
* - Accessibility attributes
*/
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import FAQAccordion from '../FAQAccordion';
// Test data
const mockFAQItems = [
{
question: 'What is SmoothSchedule?',
answer: 'SmoothSchedule is a comprehensive scheduling platform for businesses.',
},
{
question: 'How much does it cost?',
answer: 'We offer flexible pricing plans starting at $29/month.',
},
{
question: 'Can I try it for free?',
answer: 'Yes! We offer a 14-day free trial with no credit card required.',
},
];
describe('FAQAccordion', () => {
describe('Rendering', () => {
it('should render all questions', () => {
render(<FAQAccordion items={mockFAQItems} />);
expect(screen.getByText('What is SmoothSchedule?')).toBeInTheDocument();
expect(screen.getByText('How much does it cost?')).toBeInTheDocument();
expect(screen.getByText('Can I try it for free?')).toBeInTheDocument();
});
it('should render first item as expanded by default', () => {
render(<FAQAccordion items={mockFAQItems} />);
// First answer should be visible
expect(
screen.getByText('SmoothSchedule is a comprehensive scheduling platform for businesses.')
).toBeInTheDocument();
// Other answers should not be visible
expect(
screen.queryByText('We offer flexible pricing plans starting at $29/month.')
).toBeInTheDocument();
expect(
screen.queryByText('Yes! We offer a 14-day free trial with no credit card required.')
).toBeInTheDocument();
});
it('should render with empty items array', () => {
const { container } = render(<FAQAccordion items={[]} />);
// Should render the container but no items
expect(container.querySelector('.space-y-4')).toBeInTheDocument();
expect(container.querySelectorAll('button')).toHaveLength(0);
});
it('should render with single item', () => {
const singleItem = [mockFAQItems[0]];
render(<FAQAccordion items={singleItem} />);
expect(screen.getByText('What is SmoothSchedule?')).toBeInTheDocument();
expect(
screen.getByText('SmoothSchedule is a comprehensive scheduling platform for businesses.')
).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have aria-expanded attribute on buttons', () => {
render(<FAQAccordion items={mockFAQItems} />);
const buttons = screen.getAllByRole('button');
// First button should be expanded (default)
expect(buttons[0]).toHaveAttribute('aria-expanded', 'true');
// Other buttons should be collapsed
expect(buttons[1]).toHaveAttribute('aria-expanded', 'false');
expect(buttons[2]).toHaveAttribute('aria-expanded', 'false');
});
it('should update aria-expanded when item is toggled', () => {
render(<FAQAccordion items={mockFAQItems} />);
const buttons = screen.getAllByRole('button');
const secondButton = buttons[1];
// Initially collapsed
expect(secondButton).toHaveAttribute('aria-expanded', 'false');
// Click to expand
fireEvent.click(secondButton);
// Now expanded
expect(secondButton).toHaveAttribute('aria-expanded', 'true');
});
it('should have proper button semantics', () => {
render(<FAQAccordion items={mockFAQItems} />);
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
// Each button should have text content
expect(button.textContent).toBeTruthy();
// Each button should be clickable
expect(button).toBeEnabled();
});
});
});
describe('Expand/Collapse Behavior', () => {
it('should expand answer when question is clicked', () => {
render(<FAQAccordion items={mockFAQItems} />);
const secondQuestion = screen.getByText('How much does it cost?');
// Answer should be in the document but potentially hidden
const answer = screen.getByText('We offer flexible pricing plans starting at $29/month.');
const answerContainer = answer.closest('.overflow-hidden');
// Initially collapsed (max-h-0)
expect(answerContainer).toHaveClass('max-h-0');
// Click to expand
fireEvent.click(secondQuestion);
// Now expanded (max-h-96)
expect(answerContainer).toHaveClass('max-h-96');
});
it('should collapse answer when clicking expanded question', () => {
render(<FAQAccordion items={mockFAQItems} />);
const firstQuestion = screen.getByText('What is SmoothSchedule?');
const answer = screen.getByText(
'SmoothSchedule is a comprehensive scheduling platform for businesses.'
);
const answerContainer = answer.closest('.overflow-hidden');
// Initially expanded (first item is open by default)
expect(answerContainer).toHaveClass('max-h-96');
// Click to collapse
fireEvent.click(firstQuestion);
// Now collapsed
expect(answerContainer).toHaveClass('max-h-0');
});
it('should collapse answer when clicking it again (toggle)', () => {
render(<FAQAccordion items={mockFAQItems} />);
const secondQuestion = screen.getByText('How much does it cost?');
const answer = screen.getByText('We offer flexible pricing plans starting at $29/month.');
const answerContainer = answer.closest('.overflow-hidden');
// Initially collapsed
expect(answerContainer).toHaveClass('max-h-0');
// Click to expand
fireEvent.click(secondQuestion);
expect(answerContainer).toHaveClass('max-h-96');
// Click again to collapse
fireEvent.click(secondQuestion);
expect(answerContainer).toHaveClass('max-h-0');
});
});
describe('Single Item Accordion Behavior', () => {
it('should only allow one item to be expanded at a time', () => {
render(<FAQAccordion items={mockFAQItems} />);
const firstQuestion = screen.getByText('What is SmoothSchedule?');
const secondQuestion = screen.getByText('How much does it cost?');
const thirdQuestion = screen.getByText('Can I try it for free?');
const firstAnswer = screen.getByText(
'SmoothSchedule is a comprehensive scheduling platform for businesses.'
);
const secondAnswer = screen.getByText(
'We offer flexible pricing plans starting at $29/month.'
);
const thirdAnswer = screen.getByText(
'Yes! We offer a 14-day free trial with no credit card required.'
);
// Initially, first item is expanded
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
// Click second question
fireEvent.click(secondQuestion);
// Now only second item is expanded
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
// Click third question
fireEvent.click(thirdQuestion);
// Now only third item is expanded
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
// Click first question
fireEvent.click(firstQuestion);
// Back to first item expanded
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
});
it('should close the currently open item when opening another', () => {
render(<FAQAccordion items={mockFAQItems} />);
const buttons = screen.getAllByRole('button');
// First button is expanded by default
expect(buttons[0]).toHaveAttribute('aria-expanded', 'true');
expect(buttons[1]).toHaveAttribute('aria-expanded', 'false');
// Click second button
fireEvent.click(buttons[1]);
// First button should now be collapsed, second expanded
expect(buttons[0]).toHaveAttribute('aria-expanded', 'false');
expect(buttons[1]).toHaveAttribute('aria-expanded', 'true');
});
it('should allow collapsing all items by clicking the open one', () => {
render(<FAQAccordion items={mockFAQItems} />);
const firstQuestion = screen.getByText('What is SmoothSchedule?');
const buttons = screen.getAllByRole('button');
// Initially first item is expanded
expect(buttons[0]).toHaveAttribute('aria-expanded', 'true');
// Click to collapse
fireEvent.click(firstQuestion);
// All items should be collapsed
buttons.forEach((button) => {
expect(button).toHaveAttribute('aria-expanded', 'false');
});
});
});
describe('Chevron Icon Rotation', () => {
it('should rotate chevron icon when item is expanded', () => {
render(<FAQAccordion items={mockFAQItems} />);
const buttons = screen.getAllByRole('button');
const firstButton = buttons[0];
const secondButton = buttons[1];
// First item is expanded, so chevron should be rotated
const firstChevron = firstButton.querySelector('svg');
expect(firstChevron).toHaveClass('rotate-180');
// Second item is collapsed, so chevron should not be rotated
const secondChevron = secondButton.querySelector('svg');
expect(secondChevron).not.toHaveClass('rotate-180');
// Click second button
fireEvent.click(secondButton);
// Now second chevron should be rotated, first should not
expect(firstChevron).not.toHaveClass('rotate-180');
expect(secondChevron).toHaveClass('rotate-180');
});
it('should toggle chevron rotation when item is clicked multiple times', () => {
render(<FAQAccordion items={mockFAQItems} />);
const firstButton = screen.getAllByRole('button')[0];
const chevron = firstButton.querySelector('svg');
// Initially rotated (first item is expanded)
expect(chevron).toHaveClass('rotate-180');
// Click to collapse
fireEvent.click(firstButton);
expect(chevron).not.toHaveClass('rotate-180');
// Click to expand
fireEvent.click(firstButton);
expect(chevron).toHaveClass('rotate-180');
// Click to collapse again
fireEvent.click(firstButton);
expect(chevron).not.toHaveClass('rotate-180');
});
});
describe('Edge Cases', () => {
it('should handle items with long text content', () => {
const longTextItems = [
{
question: 'This is a very long question that might wrap to multiple lines in the UI?',
answer:
'This is a very long answer with lots of text. ' +
'It contains multiple sentences and provides detailed information. ' +
'The accordion should handle this gracefully without breaking the layout. ' +
'Users should be able to read all of this content when the item is expanded.',
},
];
render(<FAQAccordion items={longTextItems} />);
expect(
screen.getByText('This is a very long question that might wrap to multiple lines in the UI?')
).toBeInTheDocument();
const answer = screen.getByText(/This is a very long answer with lots of text/);
expect(answer).toBeInTheDocument();
});
it('should handle items with special characters', () => {
const specialCharItems = [
{
question: 'What about <special> & "characters"?',
answer: 'We support all UTF-8 characters: é, ñ, 中文, 日本語!',
},
];
render(<FAQAccordion items={specialCharItems} />);
expect(screen.getByText('What about <special> & "characters"?')).toBeInTheDocument();
expect(screen.getByText('We support all UTF-8 characters: é, ñ, 中文, 日本語!')).toBeInTheDocument();
});
it('should handle rapid clicking without breaking', () => {
render(<FAQAccordion items={mockFAQItems} />);
const buttons = screen.getAllByRole('button');
// Rapidly click different buttons
fireEvent.click(buttons[0]);
fireEvent.click(buttons[1]);
fireEvent.click(buttons[2]);
fireEvent.click(buttons[0]);
fireEvent.click(buttons[1]);
// Should still be functional - second button should be expanded
expect(buttons[1]).toHaveAttribute('aria-expanded', 'true');
expect(buttons[0]).toHaveAttribute('aria-expanded', 'false');
expect(buttons[2]).toHaveAttribute('aria-expanded', 'false');
});
it('should handle clicking on the same item multiple times', () => {
render(<FAQAccordion items={mockFAQItems} />);
const firstButton = screen.getAllByRole('button')[0];
// Initially expanded
expect(firstButton).toHaveAttribute('aria-expanded', 'true');
// Click multiple times
fireEvent.click(firstButton);
expect(firstButton).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(firstButton);
expect(firstButton).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(firstButton);
expect(firstButton).toHaveAttribute('aria-expanded', 'false');
});
});
describe('Visual States', () => {
it('should apply correct CSS classes for expanded state', () => {
render(<FAQAccordion items={mockFAQItems} />);
const firstAnswer = screen.getByText(
'SmoothSchedule is a comprehensive scheduling platform for businesses.'
);
const answerContainer = firstAnswer.closest('.overflow-hidden');
// Expanded state should have max-h-96
expect(answerContainer).toHaveClass('max-h-96');
expect(answerContainer).toHaveClass('transition-all');
expect(answerContainer).toHaveClass('duration-200');
});
it('should apply correct CSS classes for collapsed state', () => {
render(<FAQAccordion items={mockFAQItems} />);
const secondAnswer = screen.getByText('We offer flexible pricing plans starting at $29/month.');
const answerContainer = secondAnswer.closest('.overflow-hidden');
// Collapsed state should have max-h-0
expect(answerContainer).toHaveClass('max-h-0');
expect(answerContainer).toHaveClass('overflow-hidden');
});
it('should have proper container structure', () => {
const { container } = render(<FAQAccordion items={mockFAQItems} />);
// Root container should have space-y-4
const rootDiv = container.querySelector('.space-y-4');
expect(rootDiv).toBeInTheDocument();
// Each item should have proper styling
const itemContainers = container.querySelectorAll('.bg-white');
expect(itemContainers).toHaveLength(mockFAQItems.length);
itemContainers.forEach((item) => {
expect(item).toHaveClass('rounded-xl');
expect(item).toHaveClass('border');
expect(item).toHaveClass('overflow-hidden');
});
});
});
});

View File

@@ -0,0 +1,688 @@
/**
* Unit tests for FeatureCard component
*
* Tests the FeatureCard marketing component including:
* - Basic rendering with title and description
* - Icon rendering with different colors
* - CSS classes and styling
* - Hover states and animations
* - Accessibility
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Calendar, Clock, Users, CheckCircle, AlertCircle } from 'lucide-react';
import FeatureCard from '../FeatureCard';
describe('FeatureCard', () => {
describe('Basic Rendering', () => {
it('should render with title and description', () => {
render(
<FeatureCard
icon={Calendar}
title="Easy Scheduling"
description="Schedule appointments with ease using our intuitive calendar interface."
/>
);
expect(screen.getByText('Easy Scheduling')).toBeInTheDocument();
expect(
screen.getByText('Schedule appointments with ease using our intuitive calendar interface.')
).toBeInTheDocument();
});
it('should render with different content', () => {
render(
<FeatureCard
icon={Users}
title="Team Management"
description="Manage your team members and their availability efficiently."
/>
);
expect(screen.getByText('Team Management')).toBeInTheDocument();
expect(
screen.getByText('Manage your team members and their availability efficiently.')
).toBeInTheDocument();
});
it('should render with long description text', () => {
const longDescription =
'This is a very long description that contains multiple sentences. It should wrap properly and display all the content. Our feature card component is designed to handle various lengths of text gracefully.';
render(
<FeatureCard
icon={Clock}
title="Time Tracking"
description={longDescription}
/>
);
expect(screen.getByText(longDescription)).toBeInTheDocument();
});
it('should render with empty description', () => {
render(
<FeatureCard
icon={CheckCircle}
title="Success Tracking"
description=""
/>
);
expect(screen.getByText('Success Tracking')).toBeInTheDocument();
// Empty description should still render the paragraph element
const descriptionElement = screen.getByText('Success Tracking').parentElement?.querySelector('p');
expect(descriptionElement).toBeInTheDocument();
});
});
describe('Icon Rendering', () => {
it('should render the provided icon', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Calendar Feature"
description="Calendar description"
/>
);
// Check for SVG element (icons are rendered as SVG)
const svgElement = container.querySelector('svg');
expect(svgElement).toBeInTheDocument();
expect(svgElement).toHaveClass('h-6', 'w-6');
});
it('should render different icons correctly', () => {
const { container: container1 } = render(
<FeatureCard
icon={Calendar}
title="Feature 1"
description="Description 1"
/>
);
const { container: container2 } = render(
<FeatureCard
icon={Users}
title="Feature 2"
description="Description 2"
/>
);
// Both should have SVG elements
expect(container1.querySelector('svg')).toBeInTheDocument();
expect(container2.querySelector('svg')).toBeInTheDocument();
});
it('should apply correct icon size classes', () => {
const { container } = render(
<FeatureCard
icon={Clock}
title="Time Feature"
description="Time description"
/>
);
const svgElement = container.querySelector('svg');
expect(svgElement).toHaveClass('h-6');
expect(svgElement).toHaveClass('w-6');
});
});
describe('Icon Colors', () => {
it('should render with default brand color when no iconColor prop provided', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Default Color"
description="Uses brand color by default"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-brand-100');
expect(iconWrapper).toHaveClass('dark:bg-brand-900/30');
expect(iconWrapper).toHaveClass('text-brand-600');
expect(iconWrapper).toHaveClass('dark:text-brand-400');
});
it('should render with brand color when explicitly set', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Brand Color"
description="Explicit brand color"
iconColor="brand"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-brand-100');
expect(iconWrapper).toHaveClass('text-brand-600');
});
it('should render with green color', () => {
const { container } = render(
<FeatureCard
icon={CheckCircle}
title="Success Feature"
description="Green icon color"
iconColor="green"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-green-100');
expect(iconWrapper).toHaveClass('dark:bg-green-900/30');
expect(iconWrapper).toHaveClass('text-green-600');
expect(iconWrapper).toHaveClass('dark:text-green-400');
});
it('should render with purple color', () => {
const { container } = render(
<FeatureCard
icon={Users}
title="Purple Feature"
description="Purple icon color"
iconColor="purple"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-purple-100');
expect(iconWrapper).toHaveClass('text-purple-600');
});
it('should render with orange color', () => {
const { container } = render(
<FeatureCard
icon={AlertCircle}
title="Warning Feature"
description="Orange icon color"
iconColor="orange"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-orange-100');
expect(iconWrapper).toHaveClass('text-orange-600');
});
it('should render with pink color', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Pink Feature"
description="Pink icon color"
iconColor="pink"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-pink-100');
expect(iconWrapper).toHaveClass('text-pink-600');
});
it('should render with cyan color', () => {
const { container } = render(
<FeatureCard
icon={Clock}
title="Cyan Feature"
description="Cyan icon color"
iconColor="cyan"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-cyan-100');
expect(iconWrapper).toHaveClass('text-cyan-600');
});
});
describe('Styling and CSS Classes', () => {
it('should apply base card styling classes', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Card Styling"
description="Testing base styles"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('group');
expect(cardElement).toHaveClass('p-6');
expect(cardElement).toHaveClass('bg-white');
expect(cardElement).toHaveClass('dark:bg-gray-800');
expect(cardElement).toHaveClass('rounded-2xl');
});
it('should apply border classes', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Border Test"
description="Testing border styles"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('border');
expect(cardElement).toHaveClass('border-gray-200');
expect(cardElement).toHaveClass('dark:border-gray-700');
});
it('should apply hover border classes', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Hover Border"
description="Testing hover border styles"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('hover:border-brand-300');
expect(cardElement).toHaveClass('dark:hover:border-brand-700');
});
it('should apply shadow classes', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Shadow Test"
description="Testing shadow styles"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('hover:shadow-lg');
expect(cardElement).toHaveClass('hover:shadow-brand-600/5');
});
it('should apply transition classes', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Transition Test"
description="Testing transition styles"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('transition-all');
expect(cardElement).toHaveClass('duration-300');
});
it('should apply icon wrapper styling', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Icon Wrapper"
description="Testing icon wrapper styles"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('p-3');
expect(iconWrapper).toHaveClass('rounded-xl');
expect(iconWrapper).toHaveClass('mb-4');
});
it('should apply title styling', () => {
render(
<FeatureCard
icon={Calendar}
title="Title Styling"
description="Testing title styles"
/>
);
const titleElement = screen.getByText('Title Styling');
expect(titleElement).toHaveClass('text-lg');
expect(titleElement).toHaveClass('font-semibold');
expect(titleElement).toHaveClass('text-gray-900');
expect(titleElement).toHaveClass('dark:text-white');
expect(titleElement).toHaveClass('mb-2');
});
it('should apply title hover classes', () => {
render(
<FeatureCard
icon={Calendar}
title="Hover Title"
description="Testing title hover styles"
/>
);
const titleElement = screen.getByText('Hover Title');
expect(titleElement).toHaveClass('group-hover:text-brand-600');
expect(titleElement).toHaveClass('dark:group-hover:text-brand-400');
expect(titleElement).toHaveClass('transition-colors');
});
it('should apply description styling', () => {
render(
<FeatureCard
icon={Calendar}
title="Description Style"
description="Testing description styles"
/>
);
const descriptionElement = screen.getByText('Testing description styles');
expect(descriptionElement).toHaveClass('text-gray-600');
expect(descriptionElement).toHaveClass('dark:text-gray-400');
expect(descriptionElement).toHaveClass('leading-relaxed');
});
});
describe('Hover and Animation States', () => {
it('should have group class for hover effects', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Group Hover"
description="Testing group hover functionality"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('group');
});
it('should support mouse hover interactions', async () => {
const user = userEvent.setup();
const { container } = render(
<FeatureCard
icon={Calendar}
title="Mouse Hover"
description="Testing mouse hover"
/>
);
const cardElement = container.firstChild as HTMLElement;
// Hovering should not cause errors
await user.hover(cardElement);
expect(cardElement).toBeInTheDocument();
// Unhovering should not cause errors
await user.unhover(cardElement);
expect(cardElement).toBeInTheDocument();
});
it('should maintain structure during hover', async () => {
const user = userEvent.setup();
render(
<FeatureCard
icon={Calendar}
title="Structure Test"
description="Testing structure during hover"
/>
);
const titleElement = screen.getByText('Structure Test');
const descriptionElement = screen.getByText('Testing structure during hover');
// Hover over the card
await user.hover(titleElement.closest('.group')!);
// Elements should still be present
expect(titleElement).toBeInTheDocument();
expect(descriptionElement).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should use semantic HTML heading for title', () => {
render(
<FeatureCard
icon={Calendar}
title="Semantic Title"
description="Testing semantic HTML"
/>
);
const titleElement = screen.getByText('Semantic Title');
expect(titleElement.tagName).toBe('H3');
});
it('should use paragraph element for description', () => {
render(
<FeatureCard
icon={Calendar}
title="Semantic Description"
description="Testing paragraph element"
/>
);
const descriptionElement = screen.getByText('Testing paragraph element');
expect(descriptionElement.tagName).toBe('P');
});
it('should maintain readable text contrast', () => {
render(
<FeatureCard
icon={Calendar}
title="Contrast Test"
description="Testing text contrast"
/>
);
const titleElement = screen.getByText('Contrast Test');
const descriptionElement = screen.getByText('Testing text contrast');
// Title should have dark text (gray-900)
expect(titleElement).toHaveClass('text-gray-900');
// Description should have readable gray
expect(descriptionElement).toHaveClass('text-gray-600');
});
it('should be keyboard accessible when used in interactive context', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Keyboard Test"
description="Testing keyboard accessibility"
/>
);
const cardElement = container.firstChild as HTMLElement;
// Card itself is not interactive, so it shouldn't have tabIndex
expect(cardElement).not.toHaveAttribute('tabIndex');
});
it('should support screen readers with proper text hierarchy', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Screen Reader Test"
description="This is a longer description that screen readers will announce."
/>
);
// Check that heading comes before paragraph in DOM order
const heading = container.querySelector('h3');
const paragraph = container.querySelector('p');
expect(heading).toBeInTheDocument();
expect(paragraph).toBeInTheDocument();
// Verify DOM order (heading should appear before paragraph)
const headingPosition = Array.from(container.querySelectorAll('*')).indexOf(heading!);
const paragraphPosition = Array.from(container.querySelectorAll('*')).indexOf(paragraph!);
expect(headingPosition).toBeLessThan(paragraphPosition);
});
});
describe('Dark Mode Support', () => {
it('should include dark mode classes for card background', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Dark Mode Card"
description="Testing dark mode"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('dark:bg-gray-800');
});
it('should include dark mode classes for borders', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Dark Mode Border"
description="Testing dark mode borders"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('dark:border-gray-700');
expect(cardElement).toHaveClass('dark:hover:border-brand-700');
});
it('should include dark mode classes for title text', () => {
render(
<FeatureCard
icon={Calendar}
title="Dark Mode Title"
description="Testing dark mode title"
/>
);
const titleElement = screen.getByText('Dark Mode Title');
expect(titleElement).toHaveClass('dark:text-white');
expect(titleElement).toHaveClass('dark:group-hover:text-brand-400');
});
it('should include dark mode classes for description text', () => {
render(
<FeatureCard
icon={Calendar}
title="Dark Mode Description"
description="Testing dark mode description"
/>
);
const descriptionElement = screen.getByText('Testing dark mode description');
expect(descriptionElement).toHaveClass('dark:text-gray-400');
});
it('should include dark mode classes for icon colors', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Dark Mode Icon"
description="Testing dark mode icon"
iconColor="green"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('dark:bg-green-900/30');
expect(iconWrapper).toHaveClass('dark:text-green-400');
});
});
describe('Component Props Validation', () => {
it('should handle all required props', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Required Props"
description="All required props provided"
/>
);
expect(container.firstChild).toBeInTheDocument();
expect(screen.getByText('Required Props')).toBeInTheDocument();
expect(screen.getByText('All required props provided')).toBeInTheDocument();
});
it('should handle optional iconColor prop', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Optional Props"
description="Optional iconColor provided"
iconColor="purple"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-purple-100');
});
it('should render correctly with minimal props', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Min Props"
description="Minimal props"
/>
);
expect(container.firstChild).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle very long title text', () => {
const longTitle = 'This is a very long title that might wrap to multiple lines in the card';
render(
<FeatureCard
icon={Calendar}
title={longTitle}
description="Normal description"
/>
);
expect(screen.getByText(longTitle)).toBeInTheDocument();
});
it('should handle special characters in title', () => {
const specialTitle = 'Special <>&"\' Characters';
render(
<FeatureCard
icon={Calendar}
title={specialTitle}
description="Testing special chars"
/>
);
expect(screen.getByText(specialTitle)).toBeInTheDocument();
});
it('should handle special characters in description', () => {
const specialDescription = 'Description with <>&"\' special characters';
render(
<FeatureCard
icon={Calendar}
title="Special Chars"
description={specialDescription}
/>
);
expect(screen.getByText(specialDescription)).toBeInTheDocument();
});
it('should handle unicode characters', () => {
render(
<FeatureCard
icon={Calendar}
title="Unicode Test 你好 🎉"
description="Description with émojis and 中文"
/>
);
expect(screen.getByText("Unicode Test 你好 🎉")).toBeInTheDocument();
expect(screen.getByText("Description with émojis and 中文")).toBeInTheDocument();
});
});
});

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