diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1752198..c10de2f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -114,6 +114,7 @@ const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // const PageEditor = React.lazy(() => import('./pages/PageEditor')); // Import PageEditor const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import PublicPage const BookingFlow = React.lazy(() => import('./pages/BookingFlow')); // Import Booking Flow +const Locations = React.lazy(() => import('./pages/Locations')); // Import Locations management page // Settings pages const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout')); @@ -832,6 +833,16 @@ const AppContent: React.FC = () => { ) } /> + + ) : ( + + ) + } + /> => { + const response = await apiClient.post( + `/platform/businesses/${businessId}/change_plan/`, + { plan_code: planCode } + ); + return response.data; +}; + /** * Create a new business (platform admin only) */ @@ -329,3 +350,46 @@ export const acceptInvitation = async ( ); return response.data; }; + +// ============================================================================ +// Tenant Custom Tier +// ============================================================================ + +import { TenantCustomTier } from '../types'; + +/** + * Get a business's custom tier (if it exists) + */ +export const getCustomTier = async (businessId: number): Promise => { + try { + const response = await apiClient.get(`/platform/businesses/${businessId}/custom_tier/`); + return response.data; + } catch (error: any) { + if (error.response?.status === 404) { + return null; + } + throw error; + } +}; + +/** + * Update or create a custom tier for a business + */ +export const updateCustomTier = async ( + businessId: number, + features: Record, + notes?: string +): Promise => { + const response = await apiClient.put( + `/platform/businesses/${businessId}/custom_tier/`, + { features, notes } + ); + return response.data; +}; + +/** + * Delete a business's custom tier + */ +export const deleteCustomTier = async (businessId: number): Promise => { + await apiClient.delete(`/platform/businesses/${businessId}/custom_tier/`); +}; diff --git a/frontend/src/billing/components/FeaturePicker.tsx b/frontend/src/billing/components/FeaturePicker.tsx index da5aa8c..37b6d67 100644 --- a/frontend/src/billing/components/FeaturePicker.tsx +++ b/frontend/src/billing/components/FeaturePicker.tsx @@ -3,12 +3,11 @@ * * A searchable picker for selecting features to include in a plan or version. * Features are grouped by type (boolean capabilities vs integer limits). - * Non-canonical features (not in the catalog) are flagged with a warning. + * Features are loaded dynamically from the billing API. */ import React, { useState, useMemo } from 'react'; -import { Check, Sliders, Search, X, AlertTriangle } from 'lucide-react'; -import { isCanonicalFeature } from '../featureCatalog'; +import { Check, Sliders, Search, X } from 'lucide-react'; import type { Feature, PlanFeatureWrite } from '../../hooks/useBillingAdmin'; export interface FeaturePickerProps { @@ -151,7 +150,6 @@ export const FeaturePicker: React.FC = ({
{filteredBooleanFeatures.map((feature) => { const selected = isSelected(feature.code); - const isCanonical = isCanonicalFeature(feature.code); return (
} + > +
Original Content
+ + ); + + expect(screen.getByTestId('fallback')).toBeInTheDocument(); + expect(screen.getByText('Custom Fallback')).toBeInTheDocument(); + expect(screen.queryByText(/upgrade required/i)).not.toBeInTheDocument(); + }); + + it('should not render original children when locked without overlay', () => { + renderWithRouter( + +
Original Content
+
+ ); + + expect(screen.queryByTestId('original')).not.toBeInTheDocument(); + expect(screen.getByText(/webhooks.*upgrade required/i)).toBeInTheDocument(); + }); + + it('should render blurred children with overlay variant', () => { + renderWithRouter( + +
Blurred Content
+
+ ); + + const content = screen.getByTestId('blurred-content'); + expect(content).toBeInTheDocument(); + expect(content.parentElement).toHaveClass('blur-sm'); + }); + }); + + describe('Different Features', () => { + it('should work with different feature keys', () => { + const features: FeatureKey[] = [ + 'white_label', + 'custom_oauth', + 'can_create_plugins', + 'tasks', + ]; + + features.forEach((feature) => { + const { unmount } = renderWithRouter( + +
Content
+
+ ); + expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument(); + unmount(); + }); + }); + }); +}); + +describe('LockedButton', () => { + describe('Unlocked State', () => { + it('should render normal clickable button when not locked', () => { + const handleClick = vi.fn(); + renderWithRouter( + + Click Me + + ); + + const button = screen.getByRole('button', { name: /click me/i }); + expect(button).toBeInTheDocument(); + expect(button).not.toBeDisabled(); + expect(button).toHaveClass('custom-class'); + + fireEvent.click(button); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('should not show lock icon when unlocked', () => { + renderWithRouter( + + Submit + + ); + + const button = screen.getByRole('button', { name: /submit/i }); + expect(button.querySelector('svg')).not.toBeInTheDocument(); + }); + }); + + describe('Locked State', () => { + it('should render disabled button with lock icon when locked', () => { + renderWithRouter( + + Submit + + ); + + const button = screen.getByRole('button', { name: /submit/i }); + expect(button).toBeDisabled(); + expect(button).toHaveClass('opacity-50', 'cursor-not-allowed'); + }); + + it('should display lock icon when locked', () => { + renderWithRouter( + + Save + + ); + + const button = screen.getByRole('button'); + expect(button.textContent).toContain('Save'); + }); + + it('should show tooltip on hover when locked', () => { + const { container } = renderWithRouter( + + Create Plugin + + ); + + // Tooltip should exist in DOM + const tooltip = container.querySelector('.opacity-0'); + expect(tooltip).toBeInTheDocument(); + expect(tooltip?.textContent).toContain('Upgrade Required'); + }); + + it('should not trigger onClick when locked', () => { + const handleClick = vi.fn(); + renderWithRouter( + + Click Me + + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it('should apply custom className even when locked', () => { + renderWithRouter( + + Submit + + ); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('custom-btn'); + }); + + it('should display feature name in tooltip', () => { + const { container } = renderWithRouter( + + Send SMS + + ); + + const tooltip = container.querySelector('.whitespace-nowrap'); + expect(tooltip?.textContent).toContain('SMS Reminders'); + }); + }); + + describe('Different Features', () => { + it('should work with various feature keys', () => { + const features: FeatureKey[] = [ + 'export_data', + 'video_conferencing', + 'two_factor_auth', + 'masked_calling', + ]; + + features.forEach((feature) => { + const { unmount } = renderWithRouter( + + Action + + ); + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + unmount(); + }); + }); + }); + + describe('Accessibility', () => { + it('should have proper button role when unlocked', () => { + renderWithRouter( + + Save + + ); + + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + it('should have proper button role when locked', () => { + renderWithRouter( + + Submit + + ); + + expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument(); + }); + + it('should indicate disabled state for screen readers', () => { + renderWithRouter( + + Create + + ); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('disabled'); + }); + }); +}); diff --git a/frontend/src/components/marketing/DynamicPricingCards.tsx b/frontend/src/components/marketing/DynamicPricingCards.tsx new file mode 100644 index 0000000..8e020fd --- /dev/null +++ b/frontend/src/components/marketing/DynamicPricingCards.tsx @@ -0,0 +1,242 @@ +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Check, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { + usePublicPlans, + formatPrice, + PublicPlanVersion, +} from '../../hooks/usePublicPlans'; + +interface DynamicPricingCardsProps { + className?: string; +} + +const DynamicPricingCards: React.FC = ({ className = '' }) => { + const { t } = useTranslation(); + const { data: plans, isLoading, error } = usePublicPlans(); + const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'annual'>('monthly'); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !plans) { + return ( +
+ {t('marketing.pricing.loadError', 'Unable to load pricing. Please try again later.')} +
+ ); + } + + // Sort plans by display_order + const sortedPlans = [...plans].sort( + (a, b) => a.plan.display_order - b.plan.display_order + ); + + return ( +
+ {/* Billing Toggle */} +
+
+ + +
+
+ + {/* Plans Grid */} +
+ {sortedPlans.map((planVersion) => ( + + ))} +
+
+ ); +}; + +interface PlanCardProps { + planVersion: PublicPlanVersion; + billingPeriod: 'monthly' | 'annual'; +} + +const PlanCard: React.FC = ({ planVersion, billingPeriod }) => { + const { t } = useTranslation(); + const { plan, is_most_popular, show_price, marketing_features, trial_days } = planVersion; + + const price = + billingPeriod === 'annual' + ? planVersion.price_yearly_cents + : planVersion.price_monthly_cents; + + const isEnterprise = !show_price || plan.code === 'enterprise'; + const isFree = price === 0 && plan.code === 'free'; + + // Determine CTA + const ctaLink = isEnterprise ? '/contact' : `/signup?plan=${plan.code}`; + const ctaText = isEnterprise + ? t('marketing.pricing.contactSales', 'Contact Sales') + : isFree + ? t('marketing.pricing.getStartedFree', 'Get Started Free') + : t('marketing.pricing.startTrial', 'Start Free Trial'); + + if (is_most_popular) { + return ( +
+ {/* Most Popular Badge */} +
+ {t('marketing.pricing.mostPopular', 'Most Popular')} +
+ + {/* Header */} +
+

{plan.name}

+

{plan.description}

+
+ + {/* Price */} +
+ {isEnterprise ? ( + + {t('marketing.pricing.custom', 'Custom')} + + ) : ( + <> + + {formatPrice(price)} + + + {billingPeriod === 'annual' + ? t('marketing.pricing.perYear', '/year') + : t('marketing.pricing.perMonth', '/month')} + + + )} + {trial_days > 0 && !isFree && ( +
+ {t('marketing.pricing.trialDays', '{{days}}-day free trial', { + days: trial_days, + })} +
+ )} +
+ + {/* Features */} +
    + {marketing_features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+ + {/* CTA */} + + {ctaText} + +
+ ); + } + + return ( +
+ {/* Header */} +
+

+ {plan.name} +

+

+ {plan.description} +

+
+ + {/* Price */} +
+ {isEnterprise ? ( + + {t('marketing.pricing.custom', 'Custom')} + + ) : ( + <> + + {formatPrice(price)} + + + {billingPeriod === 'annual' + ? t('marketing.pricing.perYear', '/year') + : t('marketing.pricing.perMonth', '/month')} + + + )} + {trial_days > 0 && !isFree && ( +
+ {t('marketing.pricing.trialDays', '{{days}}-day free trial', { + days: trial_days, + })} +
+ )} + {isFree && ( +
+ {t('marketing.pricing.freeForever', 'Free forever')} +
+ )} +
+ + {/* Features */} +
    + {marketing_features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+ + {/* CTA */} + + {ctaText} + +
+ ); +}; + +export default DynamicPricingCards; diff --git a/frontend/src/components/marketing/FeatureComparisonTable.tsx b/frontend/src/components/marketing/FeatureComparisonTable.tsx new file mode 100644 index 0000000..dbccbff --- /dev/null +++ b/frontend/src/components/marketing/FeatureComparisonTable.tsx @@ -0,0 +1,251 @@ +import React from 'react'; +import { Check, X, Minus, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { + usePublicPlans, + PublicPlanVersion, + getPlanFeatureValue, + formatLimit, +} from '../../hooks/usePublicPlans'; + +// Feature categories for the comparison table +const FEATURE_CATEGORIES = [ + { + key: 'limits', + features: [ + { code: 'max_users', label: 'Team members' }, + { code: 'max_resources', label: 'Resources' }, + { code: 'max_locations', label: 'Locations' }, + { code: 'max_services', label: 'Services' }, + { code: 'max_customers', label: 'Customers' }, + { code: 'max_appointments_per_month', label: 'Appointments/month' }, + ], + }, + { + key: 'communication', + features: [ + { code: 'email_enabled', label: 'Email notifications' }, + { code: 'max_email_per_month', label: 'Emails/month' }, + { code: 'sms_enabled', label: 'SMS reminders' }, + { code: 'max_sms_per_month', label: 'SMS/month' }, + { code: 'masked_calling_enabled', label: 'Masked calling' }, + ], + }, + { + key: 'booking', + features: [ + { code: 'online_booking', label: 'Online booking' }, + { code: 'recurring_appointments', label: 'Recurring appointments' }, + { code: 'payment_processing', label: 'Accept payments' }, + { code: 'mobile_app_access', label: 'Mobile app' }, + ], + }, + { + key: 'integrations', + features: [ + { code: 'integrations_enabled', label: 'Third-party integrations' }, + { code: 'api_access', label: 'API access' }, + { code: 'max_api_calls_per_day', label: 'API calls/day' }, + ], + }, + { + key: 'branding', + features: [ + { code: 'custom_domain', label: 'Custom domain' }, + { code: 'custom_branding', label: 'Custom branding' }, + { code: 'remove_branding', label: 'Remove "Powered by"' }, + { code: 'white_label', label: 'White label' }, + ], + }, + { + key: 'enterprise', + features: [ + { code: 'multi_location', label: 'Multi-location management' }, + { code: 'team_permissions', label: 'Team permissions' }, + { code: 'audit_logs', label: 'Audit logs' }, + { code: 'advanced_reporting', label: 'Advanced analytics' }, + ], + }, + { + key: 'support', + features: [ + { code: 'priority_support', label: 'Priority support' }, + { code: 'dedicated_account_manager', label: 'Dedicated account manager' }, + { code: 'sla_guarantee', label: 'SLA guarantee' }, + ], + }, + { + key: 'storage', + features: [ + { code: 'max_storage_mb', label: 'File storage' }, + ], + }, +]; + +interface FeatureComparisonTableProps { + className?: string; +} + +const FeatureComparisonTable: React.FC = ({ + className = '', +}) => { + const { t } = useTranslation(); + const { data: plans, isLoading, error } = usePublicPlans(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !plans || plans.length === 0) { + return null; + } + + // Sort plans by display_order + const sortedPlans = [...plans].sort( + (a, b) => a.plan.display_order - b.plan.display_order + ); + + return ( +
+ + {/* Header */} + + + + {sortedPlans.map((planVersion) => ( + + ))} + + + + {FEATURE_CATEGORIES.map((category) => ( + + {/* Category Header */} + + + + {/* Features */} + {category.features.map((feature) => ( + + + {sortedPlans.map((planVersion) => ( + + ))} + + ))} + + ))} + +
+ {t('marketing.pricing.featureComparison.features', 'Features')} + + {planVersion.plan.name} +
+ {t( + `marketing.pricing.featureComparison.categories.${category.key}`, + category.key.charAt(0).toUpperCase() + category.key.slice(1) + )} +
+ {t( + `marketing.pricing.featureComparison.features.${feature.code}`, + feature.label + )} + + +
+
+ ); +}; + +interface FeatureValueProps { + planVersion: PublicPlanVersion; + featureCode: string; +} + +const FeatureValue: React.FC = ({ + planVersion, + featureCode, +}) => { + const value = getPlanFeatureValue(planVersion, featureCode); + + // Handle null/undefined - feature not set + if (value === null || value === undefined) { + return ( + + ); + } + + // Boolean feature + if (typeof value === 'boolean') { + return value ? ( + + ) : ( + + ); + } + + // Integer feature (limit) + if (typeof value === 'number') { + // Special handling for storage (convert MB to GB if > 1000) + if (featureCode === 'max_storage_mb') { + if (value === 0) { + return ( + + Unlimited + + ); + } + if (value >= 1000) { + return ( + + {(value / 1000).toFixed(0)} GB + + ); + } + return ( + + {value} MB + + ); + } + + // Regular limit display + return ( + + {formatLimit(value)} + + ); + } + + // Fallback + return ; +}; + +export default FeatureComparisonTable; diff --git a/frontend/src/components/platform/DynamicFeaturesEditor.tsx b/frontend/src/components/platform/DynamicFeaturesEditor.tsx new file mode 100644 index 0000000..c71a820 --- /dev/null +++ b/frontend/src/components/platform/DynamicFeaturesEditor.tsx @@ -0,0 +1,312 @@ +/** + * DynamicFeaturesEditor + * + * A dynamic component that loads features from the billing system API + * and renders them as toggles/inputs for editing business permissions. + * + * This is the DYNAMIC version that gets features from the billing catalog, + * which is the single source of truth. When you add a new feature to the + * billing system, it automatically appears here. + */ + +import React, { useMemo } from 'react'; +import { Key, AlertCircle } from 'lucide-react'; +import { useBillingFeatures, BillingFeature, FEATURE_CATEGORY_META } from '../../hooks/useBillingPlans'; + +export interface DynamicFeaturesEditorProps { + /** + * Current feature values mapped by tenant_field_name + * For booleans: { can_use_sms_reminders: true, can_api_access: false, ... } + * For integers: { max_users: 10, max_resources: 5, ... } + */ + values: Record; + + /** + * Callback when a feature value changes + * @param fieldName - The tenant_field_name of the feature + * @param value - The new value (boolean for toggles, number for limits) + */ + onChange: (fieldName: string, value: boolean | number | null) => void; + + /** + * Optional: Only show features in these categories + */ + categories?: BillingFeature['category'][]; + + /** + * Optional: Only show boolean or integer features + */ + featureType?: 'boolean' | 'integer'; + + /** + * Optional: Exclude features by code + */ + excludeCodes?: string[]; + + /** + * Show section header (default: true) + */ + showHeader?: boolean; + + /** + * Custom header title + */ + headerTitle?: string; + + /** + * Show descriptions under labels (default: false) + */ + showDescriptions?: boolean; + + /** + * Number of columns (default: 3) + */ + columns?: 2 | 3 | 4; +} + +const DynamicFeaturesEditor: React.FC = ({ + values, + onChange, + categories, + featureType, + excludeCodes = [], + showHeader = true, + headerTitle = 'Features & Permissions', + showDescriptions = false, + columns = 3, +}) => { + const { data: features, isLoading, error } = useBillingFeatures(); + + // Debug logging + console.log('[DynamicFeaturesEditor] Features:', features?.length, 'Loading:', isLoading, 'Error:', error); + + // Filter and group features + const groupedFeatures = useMemo(() => { + if (!features) { + console.log('[DynamicFeaturesEditor] No features data'); + return {}; + } + + // Filter features + const filtered = features.filter(f => { + if (excludeCodes.includes(f.code)) return false; + if (categories && !categories.includes(f.category)) return false; + if (featureType && f.feature_type !== featureType) return false; + if (!f.is_overridable) return false; // Skip non-overridable features + if (!f.tenant_field_name) return false; // Skip features without tenant field + return true; + }); + console.log('[DynamicFeaturesEditor] Filtered features:', filtered.length, 'featureType:', featureType); + + // Group by category + const groups: Record = {}; + for (const feature of filtered) { + if (!groups[feature.category]) { + groups[feature.category] = []; + } + groups[feature.category].push(feature); + } + + // Sort features within each category by display_order + for (const category of Object.keys(groups)) { + groups[category].sort((a, b) => a.display_order - b.display_order); + } + + return groups; + }, [features, categories, featureType, excludeCodes]); + + // Sort categories by their order + const sortedCategories = useMemo(() => { + return Object.keys(groupedFeatures).sort( + (a, b) => (FEATURE_CATEGORY_META[a as BillingFeature['category']]?.order ?? 99) - + (FEATURE_CATEGORY_META[b as BillingFeature['category']]?.order ?? 99) + ) as BillingFeature['category'][]; + }, [groupedFeatures]); + + // Check if a dependent feature should be disabled + const isDependencyDisabled = (feature: BillingFeature): boolean => { + if (!feature.depends_on_code) return false; + const parentFeature = features?.find(f => f.code === feature.depends_on_code); + if (!parentFeature) return false; + const parentValue = values[parentFeature.tenant_field_name]; + return !parentValue; + }; + + // Handle value change + const handleChange = (feature: BillingFeature, newValue: boolean | number | null) => { + onChange(feature.tenant_field_name, newValue); + + // If disabling a parent feature, also disable dependents + if (feature.feature_type === 'boolean' && !newValue) { + const dependents = features?.filter(f => f.depends_on_code === feature.code) ?? []; + for (const dep of dependents) { + if (values[dep.tenant_field_name]) { + onChange(dep.tenant_field_name, false); + } + } + } + }; + + const gridCols = { + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4', + }; + + if (isLoading) { + return ( +
+ {showHeader && ( +

+ + {headerTitle} +

+ )} +
+
+
+ {[1, 2, 3, 4, 5, 6].map(i => ( +
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+ {showHeader && ( +

+ + {headerTitle} +

+ )} +
+ + Failed to load features from billing system +
+
+ ); + } + + return ( +
+ {showHeader && ( + <> +

+ + {headerTitle} +

+

+ Control which features are available. Features are loaded from the billing system. +

+ + )} + + {sortedCategories.map(category => { + const categoryFeatures = groupedFeatures[category]; + if (!categoryFeatures || categoryFeatures.length === 0) return null; + + const categoryMeta = FEATURE_CATEGORY_META[category]; + + return ( +
+

+ {categoryMeta?.label || category} +

+
+ {categoryFeatures.map(feature => { + const isDisabled = isDependencyDisabled(feature); + const currentValue = values[feature.tenant_field_name]; + + if (feature.feature_type === 'boolean') { + const isChecked = currentValue === true; + + return ( + + ); + } + + // Integer feature (limit) + const intValue = typeof currentValue === 'number' ? currentValue : 0; + const isUnlimited = currentValue === null || currentValue === -1; + + return ( +
+ +
+ { + const val = parseInt(e.target.value); + handleChange(feature, val === -1 ? null : val); + }} + className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-1 focus:ring-indigo-500" + /> +
+ {showDescriptions && ( + + {feature.description} (-1 = unlimited) + + )} +
+ ); + })} +
+ + {/* Show dependency hint for plugins category */} + {category === 'plugins' && ( + (() => { + const pluginsFeature = categoryFeatures.find(f => f.code === 'can_use_plugins'); + if (pluginsFeature && !values[pluginsFeature.tenant_field_name]) { + return ( +

+ Enable "Use Plugins" to allow dependent features +

+ ); + } + return null; + })() + )} +
+ ); + })} +
+ ); +}; + +export default DynamicFeaturesEditor; diff --git a/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx b/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx index 3403290..182c437 100644 --- a/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx +++ b/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx @@ -31,6 +31,7 @@ import { CalendarDays, CalendarRange, Loader2, + MapPin, } from 'lucide-react'; import Portal from '../Portal'; import { @@ -40,8 +41,11 @@ import { Holiday, Resource, TimeBlockListItem, + Location, } from '../../types'; import { formatLocalDate } from '../../utils/dateUtils'; +import { LocationSelector, useShouldShowLocationSelector, useAutoSelectLocation } from '../LocationSelector'; +import { usePlanFeatures } from '../../hooks/usePlanFeatures'; // Preset block types const PRESETS = [ @@ -155,6 +159,7 @@ interface TimeBlockCreatorModalProps { editingBlock?: TimeBlockListItem | null; holidays: Holiday[]; resources: Resource[]; + locations?: Location[]; isResourceLevel?: boolean; /** Staff mode: hides level selector, locks to resource, pre-selects resource */ staffMode?: boolean; @@ -162,6 +167,9 @@ interface TimeBlockCreatorModalProps { staffResourceId?: string | number | null; } +// Block level types for the three-tier system +type BlockLevel = 'business' | 'location' | 'resource'; + type Step = 'preset' | 'details' | 'schedule' | 'review'; const TimeBlockCreatorModal: React.FC = ({ @@ -172,6 +180,7 @@ const TimeBlockCreatorModal: React.FC = ({ editingBlock, holidays, resources, + locations = [], isResourceLevel: initialIsResourceLevel = false, staffMode = false, staffResourceId = null, @@ -181,6 +190,18 @@ const TimeBlockCreatorModal: React.FC = ({ const [selectedPreset, setSelectedPreset] = useState(null); const [isResourceLevel, setIsResourceLevel] = useState(initialIsResourceLevel); + // Multi-location support + const { canUse } = usePlanFeatures(); + const hasMultiLocation = canUse('multi_location'); + const showLocationSelector = useShouldShowLocationSelector(); + const [blockLevel, setBlockLevel] = useState( + initialIsResourceLevel ? 'resource' : 'business' + ); + const [locationId, setLocationId] = useState(null); + + // Auto-select location when only one exists + useAutoSelectLocation(locationId, setLocationId); + // Form state const [title, setTitle] = useState(editingBlock?.title || ''); const [description, setDescription] = useState(editingBlock?.description || ''); @@ -233,7 +254,21 @@ const TimeBlockCreatorModal: React.FC = ({ setStartTime(editingBlock.start_time || '09:00'); setEndTime(editingBlock.end_time || '17:00'); setResourceId(editingBlock.resource || null); - setIsResourceLevel(!!editingBlock.resource); // Set level based on whether block has a resource + setLocationId(editingBlock.location ?? null); + // Determine block level based on existing data + if (editingBlock.is_business_wide) { + setBlockLevel('business'); + setIsResourceLevel(false); + } else if (editingBlock.location && !editingBlock.resource) { + setBlockLevel('location'); + setIsResourceLevel(false); + } else if (editingBlock.resource) { + setBlockLevel('resource'); + setIsResourceLevel(true); + } else { + setBlockLevel('business'); + setIsResourceLevel(false); + } // Parse dates if available if (editingBlock.start_date) { const startDate = new Date(editingBlock.start_date); @@ -288,8 +323,10 @@ const TimeBlockCreatorModal: React.FC = ({ setHolidayCodes([]); setRecurrenceStart(''); setRecurrenceEnd(''); + setLocationId(null); // In staff mode, always resource-level setIsResourceLevel(staffMode ? true : initialIsResourceLevel); + setBlockLevel(staffMode ? 'resource' : (initialIsResourceLevel ? 'resource' : 'business')); } } }, [isOpen, editingBlock, initialIsResourceLevel, staffMode, staffResourceId]); @@ -381,12 +418,37 @@ const TimeBlockCreatorModal: React.FC = ({ // In staff mode, always use the staff's resource ID const effectiveResourceId = staffMode ? staffResourceId : resourceId; + // Determine location and resource based on block level + let effectiveLocation: number | null = null; + let effectiveResource: string | number | null = null; + let isBusinessWide = false; + + switch (blockLevel) { + case 'business': + isBusinessWide = true; + effectiveLocation = null; + effectiveResource = null; + break; + case 'location': + isBusinessWide = false; + effectiveLocation = locationId; + effectiveResource = null; + break; + case 'resource': + isBusinessWide = false; + effectiveLocation = locationId; // Resource blocks can optionally have a location + effectiveResource = effectiveResourceId; + break; + } + const baseData: any = { description: description || undefined, block_type: blockType, recurrence_type: recurrenceType, all_day: allDay, - resource: isResourceLevel ? effectiveResourceId : null, + resource: effectiveResource, + location: effectiveLocation, + is_business_wide: isBusinessWide, }; if (!allDay) { @@ -441,6 +503,8 @@ const TimeBlockCreatorModal: React.FC = ({ if (!title.trim()) return false; // In staff mode, resource is auto-selected; otherwise check if selected if (isResourceLevel && !staffMode && !resourceId) return false; + // Location is required when blockLevel is 'location' + if (blockLevel === 'location' && !locationId) return false; return true; case 'schedule': if (recurrenceType === 'NONE' && selectedDates.length === 0) return false; @@ -577,48 +641,87 @@ const TimeBlockCreatorModal: React.FC = ({ -
+
+ {/* Business-wide option */} + + {/* Location-wide option - only show when multi-location is enabled */} + {showLocationSelector && ( + + )} + + {/* Resource-specific option */}
+ + {/* Location Selector - show when location-level is selected */} + {blockLevel === 'location' && showLocationSelector && ( +
+ +
+ )}
)} @@ -661,20 +776,32 @@ const TimeBlockCreatorModal: React.FC = ({ {/* Resource (if resource-level) - Hidden in staff mode since it's auto-selected */} {isResourceLevel && !staffMode && ( -
- - +
+
+ + +
+ + {/* Optional location for resource-level blocks when multi-location is enabled */} + {showLocationSelector && ( + + )}
)} @@ -1207,6 +1334,40 @@ const TimeBlockCreatorModal: React.FC = ({ )}
+ {/* Block Level - show when multi-location is enabled or not in staff mode */} + {!staffMode && ( +
+
Applies To
+
+ + {blockLevel === 'business' && } + {blockLevel === 'location' && } + {blockLevel === 'resource' && } + {blockLevel === 'business' && 'Business-wide'} + {blockLevel === 'location' && 'Specific Location'} + {blockLevel === 'resource' && 'Specific Resource'} + +
+
+ )} + + {/* Location - show when location is selected */} + {(blockLevel === 'location' || (blockLevel === 'resource' && locationId)) && locationId && ( +
+
Location
+
+ {locations.find(l => l.id === locationId)?.name || `Location ${locationId}`} +
+
+ )} + + {/* Resource - show for resource-level blocks */} {isResourceLevel && (resourceId || staffResourceId) && (
Resource
diff --git a/frontend/src/components/ui/__tests__/Alert.test.tsx b/frontend/src/components/ui/__tests__/Alert.test.tsx new file mode 100644 index 0000000..0efba53 --- /dev/null +++ b/frontend/src/components/ui/__tests__/Alert.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Alert, ErrorMessage, SuccessMessage, WarningMessage, InfoMessage } from '../Alert'; + +describe('Alert', () => { + it('renders message', () => { + render(); + expect(screen.getByText('Test message')).toBeInTheDocument(); + }); + + it('renders title when provided', () => { + render(); + expect(screen.getByText('Title')).toBeInTheDocument(); + }); + + it('renders message as ReactNode', () => { + render( + Custom content} + /> + ); + expect(screen.getByTestId('custom')).toBeInTheDocument(); + }); + + it('has alert role', () => { + render(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('renders error variant', () => { + render(); + expect(screen.getByRole('alert')).toHaveClass('bg-red-50'); + }); + + it('renders success variant', () => { + render(); + expect(screen.getByRole('alert')).toHaveClass('bg-green-50'); + }); + + it('renders warning variant', () => { + render(); + expect(screen.getByRole('alert')).toHaveClass('bg-amber-50'); + }); + + it('renders info variant', () => { + render(); + expect(screen.getByRole('alert')).toHaveClass('bg-blue-50'); + }); + + it('shows dismiss button when onDismiss is provided', () => { + const handleDismiss = vi.fn(); + render(); + expect(screen.getByLabelText('Dismiss')).toBeInTheDocument(); + }); + + it('calls onDismiss when dismiss button clicked', () => { + const handleDismiss = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText('Dismiss')); + expect(handleDismiss).toHaveBeenCalled(); + }); + + it('does not show dismiss button without onDismiss', () => { + render(); + expect(screen.queryByLabelText('Dismiss')).not.toBeInTheDocument(); + }); + + it('applies custom className', () => { + render(); + expect(screen.getByRole('alert')).toHaveClass('custom-class'); + }); + + it('applies compact style', () => { + render(); + expect(screen.getByRole('alert')).toHaveClass('p-2'); + }); + + it('applies regular padding without compact', () => { + render(); + expect(screen.getByRole('alert')).toHaveClass('p-3'); + }); +}); + +describe('Convenience components', () => { + it('ErrorMessage renders error variant', () => { + render(); + expect(screen.getByRole('alert')).toHaveClass('bg-red-50'); + }); + + it('SuccessMessage renders success variant', () => { + render(); + expect(screen.getByRole('alert')).toHaveClass('bg-green-50'); + }); + + it('WarningMessage renders warning variant', () => { + render(); + expect(screen.getByRole('alert')).toHaveClass('bg-amber-50'); + }); + + it('InfoMessage renders info variant', () => { + render(); + expect(screen.getByRole('alert')).toHaveClass('bg-blue-50'); + }); +}); diff --git a/frontend/src/components/ui/__tests__/Badge.test.tsx b/frontend/src/components/ui/__tests__/Badge.test.tsx new file mode 100644 index 0000000..9446afc --- /dev/null +++ b/frontend/src/components/ui/__tests__/Badge.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Badge } from '../Badge'; + +describe('Badge', () => { + it('renders children', () => { + render(Test); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + + it('renders default variant', () => { + render(Test); + expect(screen.getByText('Test').closest('span')).toHaveClass('bg-gray-100'); + }); + + it('renders primary variant', () => { + render(Test); + expect(screen.getByText('Test').closest('span')).toHaveClass('bg-brand-100'); + }); + + it('renders success variant', () => { + render(Test); + expect(screen.getByText('Test').closest('span')).toHaveClass('bg-green-100'); + }); + + it('renders warning variant', () => { + render(Test); + expect(screen.getByText('Test').closest('span')).toHaveClass('bg-amber-100'); + }); + + it('renders danger variant', () => { + render(Test); + expect(screen.getByText('Test').closest('span')).toHaveClass('bg-red-100'); + }); + + it('renders info variant', () => { + render(Test); + expect(screen.getByText('Test').closest('span')).toHaveClass('bg-blue-100'); + }); + + it('applies small size', () => { + render(Test); + expect(screen.getByText('Test').closest('span')).toHaveClass('text-xs'); + }); + + it('applies medium size', () => { + render(Test); + expect(screen.getByText('Test').closest('span')).toHaveClass('text-xs'); + }); + + it('applies large size', () => { + render(Test); + expect(screen.getByText('Test').closest('span')).toHaveClass('text-sm'); + }); + + it('applies pill style', () => { + render(Test); + expect(screen.getByText('Test').closest('span')).toHaveClass('rounded-full'); + }); + + it('applies rounded style by default', () => { + render(Test); + expect(screen.getByText('Test').closest('span')).toHaveClass('rounded'); + }); + + it('renders dot indicator', () => { + const { container } = render(Test); + const dot = container.querySelector('.rounded-full'); + expect(dot).toBeInTheDocument(); + }); + + it('applies custom className', () => { + render(Test); + expect(screen.getByText('Test').closest('span')).toHaveClass('custom-class'); + }); +}); diff --git a/frontend/src/components/ui/__tests__/Button.test.tsx b/frontend/src/components/ui/__tests__/Button.test.tsx new file mode 100644 index 0000000..c61ccfc --- /dev/null +++ b/frontend/src/components/ui/__tests__/Button.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Button, SubmitButton } from '../Button'; + +describe('Button', () => { + it('renders children', () => { + render(); + expect(screen.getByText('Click me')).toBeInTheDocument(); + }); + + it('handles click events', () => { + const handleClick = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalled(); + }); + + it('is disabled when disabled prop is true', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('is disabled when loading', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('shows loading spinner when loading', () => { + render(); + const spinner = document.querySelector('.animate-spin'); + expect(spinner).toBeInTheDocument(); + }); + + it('shows loading text when loading', () => { + render(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('applies primary variant by default', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('bg-brand-600'); + }); + + it('applies secondary variant', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('bg-gray-600'); + }); + + it('applies danger variant', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('bg-red-600'); + }); + + it('applies success variant', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('bg-green-600'); + }); + + it('applies warning variant', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('bg-amber-600'); + }); + + it('applies outline variant', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('bg-transparent'); + }); + + it('applies ghost variant', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('bg-transparent'); + }); + + it('applies size classes', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('px-3'); + }); + + it('applies full width', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('w-full'); + }); + + it('renders left icon', () => { + render(); + expect(screen.getByTestId('left-icon')).toBeInTheDocument(); + }); + + it('renders right icon', () => { + render(); + expect(screen.getByTestId('right-icon')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('custom-class'); + }); +}); + +describe('SubmitButton', () => { + it('renders submit text by default', () => { + render(); + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('has type submit', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('type', 'submit'); + }); + + it('renders custom submit text', () => { + render(); + expect(screen.getByText('Submit')).toBeInTheDocument(); + }); + + it('renders children over submitText', () => { + render(Custom); + expect(screen.getByText('Custom')).toBeInTheDocument(); + }); + + it('shows loading text when loading', () => { + render(); + expect(screen.getByText('Saving...')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/__tests__/Card.test.tsx b/frontend/src/components/ui/__tests__/Card.test.tsx new file mode 100644 index 0000000..70c2dc7 --- /dev/null +++ b/frontend/src/components/ui/__tests__/Card.test.tsx @@ -0,0 +1,165 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Card, CardHeader, CardBody, CardFooter } from '../Card'; + +describe('Card', () => { + it('renders children', () => { + render(Card content); + expect(screen.getByText('Card content')).toBeInTheDocument(); + }); + + it('applies base card styling', () => { + const { container } = render(Content); + expect(container.firstChild).toHaveClass('bg-white', 'rounded-lg', 'shadow-sm'); + }); + + it('applies bordered style by default', () => { + const { container } = render(Content); + expect(container.firstChild).toHaveClass('border'); + }); + + it('can remove border', () => { + const { container } = render(Content); + expect(container.firstChild).not.toHaveClass('border'); + }); + + it('applies medium padding by default', () => { + const { container } = render(Content); + expect(container.firstChild).toHaveClass('p-4'); + }); + + it('applies no padding', () => { + const { container } = render(Content); + expect(container.firstChild).not.toHaveClass('p-3', 'p-4', 'p-6'); + }); + + it('applies small padding', () => { + const { container } = render(Content); + expect(container.firstChild).toHaveClass('p-3'); + }); + + it('applies large padding', () => { + const { container } = render(Content); + expect(container.firstChild).toHaveClass('p-6'); + }); + + it('applies hoverable styling when hoverable', () => { + const { container } = render(Content); + expect(container.firstChild).toHaveClass('hover:shadow-md', 'cursor-pointer'); + }); + + it('is not hoverable by default', () => { + const { container } = render(Content); + expect(container.firstChild).not.toHaveClass('cursor-pointer'); + }); + + it('handles click events', () => { + const handleClick = vi.fn(); + render(Content); + fireEvent.click(screen.getByText('Content')); + expect(handleClick).toHaveBeenCalled(); + }); + + it('has button role when clickable', () => { + render( {}}>Content); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('has tabIndex when clickable', () => { + render( {}}>Content); + expect(screen.getByRole('button')).toHaveAttribute('tabIndex', '0'); + }); + + it('does not have button role when not clickable', () => { + render(Content); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render(Content); + expect(container.firstChild).toHaveClass('custom-class'); + }); +}); + +describe('CardHeader', () => { + it('renders children', () => { + render(Header content); + expect(screen.getByText('Header content')).toBeInTheDocument(); + }); + + it('applies header styling', () => { + const { container } = render(Header); + expect(container.firstChild).toHaveClass('flex', 'items-center', 'justify-between'); + }); + + it('applies border bottom', () => { + const { container } = render(Header); + expect(container.firstChild).toHaveClass('border-b'); + }); + + it('renders actions when provided', () => { + render(Action}>Header); + expect(screen.getByText('Action')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render(Header); + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('applies font styling to header text', () => { + const { container } = render(Header); + const headerText = container.querySelector('.font-semibold'); + expect(headerText).toBeInTheDocument(); + }); +}); + +describe('CardBody', () => { + it('renders children', () => { + render(Body content); + expect(screen.getByText('Body content')).toBeInTheDocument(); + }); + + it('applies body styling', () => { + const { container } = render(Body); + expect(container.firstChild).toHaveClass('py-4'); + }); + + it('applies custom className', () => { + const { container } = render(Body); + expect(container.firstChild).toHaveClass('custom-class'); + }); +}); + +describe('CardFooter', () => { + it('renders children', () => { + render(Footer content); + expect(screen.getByText('Footer content')).toBeInTheDocument(); + }); + + it('applies footer styling', () => { + const { container } = render(Footer); + expect(container.firstChild).toHaveClass('pt-4', 'border-t'); + }); + + it('applies custom className', () => { + const { container } = render(Footer); + expect(container.firstChild).toHaveClass('custom-class'); + }); +}); + +describe('Card composition', () => { + it('renders complete card with all parts', () => { + render( + + Edit}>Title + Main content here + Footer content + + ); + expect(screen.getByText('Title')).toBeInTheDocument(); + expect(screen.getByText('Edit')).toBeInTheDocument(); + expect(screen.getByText('Main content here')).toBeInTheDocument(); + expect(screen.getByText('Footer content')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/__tests__/CurrencyInput.test.tsx b/frontend/src/components/ui/__tests__/CurrencyInput.test.tsx new file mode 100644 index 0000000..23e2388 --- /dev/null +++ b/frontend/src/components/ui/__tests__/CurrencyInput.test.tsx @@ -0,0 +1,310 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import CurrencyInput from '../CurrencyInput'; + +describe('CurrencyInput', () => { + const defaultProps = { + value: 0, + onChange: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Rendering', () => { + it('renders an input element', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); + }); + + it('renders with default placeholder', () => { + render(); + const input = screen.getByPlaceholderText('$0.00'); + expect(input).toBeInTheDocument(); + }); + + it('renders with custom placeholder', () => { + render(); + const input = screen.getByPlaceholderText('Enter amount'); + expect(input).toBeInTheDocument(); + }); + + it('applies custom className', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveClass('custom-class'); + }); + + it('displays formatted value for non-zero cents', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('$12.34'); + }); + + it('displays empty for zero value', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue(''); + }); + + it('can be disabled', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toBeDisabled(); + }); + + it('can be required', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toBeRequired(); + }); + }); + + describe('Value Formatting', () => { + it('formats 5 cents as $0.05', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('$0.05'); + }); + + it('formats 50 cents as $0.50', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('$0.50'); + }); + + it('formats 500 cents as $5.00', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('$5.00'); + }); + + it('formats 1234 cents as $12.34', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('$12.34'); + }); + + it('formats large amounts correctly', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('$9999.99'); + }); + }); + + describe('User Input', () => { + it('calls onChange with cents value when digits entered', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: '1234' } }); + expect(onChange).toHaveBeenCalledWith(1234); + }); + + it('extracts only digits from input', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: '$12.34' } }); + expect(onChange).toHaveBeenCalledWith(1234); + }); + + it('handles empty input', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: '' } }); + expect(onChange).toHaveBeenCalledWith(0); + }); + + it('ignores non-digit characters', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: 'abc123xyz456' } }); + expect(onChange).toHaveBeenCalledWith(123456); + }); + }); + + describe('Min/Max Constraints', () => { + it('enforces max value on input', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: '5000' } }); + expect(onChange).toHaveBeenCalledWith(1000); + }); + + it('enforces min value on blur', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + fireEvent.blur(input); + expect(onChange).toHaveBeenCalledWith(100); + }); + + it('does not enforce min on blur when value is zero', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + fireEvent.blur(input); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe('Focus Behavior', () => { + it('selects all text on focus', async () => { + vi.useFakeTimers(); + render(); + const input = screen.getByRole('textbox') as HTMLInputElement; + + const selectSpy = vi.spyOn(input, 'select'); + fireEvent.focus(input); + + vi.runAllTimers(); + expect(selectSpy).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + }); + + describe('Paste Handling', () => { + it('handles paste with digits', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + const pasteEvent = { + preventDefault: vi.fn(), + clipboardData: { + getData: () => '1234', + }, + }; + + fireEvent.paste(input, pasteEvent); + expect(onChange).toHaveBeenCalledWith(1234); + }); + + it('extracts digits from pasted text', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + const pasteEvent = { + preventDefault: vi.fn(), + clipboardData: { + getData: () => '$12.34', + }, + }; + + fireEvent.paste(input, pasteEvent); + expect(onChange).toHaveBeenCalledWith(1234); + }); + + it('enforces max on paste', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + const pasteEvent = { + preventDefault: vi.fn(), + clipboardData: { + getData: () => '1000', + }, + }; + + fireEvent.paste(input, pasteEvent); + expect(onChange).toHaveBeenCalledWith(500); + }); + + it('ignores empty paste', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + const pasteEvent = { + preventDefault: vi.fn(), + clipboardData: { + getData: () => '', + }, + }; + + fireEvent.paste(input, pasteEvent); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe('Keyboard Handling', () => { + it('allows digit keys (can type digits)', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + // Type a digit + fireEvent.change(input, { target: { value: '5' } }); + expect(onChange).toHaveBeenCalledWith(5); + }); + + it('handles navigation keys (component accepts them)', () => { + render(); + const input = screen.getByRole('textbox'); + + const navigationKeys = ['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight']; + + navigationKeys.forEach((key) => { + // These should not throw or cause issues + fireEvent.keyDown(input, { key }); + }); + + // If we got here without errors, navigation keys are handled + expect(input).toBeInTheDocument(); + }); + + it('allows Ctrl+A for select all', () => { + render(); + const input = screen.getByRole('textbox'); + + // Ctrl+A should not cause issues + fireEvent.keyDown(input, { key: 'a', ctrlKey: true }); + expect(input).toBeInTheDocument(); + }); + + it('allows Cmd+C for copy', () => { + render(); + const input = screen.getByRole('textbox'); + + // Cmd+C should not cause issues + fireEvent.keyDown(input, { key: 'c', metaKey: true }); + expect(input).toBeInTheDocument(); + }); + }); + + describe('Input Attributes', () => { + it('has numeric input mode', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('inputMode', 'numeric'); + }); + + it('disables autocomplete', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('autoComplete', 'off'); + }); + + it('disables spell check', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('spellCheck', 'false'); + }); + }); +}); diff --git a/frontend/src/components/ui/__tests__/EmptyState.test.tsx b/frontend/src/components/ui/__tests__/EmptyState.test.tsx new file mode 100644 index 0000000..2ad4ade --- /dev/null +++ b/frontend/src/components/ui/__tests__/EmptyState.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { EmptyState } from '../EmptyState'; + +describe('EmptyState', () => { + it('renders title', () => { + render(); + expect(screen.getByText('No items found')).toBeInTheDocument(); + }); + + it('renders description when provided', () => { + render(); + expect(screen.getByText('Try adding some items')).toBeInTheDocument(); + }); + + it('does not render description when not provided', () => { + const { container } = render(); + const descriptions = container.querySelectorAll('p'); + expect(descriptions.length).toBe(0); + }); + + it('renders default icon when not provided', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('renders custom icon when provided', () => { + render(📭} />); + expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); + }); + + it('renders action when provided', () => { + render(Add item} />); + expect(screen.getByText('Add item')).toBeInTheDocument(); + }); + + it('does not render action when not provided', () => { + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('applies center text alignment', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('text-center'); + }); + + it('applies padding', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('py-12', 'px-4'); + }); + + it('applies custom className', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('renders title with proper styling', () => { + render(); + const title = screen.getByText('No items'); + expect(title).toHaveClass('text-lg', 'font-medium'); + }); + + it('renders description with proper styling', () => { + render(); + const description = screen.getByText('Add some items'); + expect(description).toHaveClass('text-sm', 'text-gray-500'); + }); + + it('centers the icon', () => { + const { container } = render(); + const iconContainer = container.querySelector('.flex.justify-center'); + expect(iconContainer).toBeInTheDocument(); + }); + + it('constrains description width', () => { + render(); + const description = screen.getByText('Long description text'); + expect(description).toHaveClass('max-w-sm', 'mx-auto'); + }); +}); diff --git a/frontend/src/components/ui/__tests__/FormCurrencyInput.test.tsx b/frontend/src/components/ui/__tests__/FormCurrencyInput.test.tsx new file mode 100644 index 0000000..7af9e04 --- /dev/null +++ b/frontend/src/components/ui/__tests__/FormCurrencyInput.test.tsx @@ -0,0 +1,241 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import FormCurrencyInput from '../FormCurrencyInput'; + +describe('FormCurrencyInput', () => { + const defaultProps = { + value: 0, + onChange: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Label Rendering', () => { + it('renders without label when not provided', () => { + render(); + expect(screen.queryByRole('label')).not.toBeInTheDocument(); + }); + + it('renders label when provided', () => { + render(); + expect(screen.getByText('Price')).toBeInTheDocument(); + }); + + it('shows required indicator when required', () => { + render(); + expect(screen.getByText('*')).toBeInTheDocument(); + }); + + it('does not show required indicator when not required', () => { + render(); + expect(screen.queryByText('*')).not.toBeInTheDocument(); + }); + }); + + describe('Error Handling', () => { + it('does not show error when not provided', () => { + render(); + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + it('shows error message when provided', () => { + render(); + expect(screen.getByText('Price is required')).toBeInTheDocument(); + }); + + it('applies error styling to input', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveClass('border-red-500'); + }); + + it('error has correct text color', () => { + render(); + const error = screen.getByText('Price is required'); + expect(error).toHaveClass('text-red-600'); + }); + }); + + describe('Hint Rendering', () => { + it('does not show hint when not provided', () => { + render(); + expect(screen.queryByText(/hint/i)).not.toBeInTheDocument(); + }); + + it('shows hint when provided', () => { + render(); + expect(screen.getByText('Enter price in dollars')).toBeInTheDocument(); + }); + + it('hides hint when error is shown', () => { + render( + + ); + expect(screen.queryByText('Enter price')).not.toBeInTheDocument(); + expect(screen.getByText('Price required')).toBeInTheDocument(); + }); + + it('hint has correct text color', () => { + render(); + const hint = screen.getByText('Helpful text'); + expect(hint).toHaveClass('text-gray-500'); + }); + }); + + describe('Input Behavior', () => { + it('renders input with default placeholder', () => { + render(); + const input = screen.getByPlaceholderText('$0.00'); + expect(input).toBeInTheDocument(); + }); + + it('renders input with custom placeholder', () => { + render(); + const input = screen.getByPlaceholderText('Enter amount'); + expect(input).toBeInTheDocument(); + }); + + it('displays formatted value', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('$10.00'); + }); + + it('calls onChange when value changes', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: '500' } }); + expect(onChange).toHaveBeenCalledWith(500); + }); + + it('can be disabled', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toBeDisabled(); + }); + + it('applies disabled styling', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveClass('cursor-not-allowed'); + }); + }); + + describe('Min/Max Props', () => { + it('passes min prop to CurrencyInput', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + fireEvent.blur(input); + expect(onChange).toHaveBeenCalledWith(100); + }); + + it('passes max prop to CurrencyInput', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: '5000' } }); + expect(onChange).toHaveBeenCalledWith(1000); + }); + }); + + describe('Styling', () => { + it('applies containerClassName to wrapper', () => { + const { container } = render( + + ); + expect(container.firstChild).toHaveClass('my-container'); + }); + + it('applies className to input', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveClass('my-input'); + }); + + it('applies base input classes', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveClass('w-full', 'px-3', 'py-2', 'border', 'rounded-lg'); + }); + + it('applies normal border when no error', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveClass('border-gray-300'); + }); + + it('applies error border when error provided', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveClass('border-red-500'); + expect(input).not.toHaveClass('border-gray-300'); + }); + }); + + describe('Accessibility', () => { + it('associates label with input', () => { + render(); + const label = screen.getByText('Price'); + expect(label.tagName).toBe('LABEL'); + }); + + it('marks input as required when required prop is true', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toBeRequired(); + }); + }); + + describe('Integration', () => { + it('full form flow works correctly', () => { + const onChange = vi.fn(); + render( + + ); + + // Check label and hint render + expect(screen.getByText('Service Price')).toBeInTheDocument(); + expect(screen.getByText('Enter the price for this service')).toBeInTheDocument(); + expect(screen.getByText('*')).toBeInTheDocument(); + + // Enter a value + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: '2500' } }); + expect(onChange).toHaveBeenCalledWith(2500); + }); + + it('shows error state correctly', () => { + render( + + ); + + expect(screen.getByText('Price must be greater than $1.00')).toBeInTheDocument(); + const input = screen.getByRole('textbox'); + expect(input).toHaveClass('border-red-500'); + }); + }); +}); diff --git a/frontend/src/components/ui/__tests__/FormInput.test.tsx b/frontend/src/components/ui/__tests__/FormInput.test.tsx new file mode 100644 index 0000000..f15763d --- /dev/null +++ b/frontend/src/components/ui/__tests__/FormInput.test.tsx @@ -0,0 +1,136 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { FormInput } from '../FormInput'; + +describe('FormInput', () => { + it('renders input element', () => { + render(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('renders label when provided', () => { + render(); + expect(screen.getByText('Username')).toBeInTheDocument(); + }); + + it('shows required indicator when required', () => { + render(); + expect(screen.getByText('*')).toBeInTheDocument(); + }); + + it('displays error message', () => { + render(); + expect(screen.getByText('This field is required')).toBeInTheDocument(); + }); + + it('displays hint when provided', () => { + render(); + expect(screen.getByText('Enter your username')).toBeInTheDocument(); + }); + + it('hides hint when error is shown', () => { + render(); + expect(screen.queryByText('Enter your username')).not.toBeInTheDocument(); + expect(screen.getByText('Required')).toBeInTheDocument(); + }); + + it('applies error styling when error present', () => { + render(); + expect(screen.getByRole('textbox')).toHaveClass('border-red-500'); + }); + + it('applies disabled styling', () => { + render(); + expect(screen.getByRole('textbox')).toBeDisabled(); + expect(screen.getByRole('textbox')).toHaveClass('cursor-not-allowed'); + }); + + it('applies small size classes', () => { + render(); + expect(screen.getByRole('textbox')).toHaveClass('py-1'); + }); + + it('applies medium size classes by default', () => { + render(); + expect(screen.getByRole('textbox')).toHaveClass('py-2'); + }); + + it('applies large size classes', () => { + render(); + expect(screen.getByRole('textbox')).toHaveClass('py-3'); + }); + + it('applies full width by default', () => { + render(); + expect(screen.getByRole('textbox')).toHaveClass('w-full'); + }); + + it('can disable full width', () => { + render(); + expect(screen.getByRole('textbox')).not.toHaveClass('w-full'); + }); + + it('renders left icon', () => { + render(L} />); + expect(screen.getByTestId('left-icon')).toBeInTheDocument(); + }); + + it('renders right icon', () => { + render(R} />); + expect(screen.getByTestId('right-icon')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + render(); + expect(screen.getByRole('textbox')).toHaveClass('custom-class'); + }); + + it('applies custom containerClassName', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('container-class'); + }); + + it('handles value changes', () => { + const handleChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test' } }); + expect(handleChange).toHaveBeenCalled(); + }); + + it('uses provided id', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('id', 'custom-id'); + }); + + it('uses name as id fallback', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('id', 'username'); + }); + + it('generates random id when none provided', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('id'); + }); + + it('links label to input', () => { + render(); + const input = screen.getByRole('textbox'); + const label = screen.getByText('My Label'); + expect(label).toHaveAttribute('for', 'my-input'); + expect(input).toHaveAttribute('id', 'my-input'); + }); + + it('forwards ref to input element', () => { + const ref = { current: null as HTMLInputElement | null }; + render(); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + }); + + it('passes through other input props', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('placeholder', 'Enter text'); + expect(input).toHaveAttribute('type', 'email'); + expect(input).toHaveAttribute('maxLength', '50'); + }); +}); diff --git a/frontend/src/components/ui/__tests__/FormSelect.test.tsx b/frontend/src/components/ui/__tests__/FormSelect.test.tsx new file mode 100644 index 0000000..074d753 --- /dev/null +++ b/frontend/src/components/ui/__tests__/FormSelect.test.tsx @@ -0,0 +1,149 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { FormSelect } from '../FormSelect'; + +const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3', disabled: true }, +]; + +describe('FormSelect', () => { + it('renders select element', () => { + render(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('renders options', () => { + render(); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + expect(screen.getByText('Option 3')).toBeInTheDocument(); + }); + + it('renders label when provided', () => { + render(); + expect(screen.getByText('Select Option')).toBeInTheDocument(); + }); + + it('shows required indicator when required', () => { + render(); + expect(screen.getByText('*')).toBeInTheDocument(); + }); + + it('displays placeholder option', () => { + render(); + expect(screen.getByText('Choose one')).toBeInTheDocument(); + }); + + it('disables placeholder option', () => { + render(); + const placeholder = screen.getByText('Choose one'); + expect(placeholder).toHaveAttribute('disabled'); + }); + + it('displays error message', () => { + render(); + expect(screen.getByText('Selection required')).toBeInTheDocument(); + }); + + it('displays hint when provided', () => { + render(); + expect(screen.getByText('Select your preference')).toBeInTheDocument(); + }); + + it('hides hint when error is shown', () => { + render(); + expect(screen.queryByText('Select option')).not.toBeInTheDocument(); + expect(screen.getByText('Required')).toBeInTheDocument(); + }); + + it('applies error styling when error present', () => { + render(); + expect(screen.getByRole('combobox')).toHaveClass('border-red-500'); + }); + + it('applies disabled styling', () => { + render(); + expect(screen.getByRole('combobox')).toBeDisabled(); + expect(screen.getByRole('combobox')).toHaveClass('cursor-not-allowed'); + }); + + it('disables individual options', () => { + render(); + const option3 = screen.getByText('Option 3'); + expect(option3).toHaveAttribute('disabled'); + }); + + it('applies small size classes', () => { + render(); + expect(screen.getByRole('combobox')).toHaveClass('py-1'); + }); + + it('applies medium size classes by default', () => { + render(); + expect(screen.getByRole('combobox')).toHaveClass('py-2'); + }); + + it('applies large size classes', () => { + render(); + expect(screen.getByRole('combobox')).toHaveClass('py-3'); + }); + + it('applies full width by default', () => { + render(); + expect(screen.getByRole('combobox')).toHaveClass('w-full'); + }); + + it('can disable full width', () => { + render(); + expect(screen.getByRole('combobox')).not.toHaveClass('w-full'); + }); + + it('applies custom className', () => { + render(); + expect(screen.getByRole('combobox')).toHaveClass('custom-class'); + }); + + it('applies custom containerClassName', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('container-class'); + }); + + it('handles value changes', () => { + const handleChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'option2' } }); + expect(handleChange).toHaveBeenCalled(); + }); + + it('uses provided id', () => { + render(); + expect(screen.getByRole('combobox')).toHaveAttribute('id', 'custom-id'); + }); + + it('uses name as id fallback', () => { + render(); + expect(screen.getByRole('combobox')).toHaveAttribute('id', 'category'); + }); + + it('links label to select', () => { + render(); + const select = screen.getByRole('combobox'); + const label = screen.getByText('My Label'); + expect(label).toHaveAttribute('for', 'my-select'); + expect(select).toHaveAttribute('id', 'my-select'); + }); + + it('forwards ref to select element', () => { + const ref = { current: null as HTMLSelectElement | null }; + render(); + expect(ref.current).toBeInstanceOf(HTMLSelectElement); + }); + + it('renders dropdown arrow icon', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/__tests__/FormTextarea.test.tsx b/frontend/src/components/ui/__tests__/FormTextarea.test.tsx new file mode 100644 index 0000000..0ed87f8 --- /dev/null +++ b/frontend/src/components/ui/__tests__/FormTextarea.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { FormTextarea } from '../FormTextarea'; + +describe('FormTextarea', () => { + it('renders textarea element', () => { + render(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('renders label when provided', () => { + render(); + expect(screen.getByText('Description')).toBeInTheDocument(); + }); + + it('shows required indicator when required', () => { + render(); + expect(screen.getByText('*')).toBeInTheDocument(); + }); + + it('displays error message', () => { + render(); + expect(screen.getByText('This field is required')).toBeInTheDocument(); + }); + + it('displays hint when provided', () => { + render(); + expect(screen.getByText('Max 500 characters')).toBeInTheDocument(); + }); + + it('hides hint when error is shown', () => { + render(); + expect(screen.queryByText('Enter description')).not.toBeInTheDocument(); + expect(screen.getByText('Required')).toBeInTheDocument(); + }); + + it('applies error styling when error present', () => { + render(); + expect(screen.getByRole('textbox')).toHaveClass('border-red-500'); + }); + + it('applies disabled styling', () => { + render(); + expect(screen.getByRole('textbox')).toBeDisabled(); + expect(screen.getByRole('textbox')).toHaveClass('cursor-not-allowed'); + }); + + it('applies full width by default', () => { + render(); + expect(screen.getByRole('textbox')).toHaveClass('w-full'); + }); + + it('can disable full width', () => { + render(); + expect(screen.getByRole('textbox')).not.toHaveClass('w-full'); + }); + + it('applies custom className', () => { + render(); + expect(screen.getByRole('textbox')).toHaveClass('custom-class'); + }); + + it('applies custom containerClassName', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('container-class'); + }); + + it('handles value changes', () => { + const handleChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test content' } }); + expect(handleChange).toHaveBeenCalled(); + }); + + it('uses provided id', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('id', 'custom-id'); + }); + + it('uses name as id fallback', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('id', 'description'); + }); + + it('links label to textarea', () => { + render(); + const textarea = screen.getByRole('textbox'); + const label = screen.getByText('My Label'); + expect(label).toHaveAttribute('for', 'my-textarea'); + expect(textarea).toHaveAttribute('id', 'my-textarea'); + }); + + it('forwards ref to textarea element', () => { + const ref = { current: null as HTMLTextAreaElement | null }; + render(); + expect(ref.current).toBeInstanceOf(HTMLTextAreaElement); + }); + + it('shows character count when enabled', () => { + render(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('shows character count with max chars', () => { + render(); + expect(screen.getByText('5/100')).toBeInTheDocument(); + }); + + it('applies warning style when over max chars', () => { + render(); + expect(screen.getByText('11/5')).toHaveClass('text-red-500'); + }); + + it('passes through other textarea props', () => { + render(); + const textarea = screen.getByRole('textbox'); + expect(textarea).toHaveAttribute('placeholder', 'Enter text'); + expect(textarea).toHaveAttribute('rows', '5'); + }); +}); diff --git a/frontend/src/components/ui/__tests__/LoadingSpinner.test.tsx b/frontend/src/components/ui/__tests__/LoadingSpinner.test.tsx new file mode 100644 index 0000000..d4c9fcc --- /dev/null +++ b/frontend/src/components/ui/__tests__/LoadingSpinner.test.tsx @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { LoadingSpinner, PageLoading, InlineLoading } from '../LoadingSpinner'; + +describe('LoadingSpinner', () => { + it('renders spinner element', () => { + const { container } = render(); + expect(container.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + it('applies default size (md)', () => { + const { container } = render(); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toHaveClass('h-6', 'w-6'); + }); + + it('applies xs size', () => { + const { container } = render(); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toHaveClass('h-3', 'w-3'); + }); + + it('applies sm size', () => { + const { container } = render(); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toHaveClass('h-4', 'w-4'); + }); + + it('applies lg size', () => { + const { container } = render(); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toHaveClass('h-8', 'w-8'); + }); + + it('applies xl size', () => { + const { container } = render(); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toHaveClass('h-12', 'w-12'); + }); + + it('applies default color', () => { + const { container } = render(); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toHaveClass('text-gray-500'); + }); + + it('applies white color', () => { + const { container } = render(); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toHaveClass('text-white'); + }); + + it('applies brand color', () => { + const { container } = render(); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toHaveClass('text-brand-600'); + }); + + it('applies blue color', () => { + const { container } = render(); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toHaveClass('text-blue-600'); + }); + + it('renders label when provided', () => { + render(); + expect(screen.getByText('Loading data...')).toBeInTheDocument(); + }); + + it('does not render label when not provided', () => { + const { container } = render(); + const spans = container.querySelectorAll('span'); + expect(spans.length).toBe(0); + }); + + it('centers spinner when centered prop is true', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('flex', 'items-center', 'justify-center'); + }); + + it('does not center spinner by default', () => { + const { container } = render(); + expect(container.firstChild).not.toHaveClass('py-12'); + }); + + it('applies custom className', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('custom-class'); + }); +}); + +describe('PageLoading', () => { + it('renders with default loading text', () => { + render(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('renders with custom label', () => { + render(); + expect(screen.getByText('Fetching data...')).toBeInTheDocument(); + }); + + it('renders large spinner', () => { + const { container } = render(); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toHaveClass('h-8', 'w-8'); + }); + + it('renders with brand color', () => { + const { container } = render(); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toHaveClass('text-brand-600'); + }); + + it('is centered in container', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('flex', 'items-center', 'justify-center'); + }); +}); + +describe('InlineLoading', () => { + it('renders spinner', () => { + const { container } = render(); + expect(container.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + it('renders label when provided', () => { + render(); + expect(screen.getByText('Saving...')).toBeInTheDocument(); + }); + + it('does not render label when not provided', () => { + render(); + expect(screen.queryByText(/./)).not.toBeInTheDocument(); + }); + + it('renders small spinner', () => { + const { container } = render(); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toHaveClass('h-4', 'w-4'); + }); + + it('renders inline', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('inline-flex'); + }); +}); diff --git a/frontend/src/components/ui/__tests__/Modal.test.tsx b/frontend/src/components/ui/__tests__/Modal.test.tsx new file mode 100644 index 0000000..806d1fb --- /dev/null +++ b/frontend/src/components/ui/__tests__/Modal.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Modal } from '../Modal'; + +describe('Modal', () => { + const defaultProps = { + isOpen: true, + onClose: vi.fn(), + children:
Modal Content
, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + document.body.style.overflow = ''; + }); + + it('returns null when not open', () => { + render(); + expect(screen.queryByText('Modal Content')).not.toBeInTheDocument(); + }); + + it('renders children when open', () => { + render(); + expect(screen.getByText('Modal Content')).toBeInTheDocument(); + }); + + it('renders title when provided', () => { + render(); + expect(screen.getByText('Modal Title')).toBeInTheDocument(); + }); + + it('renders footer when provided', () => { + render(Save} />); + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('shows close button by default', () => { + render(); + expect(screen.getByLabelText('Close modal')).toBeInTheDocument(); + }); + + it('hides close button when showCloseButton is false', () => { + render(); + expect(screen.queryByLabelText('Close modal')).not.toBeInTheDocument(); + }); + + it('calls onClose when close button clicked', () => { + render(); + fireEvent.click(screen.getByLabelText('Close modal')); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('calls onClose when overlay clicked', () => { + render(); + // Click on backdrop + const backdrop = document.querySelector('.backdrop-blur-sm'); + if (backdrop) { + fireEvent.click(backdrop); + expect(defaultProps.onClose).toHaveBeenCalled(); + } + }); + + it('does not close when content clicked', () => { + render(); + fireEvent.click(screen.getByText('Modal Content')); + expect(defaultProps.onClose).not.toHaveBeenCalled(); + }); + + it('does not close on overlay click when closeOnOverlayClick is false', () => { + render(); + const backdrop = document.querySelector('.backdrop-blur-sm'); + if (backdrop) { + fireEvent.click(backdrop); + expect(defaultProps.onClose).not.toHaveBeenCalled(); + } + }); + + it('applies size classes', () => { + render(); + const modalContent = document.querySelector('.max-w-lg'); + expect(modalContent).toBeInTheDocument(); + }); + + it('applies custom className', () => { + render(); + const modalContent = document.querySelector('.custom-class'); + expect(modalContent).toBeInTheDocument(); + }); + + it('prevents body scroll when open', () => { + render(); + expect(document.body.style.overflow).toBe('hidden'); + }); +}); diff --git a/frontend/src/components/ui/__tests__/ModalFooter.test.tsx b/frontend/src/components/ui/__tests__/ModalFooter.test.tsx new file mode 100644 index 0000000..ff0d50d --- /dev/null +++ b/frontend/src/components/ui/__tests__/ModalFooter.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ModalFooter } from '../ModalFooter'; + +describe('ModalFooter', () => { + it('renders cancel button when onCancel provided', () => { + render( {}} />); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + + it('renders submit button when onSubmit provided', () => { + render( {}} />); + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('renders both buttons when both handlers provided', () => { + render( {}} onSubmit={() => {}} />); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('uses custom submit text', () => { + render( {}} submitText="Create" />); + expect(screen.getByText('Create')).toBeInTheDocument(); + }); + + it('uses custom cancel text', () => { + render( {}} cancelText="Close" />); + expect(screen.getByText('Close')).toBeInTheDocument(); + }); + + it('calls onCancel when cancel button clicked', () => { + const handleCancel = vi.fn(); + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(handleCancel).toHaveBeenCalled(); + }); + + it('calls onSubmit when submit button clicked', () => { + const handleSubmit = vi.fn(); + render(); + fireEvent.click(screen.getByText('Save')); + expect(handleSubmit).toHaveBeenCalled(); + }); + + it('disables submit button when isDisabled is true', () => { + render( {}} isDisabled />); + expect(screen.getByText('Save')).toBeDisabled(); + }); + + it('disables buttons when isLoading is true', () => { + render( {}} onSubmit={() => {}} isLoading />); + expect(screen.getByText('Cancel')).toBeDisabled(); + expect(screen.getByText('Save')).toBeDisabled(); + }); + + it('shows loading spinner when isLoading', () => { + const { container } = render( {}} isLoading />); + expect(container.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + it('renders back button when showBackButton is true', () => { + render( {}} showBackButton />); + expect(screen.getByText('Back')).toBeInTheDocument(); + }); + + it('uses custom back text', () => { + render( {}} showBackButton backText="Previous" />); + expect(screen.getByText('Previous')).toBeInTheDocument(); + }); + + it('calls onBack when back button clicked', () => { + const handleBack = vi.fn(); + render(); + fireEvent.click(screen.getByText('Back')); + expect(handleBack).toHaveBeenCalled(); + }); + + it('does not render back button without onBack handler', () => { + render(); + expect(screen.queryByText('Back')).not.toBeInTheDocument(); + }); + + it('applies primary variant by default', () => { + render( {}} />); + expect(screen.getByText('Save')).toHaveClass('bg-blue-600'); + }); + + it('applies danger variant', () => { + render( {}} submitVariant="danger" />); + expect(screen.getByText('Save')).toHaveClass('bg-red-600'); + }); + + it('applies success variant', () => { + render( {}} submitVariant="success" />); + expect(screen.getByText('Save')).toHaveClass('bg-green-600'); + }); + + it('applies warning variant', () => { + render( {}} submitVariant="warning" />); + expect(screen.getByText('Save')).toHaveClass('bg-amber-600'); + }); + + it('applies secondary variant', () => { + render( {}} submitVariant="secondary" />); + expect(screen.getByText('Save')).toHaveClass('bg-gray-600'); + }); + + it('renders children instead of default buttons', () => { + render( + {}} onCancel={() => {}}> + + + ); + expect(screen.getByText('Custom Button')).toBeInTheDocument(); + expect(screen.queryByText('Cancel')).not.toBeInTheDocument(); + expect(screen.queryByText('Save')).not.toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('renders nothing when no handlers provided', () => { + const { container } = render(); + expect(container.querySelector('button')).not.toBeInTheDocument(); + }); + + it('disables back button when loading', () => { + render( {}} showBackButton isLoading />); + expect(screen.getByText('Back')).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/ui/__tests__/StepIndicator.test.tsx b/frontend/src/components/ui/__tests__/StepIndicator.test.tsx new file mode 100644 index 0000000..832aca2 --- /dev/null +++ b/frontend/src/components/ui/__tests__/StepIndicator.test.tsx @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { StepIndicator } from '../StepIndicator'; + +const steps = [ + { id: 1, label: 'Step 1' }, + { id: 2, label: 'Step 2' }, + { id: 3, label: 'Step 3' }, +]; + +describe('StepIndicator', () => { + it('renders all steps', () => { + render(); + expect(screen.getByText('Step 1')).toBeInTheDocument(); + expect(screen.getByText('Step 2')).toBeInTheDocument(); + expect(screen.getByText('Step 3')).toBeInTheDocument(); + }); + + it('shows step numbers', () => { + render(); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('highlights current step', () => { + render(); + const step2Circle = screen.getByText('2').closest('div'); + expect(step2Circle).toHaveClass('bg-blue-600'); + }); + + it('shows checkmark for completed steps', () => { + const { container } = render(); + const checkmarks = container.querySelectorAll('svg'); + // Steps 1 and 2 should show checkmarks + expect(checkmarks.length).toBe(2); + }); + + it('applies pending style to future steps', () => { + render(); + const step3Circle = screen.getByText('3').closest('div'); + expect(step3Circle).toHaveClass('bg-gray-200'); + }); + + it('shows connectors between steps by default', () => { + const { container } = render(); + const connectors = container.querySelectorAll('.w-16'); + expect(connectors.length).toBe(2); + }); + + it('hides connectors when showConnectors is false', () => { + const { container } = render(); + const connectors = container.querySelectorAll('.w-16'); + expect(connectors.length).toBe(0); + }); + + it('applies completed style to connector before current step', () => { + const { container } = render(); + const connectors = container.querySelectorAll('.w-16'); + expect(connectors[0]).toHaveClass('bg-blue-600'); + expect(connectors[1]).toHaveClass('bg-gray-200'); + }); + + it('applies blue color by default', () => { + render(); + const currentStep = screen.getByText('1').closest('div'); + expect(currentStep).toHaveClass('bg-blue-600'); + }); + + it('applies brand color', () => { + render(); + const currentStep = screen.getByText('1').closest('div'); + expect(currentStep).toHaveClass('bg-brand-600'); + }); + + it('applies green color', () => { + render(); + const currentStep = screen.getByText('1').closest('div'); + expect(currentStep).toHaveClass('bg-green-600'); + }); + + it('applies purple color', () => { + render(); + const currentStep = screen.getByText('1').closest('div'); + expect(currentStep).toHaveClass('bg-purple-600'); + }); + + it('applies custom className', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('centers steps', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('flex', 'items-center', 'justify-center'); + }); + + it('applies active text color to current step label', () => { + render(); + const step2Label = screen.getByText('Step 2'); + expect(step2Label).toHaveClass('text-blue-600'); + }); + + it('applies pending text color to future step labels', () => { + render(); + const step3Label = screen.getByText('Step 3'); + expect(step3Label).toHaveClass('text-gray-400'); + }); + + it('applies active text color to completed step labels', () => { + render(); + const step1Label = screen.getByText('Step 1'); + expect(step1Label).toHaveClass('text-blue-600'); + }); + + it('handles single step', () => { + const singleStep = [{ id: 1, label: 'Only Step' }]; + render(); + expect(screen.getByText('Only Step')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('renders step circles with correct size', () => { + render(); + const stepCircle = screen.getByText('1').closest('div'); + expect(stepCircle).toHaveClass('w-8', 'h-8'); + }); + + it('renders step circles as rounded', () => { + render(); + const stepCircle = screen.getByText('1').closest('div'); + expect(stepCircle).toHaveClass('rounded-full'); + }); + + it('handles string IDs', () => { + const stepsWithStringIds = [ + { id: 'first', label: 'First' }, + { id: 'second', label: 'Second' }, + ]; + render(); + expect(screen.getByText('First')).toBeInTheDocument(); + expect(screen.getByText('Second')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/__tests__/TabGroup.test.tsx b/frontend/src/components/ui/__tests__/TabGroup.test.tsx new file mode 100644 index 0000000..0d428f4 --- /dev/null +++ b/frontend/src/components/ui/__tests__/TabGroup.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TabGroup } from '../TabGroup'; + +const tabs = [ + { id: 'tab1', label: 'Tab 1' }, + { id: 'tab2', label: 'Tab 2' }, + { id: 'tab3', label: 'Tab 3', disabled: true }, +]; + +describe('TabGroup', () => { + it('renders all tabs', () => { + render( {}} />); + expect(screen.getByText('Tab 1')).toBeInTheDocument(); + expect(screen.getByText('Tab 2')).toBeInTheDocument(); + expect(screen.getByText('Tab 3')).toBeInTheDocument(); + }); + + it('highlights active tab', () => { + render( {}} />); + const activeButton = screen.getByText('Tab 1').closest('button'); + expect(activeButton).toHaveClass('bg-blue-600'); + }); + + it('calls onChange when tab is clicked', () => { + const handleChange = vi.fn(); + render(); + fireEvent.click(screen.getByText('Tab 2')); + expect(handleChange).toHaveBeenCalledWith('tab2'); + }); + + it('does not call onChange for disabled tabs', () => { + const handleChange = vi.fn(); + render(); + fireEvent.click(screen.getByText('Tab 3')); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it('applies disabled styling to disabled tabs', () => { + render( {}} />); + const disabledButton = screen.getByText('Tab 3').closest('button'); + expect(disabledButton).toHaveClass('opacity-50'); + expect(disabledButton).toHaveClass('cursor-not-allowed'); + }); + + it('renders default variant by default', () => { + const { container } = render( {}} />); + expect(container.firstChild).toHaveClass('rounded-lg'); + }); + + it('renders underline variant', () => { + const { container } = render( + {}} variant="underline" /> + ); + expect(container.firstChild).toHaveClass('border-b'); + }); + + it('renders pills variant', () => { + const { container } = render( + {}} variant="pills" /> + ); + const activeButton = screen.getByText('Tab 1').closest('button'); + expect(activeButton).toHaveClass('rounded-full'); + }); + + it('applies small size classes', () => { + render( {}} size="sm" />); + const button = screen.getByText('Tab 1').closest('button'); + expect(button).toHaveClass('py-1.5'); + }); + + it('applies medium size classes by default', () => { + render( {}} />); + const button = screen.getByText('Tab 1').closest('button'); + expect(button).toHaveClass('py-2'); + }); + + it('applies large size classes', () => { + render( {}} size="lg" />); + const button = screen.getByText('Tab 1').closest('button'); + expect(button).toHaveClass('py-2.5'); + }); + + it('applies full width by default', () => { + render( {}} />); + const button = screen.getByText('Tab 1').closest('button'); + expect(button).toHaveClass('flex-1'); + }); + + it('can disable full width', () => { + render( {}} fullWidth={false} />); + const button = screen.getByText('Tab 1').closest('button'); + expect(button).not.toHaveClass('flex-1'); + }); + + it('applies custom className', () => { + const { container } = render( + {}} className="custom-class" /> + ); + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('applies blue active color by default', () => { + render( {}} />); + const activeButton = screen.getByText('Tab 1').closest('button'); + expect(activeButton).toHaveClass('bg-blue-600'); + }); + + it('applies purple active color', () => { + render( {}} activeColor="purple" />); + const activeButton = screen.getByText('Tab 1').closest('button'); + expect(activeButton).toHaveClass('bg-purple-600'); + }); + + it('applies green active color', () => { + render( {}} activeColor="green" />); + const activeButton = screen.getByText('Tab 1').closest('button'); + expect(activeButton).toHaveClass('bg-green-600'); + }); + + it('applies brand active color', () => { + render( {}} activeColor="brand" />); + const activeButton = screen.getByText('Tab 1').closest('button'); + expect(activeButton).toHaveClass('bg-brand-600'); + }); + + it('renders tabs with icons', () => { + const tabsWithIcons = [ + { id: 'tab1', label: 'Tab 1', icon: 🏠 }, + { id: 'tab2', label: 'Tab 2', icon: 📧 }, + ]; + render( {}} />); + expect(screen.getByTestId('icon-1')).toBeInTheDocument(); + expect(screen.getByTestId('icon-2')).toBeInTheDocument(); + }); + + it('renders tabs with ReactNode labels', () => { + const tabsWithNodes = [ + { id: 'tab1', label: Bold Tab }, + ]; + render( {}} />); + expect(screen.getByText('Bold Tab')).toBeInTheDocument(); + }); + + it('applies underline variant colors correctly', () => { + render( + {}} variant="underline" /> + ); + const activeButton = screen.getByText('Tab 1').closest('button'); + expect(activeButton).toHaveClass('border-blue-600'); + }); + + it('applies pills variant colors correctly', () => { + render( + {}} variant="pills" /> + ); + const activeButton = screen.getByText('Tab 1').closest('button'); + expect(activeButton).toHaveClass('bg-blue-100'); + }); +}); diff --git a/frontend/src/components/ui/__tests__/UnfinishedBadge.test.tsx b/frontend/src/components/ui/__tests__/UnfinishedBadge.test.tsx new file mode 100644 index 0000000..75b3ef0 --- /dev/null +++ b/frontend/src/components/ui/__tests__/UnfinishedBadge.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { UnfinishedBadge } from '../UnfinishedBadge'; + +describe('UnfinishedBadge', () => { + it('renders WIP text', () => { + render(); + expect(screen.getByText('WIP')).toBeInTheDocument(); + }); + + it('renders as a badge', () => { + render(); + const badge = screen.getByText('WIP').closest('span'); + expect(badge).toBeInTheDocument(); + }); + + it('uses warning variant', () => { + render(); + const badge = screen.getByText('WIP').closest('span'); + expect(badge).toHaveClass('bg-amber-100'); + }); + + it('uses pill style', () => { + render(); + const badge = screen.getByText('WIP').closest('span'); + expect(badge).toHaveClass('rounded-full'); + }); + + it('uses small size', () => { + render(); + const badge = screen.getByText('WIP').closest('span'); + expect(badge).toHaveClass('text-xs'); + }); +}); diff --git a/frontend/src/hooks/__tests__/useFormValidation.test.ts b/frontend/src/hooks/__tests__/useFormValidation.test.ts new file mode 100644 index 0000000..91d1ecf --- /dev/null +++ b/frontend/src/hooks/__tests__/useFormValidation.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + useFormValidation, + required, + email, + minLength, + maxLength, + minValue, + maxValue, + pattern, + url, + matches, + phone, +} from '../useFormValidation'; + +describe('useFormValidation', () => { + describe('hook functionality', () => { + it('initializes with no errors', () => { + const { result } = renderHook(() => useFormValidation({})); + expect(result.current.errors).toEqual({}); + expect(result.current.isValid).toBe(true); + }); + + it('validates form and returns errors', () => { + const schema = { + name: [required('Name is required')], + }; + const { result } = renderHook(() => useFormValidation(schema)); + + act(() => { + result.current.validateForm({ name: '' }); + }); + + expect(result.current.errors.name).toBe('Name is required'); + expect(result.current.isValid).toBe(false); + }); + + it('validates single field', () => { + const schema = { + email: [email('Invalid email')], + }; + const { result } = renderHook(() => useFormValidation(schema)); + + const error = result.current.validateField('email', 'invalid'); + expect(error).toBe('Invalid email'); + }); + + it('returns undefined for valid field', () => { + const schema = { + email: [email('Invalid email')], + }; + const { result } = renderHook(() => useFormValidation(schema)); + + const error = result.current.validateField('email', 'test@example.com'); + expect(error).toBeUndefined(); + }); + + it('sets error manually', () => { + const { result } = renderHook(() => useFormValidation({})); + + act(() => { + result.current.setError('field', 'Custom error'); + }); + + expect(result.current.errors.field).toBe('Custom error'); + }); + + it('clears single error', () => { + const { result } = renderHook(() => useFormValidation({})); + + act(() => { + result.current.setError('field', 'Error'); + result.current.clearError('field'); + }); + + expect(result.current.errors.field).toBeUndefined(); + }); + + it('clears all errors', () => { + const { result } = renderHook(() => useFormValidation({})); + + act(() => { + result.current.setError('field1', 'Error 1'); + result.current.setError('field2', 'Error 2'); + result.current.clearAllErrors(); + }); + + expect(result.current.errors).toEqual({}); + }); + + it('getError returns correct error', () => { + const { result } = renderHook(() => useFormValidation({})); + + act(() => { + result.current.setError('field', 'Test error'); + }); + + expect(result.current.getError('field')).toBe('Test error'); + }); + + it('hasError returns true when error exists', () => { + const { result } = renderHook(() => useFormValidation({})); + + act(() => { + result.current.setError('field', 'Error'); + }); + + expect(result.current.hasError('field')).toBe(true); + }); + + it('hasError returns false when no error', () => { + const { result } = renderHook(() => useFormValidation({})); + expect(result.current.hasError('field')).toBe(false); + }); + }); + + describe('required validator', () => { + it('returns error for undefined', () => { + const validator = required('Required'); + expect(validator(undefined)).toBe('Required'); + }); + + it('returns error for null', () => { + const validator = required('Required'); + expect(validator(null)).toBe('Required'); + }); + + it('returns error for empty string', () => { + const validator = required('Required'); + expect(validator('')).toBe('Required'); + }); + + it('returns error for empty array', () => { + const validator = required('Required'); + expect(validator([])).toBe('Required'); + }); + + it('returns undefined for valid value', () => { + const validator = required('Required'); + expect(validator('value')).toBeUndefined(); + }); + + it('uses default message', () => { + const validator = required(); + expect(validator('')).toBe('This field is required'); + }); + }); + + describe('email validator', () => { + it('returns error for invalid email', () => { + const validator = email('Invalid'); + expect(validator('notanemail')).toBe('Invalid'); + }); + + it('returns undefined for valid email', () => { + const validator = email('Invalid'); + expect(validator('test@example.com')).toBeUndefined(); + }); + + it('returns undefined for empty value', () => { + const validator = email('Invalid'); + expect(validator('')).toBeUndefined(); + }); + }); + + describe('minLength validator', () => { + it('returns error when too short', () => { + const validator = minLength(5, 'Too short'); + expect(validator('ab')).toBe('Too short'); + }); + + it('returns undefined when long enough', () => { + const validator = minLength(5, 'Too short'); + expect(validator('abcde')).toBeUndefined(); + }); + + it('uses default message', () => { + const validator = minLength(5); + expect(validator('ab')).toBe('Must be at least 5 characters'); + }); + }); + + describe('maxLength validator', () => { + it('returns error when too long', () => { + const validator = maxLength(3, 'Too long'); + expect(validator('abcd')).toBe('Too long'); + }); + + it('returns undefined when short enough', () => { + const validator = maxLength(3, 'Too long'); + expect(validator('abc')).toBeUndefined(); + }); + + it('uses default message', () => { + const validator = maxLength(3); + expect(validator('abcd')).toBe('Must be at most 3 characters'); + }); + }); + + describe('minValue validator', () => { + it('returns error when below min', () => { + const validator = minValue(10, 'Too small'); + expect(validator(5)).toBe('Too small'); + }); + + it('returns undefined when at or above min', () => { + const validator = minValue(10, 'Too small'); + expect(validator(10)).toBeUndefined(); + }); + + it('returns undefined for null/undefined', () => { + const validator = minValue(10); + expect(validator(undefined as unknown as number)).toBeUndefined(); + }); + }); + + describe('maxValue validator', () => { + it('returns error when above max', () => { + const validator = maxValue(10, 'Too big'); + expect(validator(15)).toBe('Too big'); + }); + + it('returns undefined when at or below max', () => { + const validator = maxValue(10, 'Too big'); + expect(validator(10)).toBeUndefined(); + }); + }); + + describe('pattern validator', () => { + it('returns error when pattern does not match', () => { + const validator = pattern(/^[a-z]+$/, 'Letters only'); + expect(validator('abc123')).toBe('Letters only'); + }); + + it('returns undefined when pattern matches', () => { + const validator = pattern(/^[a-z]+$/, 'Letters only'); + expect(validator('abc')).toBeUndefined(); + }); + }); + + describe('url validator', () => { + it('returns error for invalid URL', () => { + const validator = url('Invalid URL'); + expect(validator('not-a-url')).toBe('Invalid URL'); + }); + + it('returns undefined for valid URL', () => { + const validator = url('Invalid URL'); + expect(validator('https://example.com')).toBeUndefined(); + }); + + it('returns undefined for empty value', () => { + const validator = url('Invalid URL'); + expect(validator('')).toBeUndefined(); + }); + }); + + describe('matches validator', () => { + it('returns error when fields do not match', () => { + const validator = matches('password', 'Must match'); + expect(validator('abc', { password: 'xyz' })).toBe('Must match'); + }); + + it('returns undefined when fields match', () => { + const validator = matches('password', 'Must match'); + expect(validator('abc', { password: 'abc' })).toBeUndefined(); + }); + + it('returns undefined when no form data', () => { + const validator = matches('password'); + expect(validator('abc')).toBeUndefined(); + }); + }); + + describe('phone validator', () => { + it('returns error for invalid phone', () => { + const validator = phone('Invalid phone'); + expect(validator('abc')).toBe('Invalid phone'); + }); + + it('returns undefined for valid phone', () => { + const validator = phone('Invalid phone'); + // Use a phone format that matches the regex: /^[+]?[(]?[0-9]{1,4}[)]?[-\s./0-9]*$/ + expect(validator('+15551234567')).toBeUndefined(); + }); + + it('returns undefined for empty value', () => { + const validator = phone('Invalid phone'); + expect(validator('')).toBeUndefined(); + }); + }); +}); diff --git a/frontend/src/hooks/__tests__/useLocations.test.ts b/frontend/src/hooks/__tests__/useLocations.test.ts new file mode 100644 index 0000000..a18eb48 --- /dev/null +++ b/frontend/src/hooks/__tests__/useLocations.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +// Mock apiClient +vi.mock('../../api/client', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})); + +import { + useLocations, + useLocation, + useCreateLocation, + useUpdateLocation, + useDeleteLocation, + useSetPrimaryLocation, + useSetLocationActive, +} from '../useLocations'; +import apiClient from '../../api/client'; + +// Create wrapper +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children); + }; +}; + +describe('useLocations hooks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('useLocations', () => { + it('fetches locations and returns data', async () => { + const mockLocations = [ + { + id: 1, + name: 'Main Office', + city: 'Denver', + state: 'CO', + is_active: true, + is_primary: true, + display_order: 0, + resource_count: 5, + service_count: 10, + }, + { + id: 2, + name: 'Branch Office', + city: 'Boulder', + state: 'CO', + is_active: true, + is_primary: false, + display_order: 1, + }, + ]; + vi.mocked(apiClient.get).mockResolvedValue({ data: mockLocations }); + + const { result } = renderHook(() => useLocations(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(apiClient.get).toHaveBeenCalledWith('/locations/'); + expect(result.current.data).toHaveLength(2); + expect(result.current.data?.[0]).toEqual(expect.objectContaining({ + id: 1, + name: 'Main Office', + is_primary: true, + })); + }); + + it('fetches all locations when includeInactive is true', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: [] }); + + renderHook(() => useLocations({ includeInactive: true }), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(apiClient.get).toHaveBeenCalledWith('/locations/?include_inactive=true'); + }); + }); + }); + + describe('useLocation', () => { + it('fetches a single location by id', async () => { + const mockLocation = { + id: 1, + name: 'Main Office', + is_active: true, + is_primary: true, + display_order: 0, + }; + vi.mocked(apiClient.get).mockResolvedValue({ data: mockLocation }); + + const { result } = renderHook(() => useLocation(1), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(apiClient.get).toHaveBeenCalledWith('/locations/1/'); + expect(result.current.data?.name).toBe('Main Office'); + }); + + it('does not fetch when id is undefined', async () => { + renderHook(() => useLocation(undefined), { + wrapper: createWrapper(), + }); + + expect(apiClient.get).not.toHaveBeenCalled(); + }); + }); + + describe('useCreateLocation', () => { + it('creates location with correct data', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } }); + + const { result } = renderHook(() => useCreateLocation(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync({ + name: 'New Location', + city: 'Denver', + state: 'CO', + }); + }); + + expect(apiClient.post).toHaveBeenCalledWith('/locations/', { + name: 'New Location', + city: 'Denver', + state: 'CO', + }); + }); + }); + + describe('useUpdateLocation', () => { + it('updates location with mapped fields', async () => { + vi.mocked(apiClient.patch).mockResolvedValue({ data: {} }); + + const { result } = renderHook(() => useUpdateLocation(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync({ + id: 1, + updates: { + name: 'Updated Office', + city: 'Boulder', + }, + }); + }); + + expect(apiClient.patch).toHaveBeenCalledWith('/locations/1/', { + name: 'Updated Office', + city: 'Boulder', + }); + }); + }); + + describe('useDeleteLocation', () => { + it('deletes location by id', async () => { + vi.mocked(apiClient.delete).mockResolvedValue({}); + + const { result } = renderHook(() => useDeleteLocation(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync(1); + }); + + expect(apiClient.delete).toHaveBeenCalledWith('/locations/1/'); + }); + }); + + describe('useSetPrimaryLocation', () => { + it('sets location as primary', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_primary: true } }); + + const { result } = renderHook(() => useSetPrimaryLocation(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync(1); + }); + + expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_primary/'); + }); + }); + + describe('useSetLocationActive', () => { + it('activates location', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_active: true } }); + + const { result } = renderHook(() => useSetLocationActive(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync({ id: 1, isActive: true }); + }); + + expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_active/', { + is_active: true, + }); + }); + + it('deactivates location', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_active: false } }); + + const { result } = renderHook(() => useSetLocationActive(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync({ id: 1, isActive: false }); + }); + + expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_active/', { + is_active: false, + }); + }); + }); +}); diff --git a/frontend/src/hooks/__tests__/useResources.test.ts b/frontend/src/hooks/__tests__/useResources.test.ts index 856a400..b9237b1 100644 --- a/frontend/src/hooks/__tests__/useResources.test.ts +++ b/frontend/src/hooks/__tests__/useResources.test.ts @@ -67,6 +67,9 @@ describe('useResources hooks', () => { maxConcurrentEvents: 2, savedLaneCount: undefined, userCanEditSchedule: false, + locationId: null, + locationName: null, + isMobile: false, }); }); diff --git a/frontend/src/hooks/useBillingAdmin.ts b/frontend/src/hooks/useBillingAdmin.ts index 5510aeb..2d170f5 100644 --- a/frontend/src/hooks/useBillingAdmin.ts +++ b/frontend/src/hooks/useBillingAdmin.ts @@ -433,6 +433,68 @@ export const useMarkVersionLegacy = () => { }); }; +// Force update response type +export interface ForceUpdateResponse { + message: string; + version: PlanVersion; + affected_count: number; + affected_businesses: string[]; +} + +// Force update confirmation response (when confirm not provided) +export interface ForceUpdateConfirmRequired { + detail: string; + warning: string; + subscriber_count: number; + requires_confirm: true; +} + +/** + * DANGEROUS: Force update a plan version in place, affecting all subscribers. + * + * This bypasses grandfathering and modifies the plan for ALL existing subscribers. + * Only superusers can use this action. + * + * Usage: + * 1. Call without confirm to get subscriber count and warning + * 2. Show warning to user + * 3. Call with confirm: true to execute + */ +export const useForceUpdatePlanVersion = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + confirm, + ...updates + }: PlanVersionUpdate & { id: number; confirm?: boolean }): Promise< + ForceUpdateResponse | ForceUpdateConfirmRequired + > => { + const { data } = await apiClient.post( + `${BILLING_BASE}/plan-versions/${id}/force_update/`, + { ...updates, confirm } + ); + return data; + }, + onSuccess: (data) => { + // Only invalidate if it was a confirmed update (not just checking) + if ('version' in data) { + queryClient.invalidateQueries({ queryKey: ['billingAdmin'] }); + } + }, + }); +}; + +/** + * Check if response is a confirmation requirement + */ +export const isForceUpdateConfirmRequired = ( + response: ForceUpdateResponse | ForceUpdateConfirmRequired +): response is ForceUpdateConfirmRequired => { + return 'requires_confirm' in response && response.requires_confirm === true; +}; + export const usePlanVersionSubscribers = (id: number) => { return useQuery({ queryKey: ['billingAdmin', 'planVersions', id, 'subscribers'], diff --git a/frontend/src/hooks/useBillingPlans.ts b/frontend/src/hooks/useBillingPlans.ts new file mode 100644 index 0000000..1cc0a2f --- /dev/null +++ b/frontend/src/hooks/useBillingPlans.ts @@ -0,0 +1,372 @@ +/** + * Billing Plans Hooks + * + * Provides access to the billing system's plans, features, and add-ons. + * Used by platform admin for managing tenant subscriptions. + */ + +import { useQuery } from '@tanstack/react-query'; +import apiClient from '../api/client'; + +// Feature from billing system - the SINGLE SOURCE OF TRUTH +export interface BillingFeature { + id: number; + code: string; + name: string; + description: string; + feature_type: 'boolean' | 'integer'; + // Dynamic feature management + category: 'limits' | 'payments' | 'communication' | 'customization' | 'plugins' | 'advanced' | 'scheduling' | 'enterprise'; + tenant_field_name: string; // Corresponding field on Tenant model + display_order: number; + is_overridable: boolean; + depends_on: number | null; // ID of parent feature + depends_on_code: string | null; // Code of parent feature (for convenience) +} + +// Category metadata for display +export const FEATURE_CATEGORY_META: Record = { + limits: { label: 'Limits', order: 0 }, + payments: { label: 'Payments & Revenue', order: 1 }, + communication: { label: 'Communication', order: 2 }, + customization: { label: 'Customization', order: 3 }, + plugins: { label: 'Plugins & Automation', order: 4 }, + advanced: { label: 'Advanced Features', order: 5 }, + scheduling: { label: 'Scheduling', order: 6 }, + enterprise: { label: 'Enterprise & Security', order: 7 }, +}; + +// Plan feature with value +export interface BillingPlanFeature { + id: number; + feature: BillingFeature; + bool_value: boolean | null; + int_value: number | null; + value: boolean | number | null; +} + +// Plan (logical grouping) +export interface BillingPlan { + id: number; + code: string; + name: string; + description: string; + display_order: number; + is_active: boolean; + max_pages: number; + allow_custom_domains: boolean; + max_custom_domains: number; +} + +// Plan version (specific offer with pricing and features) +export interface BillingPlanVersion { + id: number; + plan: BillingPlan; + version: number; + name: string; + is_public: boolean; + is_legacy: boolean; + starts_at: string | null; + ends_at: string | null; + price_monthly_cents: number; + price_yearly_cents: number; + transaction_fee_percent: string; + transaction_fee_fixed_cents: number; + trial_days: number; + sms_price_per_message_cents: number; + masked_calling_price_per_minute_cents: number; + proxy_number_monthly_fee_cents: number; + default_auto_reload_enabled: boolean; + default_auto_reload_threshold_cents: number; + default_auto_reload_amount_cents: number; + is_most_popular: boolean; + show_price: boolean; + marketing_features: string[]; + stripe_product_id: string; + stripe_price_id_monthly: string; + stripe_price_id_yearly: string; + is_available: boolean; + features: BillingPlanFeature[]; + subscriber_count?: number; + created_at: string; +} + +// Plan with all versions +export interface BillingPlanWithVersions { + id: number; + code: string; + name: string; + description: string; + display_order: number; + is_active: boolean; + max_pages: number; + allow_custom_domains: boolean; + max_custom_domains: number; + versions: BillingPlanVersion[]; + active_version: BillingPlanVersion | null; + total_subscribers: number; +} + +// Add-on product +export interface BillingAddOn { + id: number; + code: string; + name: string; + description: string; + price_monthly_cents: number; + price_one_time_cents: number; + stripe_product_id: string; + stripe_price_id: string; + is_stackable: boolean; + is_active: boolean; + features: BillingPlanFeature[]; +} + +/** + * Hook to get all billing plans with their versions (admin view) + */ +export const useBillingPlans = () => { + return useQuery({ + queryKey: ['billingPlans'], + queryFn: async () => { + const { data } = await apiClient.get('/billing/admin/plans/'); + return data; + }, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; + +/** + * Hook to get the public plan catalog (available versions only) + */ +export const useBillingPlanCatalog = () => { + return useQuery({ + queryKey: ['billingPlanCatalog'], + queryFn: async () => { + const { data } = await apiClient.get('/billing/plans/'); + return data; + }, + staleTime: 5 * 60 * 1000, + }); +}; + +/** + * Hook to get all features + */ +export const useBillingFeatures = () => { + return useQuery({ + queryKey: ['billingFeatures'], + queryFn: async () => { + const { data } = await apiClient.get('/billing/admin/features/'); + return data; + }, + staleTime: 10 * 60 * 1000, // 10 minutes (features rarely change) + }); +}; + +/** + * Hook to get available add-ons + */ +export const useBillingAddOns = () => { + return useQuery({ + queryKey: ['billingAddOns'], + queryFn: async () => { + const { data } = await apiClient.get('/billing/addons/'); + return data; + }, + staleTime: 5 * 60 * 1000, + }); +}; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Get a feature value from a plan version's features array + */ +export function getFeatureValue( + features: BillingPlanFeature[], + featureCode: string +): boolean | number | null { + const feature = features.find(f => f.feature.code === featureCode); + if (!feature) return null; + return feature.value; +} + +/** + * Get a boolean feature value (defaults to false if not found) + */ +export function getBooleanFeature( + features: BillingPlanFeature[], + featureCode: string +): boolean { + const value = getFeatureValue(features, featureCode); + return typeof value === 'boolean' ? value : false; +} + +/** + * Get an integer feature value (defaults to 0 if not found, null means unlimited) + */ +export function getIntegerFeature( + features: BillingPlanFeature[], + featureCode: string +): number | null { + const value = getFeatureValue(features, featureCode); + if (value === null || value === undefined) return null; // Unlimited + return typeof value === 'number' ? value : 0; +} + +/** + * Convert a plan version's features to a flat object for form state + * Maps feature codes to their values + */ +export function planFeaturesToFormState( + planVersion: BillingPlanVersion | null +): Record { + if (!planVersion) return {}; + + const state: Record = {}; + + for (const pf of planVersion.features) { + state[pf.feature.code] = pf.value; + } + + return state; +} + +/** + * Map old tier names to new plan codes + */ +export const TIER_TO_PLAN_CODE: Record = { + FREE: 'free', + STARTER: 'starter', + GROWTH: 'growth', + PROFESSIONAL: 'pro', // Old name -> new code + PRO: 'pro', + ENTERPRISE: 'enterprise', +}; + +/** + * Map new plan codes to display names + */ +export const PLAN_CODE_TO_NAME: Record = { + free: 'Free', + starter: 'Starter', + growth: 'Growth', + pro: 'Pro', + enterprise: 'Enterprise', +}; + +/** + * Get the active plan version for a given plan code + */ +export function getActivePlanVersion( + plans: BillingPlanWithVersions[], + planCode: string +): BillingPlanVersion | null { + const plan = plans.find(p => p.code === planCode); + return plan?.active_version || null; +} + +/** + * Feature code mapping from old permission names to new feature codes + */ +export const PERMISSION_TO_FEATURE_CODE: Record = { + // Communication + can_use_sms_reminders: 'sms_enabled', + can_use_masked_phone_numbers: 'masked_calling_enabled', + + // Platform + can_api_access: 'api_access', + can_use_custom_domain: 'custom_domain', + can_white_label: 'white_label', + + // Features + can_accept_payments: 'payment_processing', + can_use_mobile_app: 'mobile_app_access', + advanced_reporting: 'advanced_reporting', + priority_support: 'priority_support', + dedicated_support: 'dedicated_account_manager', + + // Limits (integer features) + max_users: 'max_users', + max_resources: 'max_resources', + max_locations: 'max_locations', +}; + +/** + * Convert plan features to legacy permission format for backward compatibility + */ +export function planFeaturesToLegacyPermissions( + planVersion: BillingPlanVersion | null +): Record { + if (!planVersion) return {}; + + const permissions: Record = {}; + + // Map features to legacy permission names + for (const pf of planVersion.features) { + const code = pf.feature.code; + const value = pf.value; + + // Direct feature code + permissions[code] = value as boolean | number; + + // Also add with legacy naming for backward compatibility + switch (code) { + case 'sms_enabled': + permissions.can_use_sms_reminders = value as boolean; + break; + case 'masked_calling_enabled': + permissions.can_use_masked_phone_numbers = value as boolean; + break; + case 'api_access': + permissions.can_api_access = value as boolean; + permissions.can_connect_to_api = value as boolean; + break; + case 'custom_domain': + permissions.can_use_custom_domain = value as boolean; + break; + case 'white_label': + permissions.can_white_label = value as boolean; + break; + case 'remove_branding': + permissions.can_white_label = permissions.can_white_label || (value as boolean); + break; + case 'payment_processing': + permissions.can_accept_payments = value as boolean; + break; + case 'mobile_app_access': + permissions.can_use_mobile_app = value as boolean; + break; + case 'advanced_reporting': + permissions.advanced_reporting = value as boolean; + break; + case 'priority_support': + permissions.priority_support = value as boolean; + break; + case 'dedicated_account_manager': + permissions.dedicated_support = value as boolean; + break; + case 'integrations_enabled': + permissions.can_use_webhooks = value as boolean; + permissions.can_use_calendar_sync = value as boolean; + break; + case 'team_permissions': + permissions.can_require_2fa = value as boolean; + break; + case 'audit_logs': + permissions.can_download_logs = value as boolean; + break; + case 'custom_branding': + permissions.can_customize_booking_page = value as boolean; + break; + case 'recurring_appointments': + permissions.can_book_repeated_events = value as boolean; + break; + } + } + + return permissions; +} diff --git a/frontend/src/hooks/useBusiness.ts b/frontend/src/hooks/useBusiness.ts index 39ad535..314bc90 100644 --- a/frontend/src/hooks/useBusiness.ts +++ b/frontend/src/hooks/useBusiness.ts @@ -38,7 +38,7 @@ export const useCurrentBusiness = () => { timezone: data.timezone || 'America/New_York', timezoneDisplayMode: data.timezone_display_mode || 'business', whitelabelEnabled: data.whitelabel_enabled, - plan: data.tier, // Map tier to plan + plan: data.plan, status: data.status, joinedAt: data.created_at ? new Date(data.created_at) : undefined, resourcesCanReschedule: data.resources_can_reschedule, @@ -72,6 +72,7 @@ export const useCurrentBusiness = () => { pos_system: false, mobile_app: false, contracts: false, + multi_location: false, }, }; }, diff --git a/frontend/src/hooks/useLocations.ts b/frontend/src/hooks/useLocations.ts new file mode 100644 index 0000000..4ef92f3 --- /dev/null +++ b/frontend/src/hooks/useLocations.ts @@ -0,0 +1,153 @@ +/** + * Location Management Hooks + * + * Provides hooks for managing business locations in a multi-location setup. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '../api/client'; +import { Location } from '../types'; + +interface LocationFilters { + includeInactive?: boolean; +} + +/** + * Hook to fetch locations with optional inactive filter + */ +export const useLocations = (filters?: LocationFilters) => { + return useQuery({ + queryKey: ['locations', filters], + queryFn: async () => { + let url = '/locations/'; + if (filters?.includeInactive) { + url += '?include_inactive=true'; + } + const { data } = await apiClient.get(url); + return data; + }, + }); +}; + +/** + * Hook to get a single location by ID + */ +export const useLocation = (id: number | undefined) => { + return useQuery({ + queryKey: ['locations', id], + queryFn: async () => { + const { data } = await apiClient.get(`/locations/${id}/`); + return data; + }, + enabled: id !== undefined, + }); +}; + +/** + * Hook to create a new location + */ +export const useCreateLocation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (locationData: Partial) => { + const { data } = await apiClient.post('/locations/', locationData); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['locations'] }); + }, + }); +}; + +/** + * Hook to update a location + */ +export const useUpdateLocation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, updates }: { id: number; updates: Partial }) => { + const { data } = await apiClient.patch(`/locations/${id}/`, updates); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['locations'] }); + }, + }); +}; + +/** + * Hook to delete a location + */ +export const useDeleteLocation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: number) => { + await apiClient.delete(`/locations/${id}/`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['locations'] }); + }, + }); +}; + +/** + * Hook to set a location as primary + */ +export const useSetPrimaryLocation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: number) => { + const { data } = await apiClient.post(`/locations/${id}/set_primary/`); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['locations'] }); + }, + }); +}; + +/** + * Hook to activate or deactivate a location + */ +export const useSetLocationActive = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, isActive }: { id: number; isActive: boolean }) => { + const { data } = await apiClient.post(`/locations/${id}/set_active/`, { + is_active: isActive, + }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['locations'] }); + }, + }); +}; + +/** + * Hook to get only active locations (convenience wrapper) + */ +export const useActiveLocations = () => { + return useLocations(); +}; + +/** + * Hook to get all locations including inactive + */ +export const useAllLocations = () => { + return useLocations({ includeInactive: true }); +}; + +/** + * Hook to get the primary location + */ +export const usePrimaryLocation = () => { + const { data: locations, ...rest } = useLocations(); + const primaryLocation = locations?.find(loc => loc.is_primary); + return { data: primaryLocation, locations, ...rest }; +}; diff --git a/frontend/src/hooks/usePlanFeatures.ts b/frontend/src/hooks/usePlanFeatures.ts index 5573eab..09442e7 100644 --- a/frontend/src/hooks/usePlanFeatures.ts +++ b/frontend/src/hooks/usePlanFeatures.ts @@ -93,6 +93,7 @@ export const FEATURE_NAMES: Record = { pos_system: 'POS System', mobile_app: 'Mobile App', contracts: 'Contracts', + multi_location: 'Multiple Locations', }; /** @@ -115,4 +116,5 @@ export const FEATURE_DESCRIPTIONS: Record = { pos_system: 'Process in-person payments with Point of Sale', mobile_app: 'Access SmoothSchedule on mobile devices', contracts: 'Create and manage contracts with customers', + multi_location: 'Manage multiple business locations with separate resources and services', }; diff --git a/frontend/src/hooks/usePlatform.ts b/frontend/src/hooks/usePlatform.ts index 8dd1b88..3053e66 100644 --- a/frontend/src/hooks/usePlatform.ts +++ b/frontend/src/hooks/usePlatform.ts @@ -11,6 +11,7 @@ import { updateBusiness, createBusiness, deleteBusiness, + changeBusinessPlan, PlatformBusinessUpdate, PlatformBusinessCreate, getTenantInvitations, @@ -73,6 +74,22 @@ export const useUpdateBusiness = () => { }); }; +/** + * Hook to change a business's subscription plan (platform admin only) + */ +export const useChangeBusinessPlan = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ businessId, planCode }: { businessId: number; planCode: string }) => + changeBusinessPlan(businessId, planCode), + onSuccess: () => { + // Invalidate and refetch businesses list + queryClient.invalidateQueries({ queryKey: ['platform', 'businesses'] }); + }, + }); +}; + /** * Hook to create a new business (platform admin only) */ diff --git a/frontend/src/hooks/usePublicPlans.ts b/frontend/src/hooks/usePublicPlans.ts new file mode 100644 index 0000000..b9673be --- /dev/null +++ b/frontend/src/hooks/usePublicPlans.ts @@ -0,0 +1,156 @@ +/** + * Public Plans Hook + * + * Fetches public plans from the billing API for the marketing pricing page. + * This endpoint doesn't require authentication. + */ + +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { API_BASE_URL } from '../api/config'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface Feature { + id: number; + code: string; + name: string; + description: string; + feature_type: 'boolean' | 'integer'; +} + +export interface PlanFeature { + id: number; + feature: Feature; + bool_value: boolean | null; + int_value: number | null; + value: boolean | number | null; +} + +export interface Plan { + id: number; + code: string; + name: string; + description: string; + display_order: number; + is_active: boolean; +} + +export interface PublicPlanVersion { + id: number; + plan: Plan; + version: number; + name: string; + is_public: boolean; + is_legacy: boolean; + price_monthly_cents: number; + price_yearly_cents: number; + transaction_fee_percent: string; + transaction_fee_fixed_cents: number; + trial_days: number; + is_most_popular: boolean; + show_price: boolean; + marketing_features: string[]; + is_available: boolean; + features: PlanFeature[]; + created_at: string; +} + +// ============================================================================= +// API Client (no auth required) +// ============================================================================= + +const publicApiClient = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// ============================================================================= +// API Functions +// ============================================================================= + +/** + * Fetch public plans from the billing catalog. + * No authentication required. + */ +export const fetchPublicPlans = async (): Promise => { + const response = await publicApiClient.get('/billing/plans/'); + return response.data; +}; + +// ============================================================================= +// Hook +// ============================================================================= + +/** + * Hook to fetch public plans for the pricing page. + */ +export const usePublicPlans = () => { + return useQuery({ + queryKey: ['publicPlans'], + queryFn: fetchPublicPlans, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime) + }); +}; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Format price from cents to dollars with currency symbol. + */ +export const formatPrice = (cents: number): string => { + if (cents === 0) return '$0'; + return `$${(cents / 100).toFixed(0)}`; +}; + +/** + * Get a feature value from a plan version by feature code. + */ +export const getPlanFeatureValue = ( + planVersion: PublicPlanVersion, + featureCode: string +): boolean | number | null => { + const planFeature = planVersion.features.find( + (pf) => pf.feature.code === featureCode + ); + return planFeature?.value ?? null; +}; + +/** + * Check if a plan has a boolean feature enabled. + */ +export const hasPlanFeature = ( + planVersion: PublicPlanVersion, + featureCode: string +): boolean => { + const value = getPlanFeatureValue(planVersion, featureCode); + return value === true; +}; + +/** + * Get an integer limit from a plan version. + * Returns 0 if not set (unlimited) or the actual limit. + */ +export const getPlanLimit = ( + planVersion: PublicPlanVersion, + featureCode: string +): number => { + const value = getPlanFeatureValue(planVersion, featureCode); + return typeof value === 'number' ? value : 0; +}; + +/** + * Format a limit value for display. + * 0 means unlimited. + */ +export const formatLimit = (value: number): string => { + if (value === 0) return 'Unlimited'; + return value.toLocaleString(); +}; diff --git a/frontend/src/hooks/useResources.ts b/frontend/src/hooks/useResources.ts index b832d7b..27617ad 100644 --- a/frontend/src/hooks/useResources.ts +++ b/frontend/src/hooks/useResources.ts @@ -31,6 +31,10 @@ export const useResources = (filters?: ResourceFilters) => { maxConcurrentEvents: r.max_concurrent_events ?? 1, savedLaneCount: r.saved_lane_count, userCanEditSchedule: r.user_can_edit_schedule ?? false, + // Location fields + locationId: r.location ?? null, + locationName: r.location_name ?? null, + isMobile: r.is_mobile ?? false, })); }, }); @@ -53,6 +57,10 @@ export const useResource = (id: string) => { maxConcurrentEvents: data.max_concurrent_events ?? 1, savedLaneCount: data.saved_lane_count, userCanEditSchedule: data.user_can_edit_schedule ?? false, + // Location fields + locationId: data.location ?? null, + locationName: data.location_name ?? null, + isMobile: data.is_mobile ?? false, }; }, enabled: !!id, @@ -82,6 +90,13 @@ export const useCreateResource = () => { if (resourceData.userCanEditSchedule !== undefined) { backendData.user_can_edit_schedule = resourceData.userCanEditSchedule; } + // Location fields + if (resourceData.locationId !== undefined) { + backendData.location = resourceData.locationId; + } + if (resourceData.isMobile !== undefined) { + backendData.is_mobile = resourceData.isMobile; + } const { data } = await apiClient.post('/resources/', backendData); return data; @@ -115,6 +130,13 @@ export const useUpdateResource = () => { if (updates.userCanEditSchedule !== undefined) { backendData.user_can_edit_schedule = updates.userCanEditSchedule; } + // Location fields + if (updates.locationId !== undefined) { + backendData.location = updates.locationId; + } + if (updates.isMobile !== undefined) { + backendData.is_mobile = updates.isMobile; + } const { data } = await apiClient.patch(`/resources/${id}/`, backendData); return data; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 476b6e4..1948414 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -114,6 +114,7 @@ "tickets": "Tickets", "help": "Help", "contracts": "Contracts", + "locations": "Locations", "platformGuide": "Platform Guide", "ticketingHelp": "Ticketing System", "apiDocs": "API Docs", @@ -1753,16 +1754,16 @@ "tiers": { "free": { "name": "Free", - "description": "Perfect for getting started", + "description": "Perfect for solo practitioners testing the platform.", "price": "0", "trial": "Free forever - no trial needed", "features": [ - "Up to 2 resources", - "Basic scheduling", - "Customer management", - "Direct Stripe integration", - "Subdomain (business.smoothschedule.com)", - "Community support" + "1 user", + "1 resource", + "50 appointments/month", + "Online booking", + "Email reminders", + "Basic reporting" ], "transactionFee": "2.5% + $0.30 per transaction" }, @@ -1797,53 +1798,73 @@ }, "enterprise": { "name": "Enterprise", - "description": "For large organizations", - "price": "Custom", + "description": "For multi-location and white-label needs.", + "price": "199", "trial": "14-day free trial", "features": [ - "All Business features", - "Custom integrations", - "Dedicated success manager", - "SLA guarantees", - "Custom contracts", - "On-premise option" + "Unlimited users & resources", + "Unlimited appointments", + "Multi-location support", + "White label branding", + "Priority support", + "Dedicated account manager", + "SLA guarantees" ], "transactionFee": "Custom transaction fees" }, "starter": { "name": "Starter", - "description": "Perfect for solo practitioners and small studios.", + "description": "Perfect for small businesses getting started.", "cta": "Start Free", "features": { - "0": "1 User", - "1": "Unlimited Appointments", - "2": "1 Active Automation", - "3": "Basic Reporting", - "4": "Email Support" + "0": "3 Users", + "1": "5 Resources", + "2": "200 Appointments/month", + "3": "Payment Processing", + "4": "Mobile App Access" }, "notIncluded": { - "0": "Custom Domain", - "1": "Python Scripting", - "2": "White-Labeling", - "3": "Priority Support" + "0": "SMS Reminders", + "1": "Custom Domain", + "2": "Integrations", + "3": "API Access" + } + }, + "growth": { + "name": "Growth", + "description": "For growing teams needing SMS and integrations.", + "cta": "Start Trial", + "features": { + "0": "10 Users", + "1": "15 Resources", + "2": "1,000 Appointments/month", + "3": "SMS Reminders", + "4": "Custom Domain", + "5": "Integrations" + }, + "notIncluded": { + "0": "API Access", + "1": "Advanced Reporting", + "2": "Team Permissions" } }, "pro": { "name": "Pro", - "description": "For growing businesses that need automation.", + "description": "For established businesses needing API and analytics.", "cta": "Start Trial", "features": { - "0": "5 Users", - "1": "Unlimited Appointments", - "2": "5 Active Automations", - "3": "Advanced Reporting", - "4": "Priority Email Support", - "5": "SMS Reminders" + "0": "25 Users", + "1": "50 Resources", + "2": "5,000 Appointments/month", + "3": "API Access", + "4": "Advanced Reporting", + "5": "Team Permissions", + "6": "Audit Logs" }, "notIncluded": { - "0": "Custom Domain", - "1": "Python Scripting", - "2": "White-Labeling" + "0": "Multi-location", + "1": "White Label", + "2": "Priority Support" } } }, @@ -1865,7 +1886,62 @@ "question": "Is my data safe?", "answer": "Absolutely. We use dedicated secure vaults to physically isolate your data from other customers. Your business data is never mixed with anyone else's." } - } + }, + "featureComparison": { + "title": "Compare Plans", + "subtitle": "See exactly what you get with each plan", + "features": "Features", + "categories": { + "limits": "Usage Limits", + "communication": "Communication", + "booking": "Booking & Payments", + "integrations": "Integrations & API", + "branding": "Branding & Customization", + "enterprise": "Enterprise Features", + "support": "Support", + "storage": "Storage" + }, + "features": { + "max_users": "Team members", + "max_resources": "Resources", + "max_locations": "Locations", + "max_services": "Services", + "max_customers": "Customers", + "max_appointments_per_month": "Appointments/month", + "email_enabled": "Email notifications", + "max_email_per_month": "Emails/month", + "sms_enabled": "SMS reminders", + "max_sms_per_month": "SMS/month", + "masked_calling_enabled": "Masked calling", + "online_booking": "Online booking", + "recurring_appointments": "Recurring appointments", + "payment_processing": "Accept payments", + "mobile_app_access": "Mobile app", + "integrations_enabled": "Third-party integrations", + "api_access": "API access", + "max_api_calls_per_day": "API calls/day", + "custom_domain": "Custom domain", + "custom_branding": "Custom branding", + "remove_branding": "Remove \"Powered by\"", + "white_label": "White label", + "multi_location": "Multi-location management", + "team_permissions": "Team permissions", + "audit_logs": "Audit logs", + "advanced_reporting": "Advanced analytics", + "priority_support": "Priority support", + "dedicated_account_manager": "Dedicated account manager", + "sla_guarantee": "SLA guarantee", + "max_storage_mb": "File storage" + } + }, + "loadError": "Unable to load pricing. Please try again later.", + "savePercent": "Save ~17%", + "perYear": "/year", + "trialDays": "{{days}}-day free trial", + "freeForever": "Free forever", + "custom": "Custom", + "getStartedFree": "Get Started Free", + "startTrial": "Start Free Trial" }, "testimonials": { "title": "Loved by Businesses Everywhere", diff --git a/frontend/src/pages/Locations.tsx b/frontend/src/pages/Locations.tsx new file mode 100644 index 0000000..a0241aa --- /dev/null +++ b/frontend/src/pages/Locations.tsx @@ -0,0 +1,504 @@ +/** + * Locations Management Page + * + * Allows business owners/managers to manage multiple locations. + */ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Location } from '../types'; +import { + useLocations, + useCreateLocation, + useUpdateLocation, + useDeleteLocation, + useSetPrimaryLocation, + useSetLocationActive, +} from '../hooks/useLocations'; +import { + Plus, + MapPin, + Star, + MoreVertical, + Edit, + Trash2, + Power, + PowerOff, + Building2, +} from 'lucide-react'; +import { Modal, FormInput, Button, Alert } from '../components/ui'; + +interface LocationFormData { + name: string; + address_line1: string; + address_line2: string; + city: string; + state: string; + postal_code: string; + country: string; + phone: string; + email: string; + timezone: string; +} + +const emptyFormData: LocationFormData = { + name: '', + address_line1: '', + address_line2: '', + city: '', + state: '', + postal_code: '', + country: 'US', + phone: '', + email: '', + timezone: '', +}; + +const Locations: React.FC = () => { + const { t } = useTranslation(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingLocation, setEditingLocation] = useState(null); + const [formData, setFormData] = useState(emptyFormData); + const [activeMenu, setActiveMenu] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + + const { data: locations = [], isLoading, error } = useLocations({ includeInactive: true }); + const createMutation = useCreateLocation(); + const updateMutation = useUpdateLocation(); + const deleteMutation = useDeleteLocation(); + const setPrimaryMutation = useSetPrimaryLocation(); + const setActiveMutation = useSetLocationActive(); + + const handleOpenCreate = () => { + setEditingLocation(null); + setFormData(emptyFormData); + setIsModalOpen(true); + }; + + const handleOpenEdit = (location: Location) => { + setEditingLocation(location); + setFormData({ + name: location.name, + address_line1: location.address_line1 || '', + address_line2: location.address_line2 || '', + city: location.city || '', + state: location.state || '', + postal_code: location.postal_code || '', + country: location.country || 'US', + phone: location.phone || '', + email: location.email || '', + timezone: location.timezone || '', + }); + setIsModalOpen(true); + setActiveMenu(null); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + if (editingLocation) { + await updateMutation.mutateAsync({ + id: editingLocation.id, + updates: formData, + }); + } else { + await createMutation.mutateAsync(formData); + } + setIsModalOpen(false); + setFormData(emptyFormData); + setEditingLocation(null); + } catch (err) { + // Error handled by mutation + } + }; + + const handleSetPrimary = async (location: Location) => { + try { + await setPrimaryMutation.mutateAsync(location.id); + setActiveMenu(null); + } catch (err) { + // Error handled by mutation + } + }; + + const handleToggleActive = async (location: Location) => { + try { + await setActiveMutation.mutateAsync({ + id: location.id, + isActive: !location.is_active, + }); + setActiveMenu(null); + } catch (err) { + // Error handled by mutation + } + }; + + const handleDelete = async () => { + if (!deleteConfirm) return; + + try { + await deleteMutation.mutateAsync(deleteConfirm.id); + setDeleteConfirm(null); + } catch (err) { + // Error handled by mutation + } + }; + + if (isLoading) { + return ( +
+
+
+
+
+ ); + } + + if (error) { + return ( +
+ + Failed to load locations: {(error as Error).message} + +
+ ); + } + + const activeLocations = locations.filter(l => l.is_active); + const inactiveLocations = locations.filter(l => !l.is_active); + + return ( +
+ {/* Header */} +
+
+

+ Locations +

+

+ Manage your business locations +

+
+ +
+ + {/* Mutation Errors */} + {(createMutation.error || updateMutation.error || deleteMutation.error || + setPrimaryMutation.error || setActiveMutation.error) && ( + + {((createMutation.error || updateMutation.error || deleteMutation.error || + setPrimaryMutation.error || setActiveMutation.error) as any)?.response?.data?.detail || + 'An error occurred'} + + )} + + {/* Locations Grid */} + {locations.length === 0 ? ( +
+ +

+ No locations yet +

+

+ Get started by creating your first location. +

+ +
+ ) : ( +
+ {/* Active Locations */} + {activeLocations.map((location) => ( + setActiveMenu(activeMenu === location.id ? null : location.id)} + onEdit={() => handleOpenEdit(location)} + onSetPrimary={() => handleSetPrimary(location)} + onToggleActive={() => handleToggleActive(location)} + onDelete={() => setDeleteConfirm(location)} + /> + ))} + + {/* Inactive Locations */} + {inactiveLocations.map((location) => ( + setActiveMenu(activeMenu === location.id ? null : location.id)} + onEdit={() => handleOpenEdit(location)} + onSetPrimary={() => handleSetPrimary(location)} + onToggleActive={() => handleToggleActive(location)} + onDelete={() => setDeleteConfirm(location)} + /> + ))} +
+ )} + + {/* Add/Edit Modal */} + setIsModalOpen(false)} + title={editingLocation ? 'Edit Location' : 'Add Location'} + size="lg" + > +
+ + + + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {/* Delete Confirmation Modal */} + setDeleteConfirm(null)} + title="Delete Location" + size="sm" + > +

+ Are you sure you want to delete {deleteConfirm?.name}? + This action cannot be undone. +

+
+ + +
+
+
+ ); +}; + +// Location Card Component +interface LocationCardProps { + location: Location; + isMenuOpen: boolean; + onMenuToggle: () => void; + onEdit: () => void; + onSetPrimary: () => void; + onToggleActive: () => void; + onDelete: () => void; +} + +const LocationCard: React.FC = ({ + location, + isMenuOpen, + onMenuToggle, + onEdit, + onSetPrimary, + onToggleActive, + onDelete, +}) => { + const address = [ + location.address_line1, + location.city, + location.state, + location.postal_code, + ].filter(Boolean).join(', '); + + return ( +
+ {/* Primary Badge */} + {location.is_primary && ( +
+ + + Primary + +
+ )} + + {/* Menu Button */} +
+ + + {/* Dropdown Menu */} + {isMenuOpen && ( +
+ + {!location.is_primary && ( + + )} + +
+ +
+ )} +
+ + {/* Content */} +
+

+ {location.name} +

+ + {address && ( +

+ + {address} +

+ )} + + {/* Stats */} +
+ {location.resource_count !== undefined && ( + {location.resource_count} resources + )} + {location.service_count !== undefined && ( + {location.service_count} services + )} +
+ + {/* Status Badge */} + {!location.is_active && ( + + Inactive + + )} +
+
+ ); +}; + +export default Locations; diff --git a/frontend/src/pages/MyPlugins.tsx b/frontend/src/pages/MyPlugins.tsx index ab68ee3..eee30f4 100644 --- a/frontend/src/pages/MyPlugins.tsx +++ b/frontend/src/pages/MyPlugins.tsx @@ -72,7 +72,7 @@ const MyPlugins: React.FC = () => { const canCreatePlugins = canUse('can_create_plugins'); const isLocked = !hasPluginsFeature; - // Fetch installed plugins + // Fetch installed plugins - only when user has the feature const { data: plugins = [], isLoading, error } = useQuery({ queryKey: ['plugin-installations'], queryFn: async () => { @@ -95,6 +95,8 @@ const MyPlugins: React.FC = () => { review: p.review, })); }, + // Don't fetch if user doesn't have the plugins feature + enabled: hasPluginsFeature && !permissionsLoading, }); // Uninstall plugin mutation @@ -249,7 +251,10 @@ const MyPlugins: React.FC = () => { ); } - if (error) { + // Check if error is a 403 (plan restriction) - show upgrade prompt instead + const is403Error = error && (error as any)?.response?.status === 403; + + if (error && !is403Error) { return (
@@ -261,8 +266,11 @@ const MyPlugins: React.FC = () => { ); } + // If 403 error, treat as locked + const effectivelyLocked = isLocked || is403Error; + return ( - +
{/* Header */}
diff --git a/frontend/src/pages/PluginMarketplace.tsx b/frontend/src/pages/PluginMarketplace.tsx index 01d29fd..28418f2 100644 --- a/frontend/src/pages/PluginMarketplace.tsx +++ b/frontend/src/pages/PluginMarketplace.tsx @@ -98,7 +98,7 @@ const PluginMarketplace: React.FC = () => { const hasPluginsFeature = canUse('plugins'); const isLocked = !hasPluginsFeature; - // Fetch marketplace plugins + // Fetch marketplace plugins - only when user has the feature const { data: plugins = [], isLoading, error } = useQuery({ queryKey: ['plugin-templates', 'marketplace'], queryFn: async () => { @@ -121,6 +121,8 @@ const PluginMarketplace: React.FC = () => { pluginCode: p.plugin_code, })); }, + // Don't fetch if user doesn't have the plugins feature + enabled: hasPluginsFeature && !permissionsLoading, }); // Fetch installed plugins to check which are already installed @@ -130,6 +132,8 @@ const PluginMarketplace: React.FC = () => { const { data } = await api.get('/plugin-installations/'); return data; }, + // Don't fetch if user doesn't have the plugins feature + enabled: hasPluginsFeature && !permissionsLoading, }); // Create a set of installed template IDs for quick lookup @@ -223,7 +227,10 @@ const PluginMarketplace: React.FC = () => { ); } - if (error) { + // Check if error is a 403 (plan restriction) - show upgrade prompt instead + const is403Error = error && (error as any)?.response?.status === 403; + + if (error && !is403Error) { return (
@@ -235,8 +242,11 @@ const PluginMarketplace: React.FC = () => { ); } + // If 403 error, treat as locked + const effectivelyLocked = isLocked || is403Error; + return ( - +
{/* Header */}
diff --git a/frontend/src/pages/Resources.tsx b/frontend/src/pages/Resources.tsx index d09cbbe..896ba1b 100644 --- a/frontend/src/pages/Resources.tsx +++ b/frontend/src/pages/Resources.tsx @@ -9,6 +9,8 @@ import ResourceCalendar from '../components/ResourceCalendar'; import ResourceDetailModal from '../components/ResourceDetailModal'; import Portal from '../components/Portal'; import { getOverQuotaResourceIds } from '../utils/quotaUtils'; +import { LocationSelector, useShouldShowLocationSelector, useAutoSelectLocation } from '../components/LocationSelector'; +import { usePlanFeatures } from '../hooks/usePlanFeatures'; import { Plus, User as UserIcon, @@ -20,7 +22,8 @@ import { X, Pencil, AlertTriangle, - MapPin + MapPin, + Truck } from 'lucide-react'; const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => { @@ -64,6 +67,16 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => const [formMultilaneEnabled, setFormMultilaneEnabled] = React.useState(false); const [formSavedLaneCount, setFormSavedLaneCount] = React.useState(undefined); const [formUserCanEditSchedule, setFormUserCanEditSchedule] = React.useState(false); + const [formLocationId, setFormLocationId] = React.useState(null); + const [formIsMobile, setFormIsMobile] = React.useState(false); + + // Location features + const { canUse } = usePlanFeatures(); + const hasMultiLocation = canUse('multi_location'); + const showLocationSelector = useShouldShowLocationSelector(); + + // Auto-select location when only one exists + useAutoSelectLocation(formLocationId, setFormLocationId); // Staff selection state const [selectedStaffId, setSelectedStaffId] = useState(null); @@ -186,6 +199,8 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => setFormMultilaneEnabled(editingResource.maxConcurrentEvents > 1); setFormSavedLaneCount(editingResource.savedLaneCount); setFormUserCanEditSchedule(editingResource.userCanEditSchedule ?? false); + setFormLocationId(editingResource.locationId ?? null); + setFormIsMobile(editingResource.isMobile ?? false); // Pre-fill staff if editing a STAFF resource if (editingResource.type === 'STAFF' && editingResource.userId) { setSelectedStaffId(editingResource.userId); @@ -203,6 +218,8 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => setFormMultilaneEnabled(false); setFormSavedLaneCount(undefined); setFormUserCanEditSchedule(false); + setFormLocationId(null); + setFormIsMobile(false); setSelectedStaffId(null); setStaffSearchQuery(''); setDebouncedSearchQuery(''); @@ -265,6 +282,8 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => savedLaneCount: number | undefined; userId?: string; userCanEditSchedule?: boolean; + locationId?: number | null; + isMobile?: boolean; } = { name: formName, type: formType, @@ -277,6 +296,12 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => resourceData.userCanEditSchedule = formUserCanEditSchedule; } + // Add location fields if multi-location is enabled + if (hasMultiLocation) { + resourceData.locationId = formIsMobile ? null : formLocationId; + resourceData.isMobile = formIsMobile; + } + if (editingResource) { updateResourceMutation.mutate({ id: editingResource.id, @@ -602,6 +627,60 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => />
+ {/* Location Selection - only shown when multi-location is enabled and >1 locations */} + {hasMultiLocation && showLocationSelector && ( + <> + {/* Mobile Resource Toggle (for STAFF type) */} + {formType === 'STAFF' && ( +
+
+ +

+ {t('resources.mobileResourceDescription', 'Can work at any location (e.g., mobile technician, field service)')} +

+
+ +
+ )} + + {/* Location Selector - hidden for mobile resources */} + {!formIsMobile && ( + + )} + + {formIsMobile && ( +
+ + + {t('resources.mobileResourceHint', 'This resource can serve customers at any location')} + +
+ )} + + )} + {/* Description */}