Add TenantCustomTier system and fix BusinessEditModal feature loading
Backend: - Add TenantCustomTier model for per-tenant feature overrides - Update EntitlementService to check custom tier before plan features - Add custom_tier action on TenantViewSet (GET/PUT/DELETE) - Add Celery task for grace period management (30-day expiry) Frontend: - Add DynamicFeaturesEditor component for dynamic feature management - Fix BusinessEditModal to load features from plan defaults when no custom tier - Update limits (max_users, max_resources, etc.) to use featureValues - Remove outdated canonical feature check from FeaturePicker (removes warning icons) - Add useBillingPlans hook for accessing billing system data - Add custom tier API functions to platform.ts Features now follow consistent rules: - Load from plan defaults when no custom tier exists - Load from custom tier when one exists - Reset to plan defaults when plan changes - Save to custom tier on edit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
293
frontend/src/hooks/__tests__/useFormValidation.test.ts
Normal file
293
frontend/src/hooks/__tests__/useFormValidation.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import {
|
||||
useFormValidation,
|
||||
required,
|
||||
email,
|
||||
minLength,
|
||||
maxLength,
|
||||
minValue,
|
||||
maxValue,
|
||||
pattern,
|
||||
url,
|
||||
matches,
|
||||
phone,
|
||||
} from '../useFormValidation';
|
||||
|
||||
describe('useFormValidation', () => {
|
||||
describe('hook functionality', () => {
|
||||
it('initializes with no errors', () => {
|
||||
const { result } = renderHook(() => useFormValidation({}));
|
||||
expect(result.current.errors).toEqual({});
|
||||
expect(result.current.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('validates form and returns errors', () => {
|
||||
const schema = {
|
||||
name: [required('Name is required')],
|
||||
};
|
||||
const { result } = renderHook(() => useFormValidation(schema));
|
||||
|
||||
act(() => {
|
||||
result.current.validateForm({ name: '' });
|
||||
});
|
||||
|
||||
expect(result.current.errors.name).toBe('Name is required');
|
||||
expect(result.current.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates single field', () => {
|
||||
const schema = {
|
||||
email: [email('Invalid email')],
|
||||
};
|
||||
const { result } = renderHook(() => useFormValidation(schema));
|
||||
|
||||
const error = result.current.validateField('email', 'invalid');
|
||||
expect(error).toBe('Invalid email');
|
||||
});
|
||||
|
||||
it('returns undefined for valid field', () => {
|
||||
const schema = {
|
||||
email: [email('Invalid email')],
|
||||
};
|
||||
const { result } = renderHook(() => useFormValidation(schema));
|
||||
|
||||
const error = result.current.validateField('email', 'test@example.com');
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets error manually', () => {
|
||||
const { result } = renderHook(() => useFormValidation({}));
|
||||
|
||||
act(() => {
|
||||
result.current.setError('field', 'Custom error');
|
||||
});
|
||||
|
||||
expect(result.current.errors.field).toBe('Custom error');
|
||||
});
|
||||
|
||||
it('clears single error', () => {
|
||||
const { result } = renderHook(() => useFormValidation({}));
|
||||
|
||||
act(() => {
|
||||
result.current.setError('field', 'Error');
|
||||
result.current.clearError('field');
|
||||
});
|
||||
|
||||
expect(result.current.errors.field).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears all errors', () => {
|
||||
const { result } = renderHook(() => useFormValidation({}));
|
||||
|
||||
act(() => {
|
||||
result.current.setError('field1', 'Error 1');
|
||||
result.current.setError('field2', 'Error 2');
|
||||
result.current.clearAllErrors();
|
||||
});
|
||||
|
||||
expect(result.current.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('getError returns correct error', () => {
|
||||
const { result } = renderHook(() => useFormValidation({}));
|
||||
|
||||
act(() => {
|
||||
result.current.setError('field', 'Test error');
|
||||
});
|
||||
|
||||
expect(result.current.getError('field')).toBe('Test error');
|
||||
});
|
||||
|
||||
it('hasError returns true when error exists', () => {
|
||||
const { result } = renderHook(() => useFormValidation({}));
|
||||
|
||||
act(() => {
|
||||
result.current.setError('field', 'Error');
|
||||
});
|
||||
|
||||
expect(result.current.hasError('field')).toBe(true);
|
||||
});
|
||||
|
||||
it('hasError returns false when no error', () => {
|
||||
const { result } = renderHook(() => useFormValidation({}));
|
||||
expect(result.current.hasError('field')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('required validator', () => {
|
||||
it('returns error for undefined', () => {
|
||||
const validator = required('Required');
|
||||
expect(validator(undefined)).toBe('Required');
|
||||
});
|
||||
|
||||
it('returns error for null', () => {
|
||||
const validator = required('Required');
|
||||
expect(validator(null)).toBe('Required');
|
||||
});
|
||||
|
||||
it('returns error for empty string', () => {
|
||||
const validator = required('Required');
|
||||
expect(validator('')).toBe('Required');
|
||||
});
|
||||
|
||||
it('returns error for empty array', () => {
|
||||
const validator = required('Required');
|
||||
expect(validator([])).toBe('Required');
|
||||
});
|
||||
|
||||
it('returns undefined for valid value', () => {
|
||||
const validator = required('Required');
|
||||
expect(validator('value')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses default message', () => {
|
||||
const validator = required();
|
||||
expect(validator('')).toBe('This field is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('email validator', () => {
|
||||
it('returns error for invalid email', () => {
|
||||
const validator = email('Invalid');
|
||||
expect(validator('notanemail')).toBe('Invalid');
|
||||
});
|
||||
|
||||
it('returns undefined for valid email', () => {
|
||||
const validator = email('Invalid');
|
||||
expect(validator('test@example.com')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty value', () => {
|
||||
const validator = email('Invalid');
|
||||
expect(validator('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('minLength validator', () => {
|
||||
it('returns error when too short', () => {
|
||||
const validator = minLength(5, 'Too short');
|
||||
expect(validator('ab')).toBe('Too short');
|
||||
});
|
||||
|
||||
it('returns undefined when long enough', () => {
|
||||
const validator = minLength(5, 'Too short');
|
||||
expect(validator('abcde')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses default message', () => {
|
||||
const validator = minLength(5);
|
||||
expect(validator('ab')).toBe('Must be at least 5 characters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxLength validator', () => {
|
||||
it('returns error when too long', () => {
|
||||
const validator = maxLength(3, 'Too long');
|
||||
expect(validator('abcd')).toBe('Too long');
|
||||
});
|
||||
|
||||
it('returns undefined when short enough', () => {
|
||||
const validator = maxLength(3, 'Too long');
|
||||
expect(validator('abc')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses default message', () => {
|
||||
const validator = maxLength(3);
|
||||
expect(validator('abcd')).toBe('Must be at most 3 characters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('minValue validator', () => {
|
||||
it('returns error when below min', () => {
|
||||
const validator = minValue(10, 'Too small');
|
||||
expect(validator(5)).toBe('Too small');
|
||||
});
|
||||
|
||||
it('returns undefined when at or above min', () => {
|
||||
const validator = minValue(10, 'Too small');
|
||||
expect(validator(10)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for null/undefined', () => {
|
||||
const validator = minValue(10);
|
||||
expect(validator(undefined as unknown as number)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxValue validator', () => {
|
||||
it('returns error when above max', () => {
|
||||
const validator = maxValue(10, 'Too big');
|
||||
expect(validator(15)).toBe('Too big');
|
||||
});
|
||||
|
||||
it('returns undefined when at or below max', () => {
|
||||
const validator = maxValue(10, 'Too big');
|
||||
expect(validator(10)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pattern validator', () => {
|
||||
it('returns error when pattern does not match', () => {
|
||||
const validator = pattern(/^[a-z]+$/, 'Letters only');
|
||||
expect(validator('abc123')).toBe('Letters only');
|
||||
});
|
||||
|
||||
it('returns undefined when pattern matches', () => {
|
||||
const validator = pattern(/^[a-z]+$/, 'Letters only');
|
||||
expect(validator('abc')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('url validator', () => {
|
||||
it('returns error for invalid URL', () => {
|
||||
const validator = url('Invalid URL');
|
||||
expect(validator('not-a-url')).toBe('Invalid URL');
|
||||
});
|
||||
|
||||
it('returns undefined for valid URL', () => {
|
||||
const validator = url('Invalid URL');
|
||||
expect(validator('https://example.com')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty value', () => {
|
||||
const validator = url('Invalid URL');
|
||||
expect(validator('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('matches validator', () => {
|
||||
it('returns error when fields do not match', () => {
|
||||
const validator = matches('password', 'Must match');
|
||||
expect(validator('abc', { password: 'xyz' })).toBe('Must match');
|
||||
});
|
||||
|
||||
it('returns undefined when fields match', () => {
|
||||
const validator = matches('password', 'Must match');
|
||||
expect(validator('abc', { password: 'abc' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no form data', () => {
|
||||
const validator = matches('password');
|
||||
expect(validator('abc')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('phone validator', () => {
|
||||
it('returns error for invalid phone', () => {
|
||||
const validator = phone('Invalid phone');
|
||||
expect(validator('abc')).toBe('Invalid phone');
|
||||
});
|
||||
|
||||
it('returns undefined for valid phone', () => {
|
||||
const validator = phone('Invalid phone');
|
||||
// Use a phone format that matches the regex: /^[+]?[(]?[0-9]{1,4}[)]?[-\s./0-9]*$/
|
||||
expect(validator('+15551234567')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty value', () => {
|
||||
const validator = phone('Invalid phone');
|
||||
expect(validator('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
248
frontend/src/hooks/__tests__/useLocations.test.ts
Normal file
248
frontend/src/hooks/__tests__/useLocations.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
useLocations,
|
||||
useLocation,
|
||||
useCreateLocation,
|
||||
useUpdateLocation,
|
||||
useDeleteLocation,
|
||||
useSetPrimaryLocation,
|
||||
useSetLocationActive,
|
||||
} from '../useLocations';
|
||||
import apiClient from '../../api/client';
|
||||
|
||||
// Create wrapper
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
};
|
||||
|
||||
describe('useLocations hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useLocations', () => {
|
||||
it('fetches locations and returns data', async () => {
|
||||
const mockLocations = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Main Office',
|
||||
city: 'Denver',
|
||||
state: 'CO',
|
||||
is_active: true,
|
||||
is_primary: true,
|
||||
display_order: 0,
|
||||
resource_count: 5,
|
||||
service_count: 10,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Branch Office',
|
||||
city: 'Boulder',
|
||||
state: 'CO',
|
||||
is_active: true,
|
||||
is_primary: false,
|
||||
display_order: 1,
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockLocations });
|
||||
|
||||
const { result } = renderHook(() => useLocations(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/locations/');
|
||||
expect(result.current.data).toHaveLength(2);
|
||||
expect(result.current.data?.[0]).toEqual(expect.objectContaining({
|
||||
id: 1,
|
||||
name: 'Main Office',
|
||||
is_primary: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('fetches all locations when includeInactive is true', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
renderHook(() => useLocations({ includeInactive: true }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/locations/?include_inactive=true');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLocation', () => {
|
||||
it('fetches a single location by id', async () => {
|
||||
const mockLocation = {
|
||||
id: 1,
|
||||
name: 'Main Office',
|
||||
is_active: true,
|
||||
is_primary: true,
|
||||
display_order: 0,
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockLocation });
|
||||
|
||||
const { result } = renderHook(() => useLocation(1), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/locations/1/');
|
||||
expect(result.current.data?.name).toBe('Main Office');
|
||||
});
|
||||
|
||||
it('does not fetch when id is undefined', async () => {
|
||||
renderHook(() => useLocation(undefined), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateLocation', () => {
|
||||
it('creates location with correct data', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
|
||||
|
||||
const { result } = renderHook(() => useCreateLocation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
name: 'New Location',
|
||||
city: 'Denver',
|
||||
state: 'CO',
|
||||
});
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/locations/', {
|
||||
name: 'New Location',
|
||||
city: 'Denver',
|
||||
state: 'CO',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateLocation', () => {
|
||||
it('updates location with mapped fields', async () => {
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
|
||||
|
||||
const { result } = renderHook(() => useUpdateLocation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
updates: {
|
||||
name: 'Updated Office',
|
||||
city: 'Boulder',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/locations/1/', {
|
||||
name: 'Updated Office',
|
||||
city: 'Boulder',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteLocation', () => {
|
||||
it('deletes location by id', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useDeleteLocation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(1);
|
||||
});
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/locations/1/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSetPrimaryLocation', () => {
|
||||
it('sets location as primary', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_primary: true } });
|
||||
|
||||
const { result } = renderHook(() => useSetPrimaryLocation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(1);
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_primary/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSetLocationActive', () => {
|
||||
it('activates location', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_active: true } });
|
||||
|
||||
const { result } = renderHook(() => useSetLocationActive(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ id: 1, isActive: true });
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_active/', {
|
||||
is_active: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('deactivates location', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_active: false } });
|
||||
|
||||
const { result } = renderHook(() => useSetLocationActive(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ id: 1, isActive: false });
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_active/', {
|
||||
is_active: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,9 @@ describe('useResources hooks', () => {
|
||||
maxConcurrentEvents: 2,
|
||||
savedLaneCount: undefined,
|
||||
userCanEditSchedule: false,
|
||||
locationId: null,
|
||||
locationName: null,
|
||||
isMobile: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
372
frontend/src/hooks/useBillingPlans.ts
Normal file
372
frontend/src/hooks/useBillingPlans.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Billing Plans Hooks
|
||||
*
|
||||
* Provides access to the billing system's plans, features, and add-ons.
|
||||
* Used by platform admin for managing tenant subscriptions.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
// Feature from billing system - the SINGLE SOURCE OF TRUTH
|
||||
export interface BillingFeature {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
feature_type: 'boolean' | 'integer';
|
||||
// Dynamic feature management
|
||||
category: 'limits' | 'payments' | 'communication' | 'customization' | 'plugins' | 'advanced' | 'scheduling' | 'enterprise';
|
||||
tenant_field_name: string; // Corresponding field on Tenant model
|
||||
display_order: number;
|
||||
is_overridable: boolean;
|
||||
depends_on: number | null; // ID of parent feature
|
||||
depends_on_code: string | null; // Code of parent feature (for convenience)
|
||||
}
|
||||
|
||||
// Category metadata for display
|
||||
export const FEATURE_CATEGORY_META: Record<BillingFeature['category'], { label: string; order: number }> = {
|
||||
limits: { label: 'Limits', order: 0 },
|
||||
payments: { label: 'Payments & Revenue', order: 1 },
|
||||
communication: { label: 'Communication', order: 2 },
|
||||
customization: { label: 'Customization', order: 3 },
|
||||
plugins: { label: 'Plugins & Automation', order: 4 },
|
||||
advanced: { label: 'Advanced Features', order: 5 },
|
||||
scheduling: { label: 'Scheduling', order: 6 },
|
||||
enterprise: { label: 'Enterprise & Security', order: 7 },
|
||||
};
|
||||
|
||||
// Plan feature with value
|
||||
export interface BillingPlanFeature {
|
||||
id: number;
|
||||
feature: BillingFeature;
|
||||
bool_value: boolean | null;
|
||||
int_value: number | null;
|
||||
value: boolean | number | null;
|
||||
}
|
||||
|
||||
// Plan (logical grouping)
|
||||
export interface BillingPlan {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
display_order: number;
|
||||
is_active: boolean;
|
||||
max_pages: number;
|
||||
allow_custom_domains: boolean;
|
||||
max_custom_domains: number;
|
||||
}
|
||||
|
||||
// Plan version (specific offer with pricing and features)
|
||||
export interface BillingPlanVersion {
|
||||
id: number;
|
||||
plan: BillingPlan;
|
||||
version: number;
|
||||
name: string;
|
||||
is_public: boolean;
|
||||
is_legacy: boolean;
|
||||
starts_at: string | null;
|
||||
ends_at: string | null;
|
||||
price_monthly_cents: number;
|
||||
price_yearly_cents: number;
|
||||
transaction_fee_percent: string;
|
||||
transaction_fee_fixed_cents: number;
|
||||
trial_days: number;
|
||||
sms_price_per_message_cents: number;
|
||||
masked_calling_price_per_minute_cents: number;
|
||||
proxy_number_monthly_fee_cents: number;
|
||||
default_auto_reload_enabled: boolean;
|
||||
default_auto_reload_threshold_cents: number;
|
||||
default_auto_reload_amount_cents: number;
|
||||
is_most_popular: boolean;
|
||||
show_price: boolean;
|
||||
marketing_features: string[];
|
||||
stripe_product_id: string;
|
||||
stripe_price_id_monthly: string;
|
||||
stripe_price_id_yearly: string;
|
||||
is_available: boolean;
|
||||
features: BillingPlanFeature[];
|
||||
subscriber_count?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Plan with all versions
|
||||
export interface BillingPlanWithVersions {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
display_order: number;
|
||||
is_active: boolean;
|
||||
max_pages: number;
|
||||
allow_custom_domains: boolean;
|
||||
max_custom_domains: number;
|
||||
versions: BillingPlanVersion[];
|
||||
active_version: BillingPlanVersion | null;
|
||||
total_subscribers: number;
|
||||
}
|
||||
|
||||
// Add-on product
|
||||
export interface BillingAddOn {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price_monthly_cents: number;
|
||||
price_one_time_cents: number;
|
||||
stripe_product_id: string;
|
||||
stripe_price_id: string;
|
||||
is_stackable: boolean;
|
||||
is_active: boolean;
|
||||
features: BillingPlanFeature[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get all billing plans with their versions (admin view)
|
||||
*/
|
||||
export const useBillingPlans = () => {
|
||||
return useQuery<BillingPlanWithVersions[]>({
|
||||
queryKey: ['billingPlans'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/billing/admin/plans/');
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get the public plan catalog (available versions only)
|
||||
*/
|
||||
export const useBillingPlanCatalog = () => {
|
||||
return useQuery<BillingPlanVersion[]>({
|
||||
queryKey: ['billingPlanCatalog'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/billing/plans/');
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get all features
|
||||
*/
|
||||
export const useBillingFeatures = () => {
|
||||
return useQuery<BillingFeature[]>({
|
||||
queryKey: ['billingFeatures'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/billing/admin/features/');
|
||||
return data;
|
||||
},
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes (features rarely change)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get available add-ons
|
||||
*/
|
||||
export const useBillingAddOns = () => {
|
||||
return useQuery<BillingAddOn[]>({
|
||||
queryKey: ['billingAddOns'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/billing/addons/');
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get a feature value from a plan version's features array
|
||||
*/
|
||||
export function getFeatureValue(
|
||||
features: BillingPlanFeature[],
|
||||
featureCode: string
|
||||
): boolean | number | null {
|
||||
const feature = features.find(f => f.feature.code === featureCode);
|
||||
if (!feature) return null;
|
||||
return feature.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a boolean feature value (defaults to false if not found)
|
||||
*/
|
||||
export function getBooleanFeature(
|
||||
features: BillingPlanFeature[],
|
||||
featureCode: string
|
||||
): boolean {
|
||||
const value = getFeatureValue(features, featureCode);
|
||||
return typeof value === 'boolean' ? value : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an integer feature value (defaults to 0 if not found, null means unlimited)
|
||||
*/
|
||||
export function getIntegerFeature(
|
||||
features: BillingPlanFeature[],
|
||||
featureCode: string
|
||||
): number | null {
|
||||
const value = getFeatureValue(features, featureCode);
|
||||
if (value === null || value === undefined) return null; // Unlimited
|
||||
return typeof value === 'number' ? value : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a plan version's features to a flat object for form state
|
||||
* Maps feature codes to their values
|
||||
*/
|
||||
export function planFeaturesToFormState(
|
||||
planVersion: BillingPlanVersion | null
|
||||
): Record<string, boolean | number | null> {
|
||||
if (!planVersion) return {};
|
||||
|
||||
const state: Record<string, boolean | number | null> = {};
|
||||
|
||||
for (const pf of planVersion.features) {
|
||||
state[pf.feature.code] = pf.value;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map old tier names to new plan codes
|
||||
*/
|
||||
export const TIER_TO_PLAN_CODE: Record<string, string> = {
|
||||
FREE: 'free',
|
||||
STARTER: 'starter',
|
||||
GROWTH: 'growth',
|
||||
PROFESSIONAL: 'pro', // Old name -> new code
|
||||
PRO: 'pro',
|
||||
ENTERPRISE: 'enterprise',
|
||||
};
|
||||
|
||||
/**
|
||||
* Map new plan codes to display names
|
||||
*/
|
||||
export const PLAN_CODE_TO_NAME: Record<string, string> = {
|
||||
free: 'Free',
|
||||
starter: 'Starter',
|
||||
growth: 'Growth',
|
||||
pro: 'Pro',
|
||||
enterprise: 'Enterprise',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the active plan version for a given plan code
|
||||
*/
|
||||
export function getActivePlanVersion(
|
||||
plans: BillingPlanWithVersions[],
|
||||
planCode: string
|
||||
): BillingPlanVersion | null {
|
||||
const plan = plans.find(p => p.code === planCode);
|
||||
return plan?.active_version || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature code mapping from old permission names to new feature codes
|
||||
*/
|
||||
export const PERMISSION_TO_FEATURE_CODE: Record<string, string> = {
|
||||
// Communication
|
||||
can_use_sms_reminders: 'sms_enabled',
|
||||
can_use_masked_phone_numbers: 'masked_calling_enabled',
|
||||
|
||||
// Platform
|
||||
can_api_access: 'api_access',
|
||||
can_use_custom_domain: 'custom_domain',
|
||||
can_white_label: 'white_label',
|
||||
|
||||
// Features
|
||||
can_accept_payments: 'payment_processing',
|
||||
can_use_mobile_app: 'mobile_app_access',
|
||||
advanced_reporting: 'advanced_reporting',
|
||||
priority_support: 'priority_support',
|
||||
dedicated_support: 'dedicated_account_manager',
|
||||
|
||||
// Limits (integer features)
|
||||
max_users: 'max_users',
|
||||
max_resources: 'max_resources',
|
||||
max_locations: 'max_locations',
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert plan features to legacy permission format for backward compatibility
|
||||
*/
|
||||
export function planFeaturesToLegacyPermissions(
|
||||
planVersion: BillingPlanVersion | null
|
||||
): Record<string, boolean | number> {
|
||||
if (!planVersion) return {};
|
||||
|
||||
const permissions: Record<string, boolean | number> = {};
|
||||
|
||||
// Map features to legacy permission names
|
||||
for (const pf of planVersion.features) {
|
||||
const code = pf.feature.code;
|
||||
const value = pf.value;
|
||||
|
||||
// Direct feature code
|
||||
permissions[code] = value as boolean | number;
|
||||
|
||||
// Also add with legacy naming for backward compatibility
|
||||
switch (code) {
|
||||
case 'sms_enabled':
|
||||
permissions.can_use_sms_reminders = value as boolean;
|
||||
break;
|
||||
case 'masked_calling_enabled':
|
||||
permissions.can_use_masked_phone_numbers = value as boolean;
|
||||
break;
|
||||
case 'api_access':
|
||||
permissions.can_api_access = value as boolean;
|
||||
permissions.can_connect_to_api = value as boolean;
|
||||
break;
|
||||
case 'custom_domain':
|
||||
permissions.can_use_custom_domain = value as boolean;
|
||||
break;
|
||||
case 'white_label':
|
||||
permissions.can_white_label = value as boolean;
|
||||
break;
|
||||
case 'remove_branding':
|
||||
permissions.can_white_label = permissions.can_white_label || (value as boolean);
|
||||
break;
|
||||
case 'payment_processing':
|
||||
permissions.can_accept_payments = value as boolean;
|
||||
break;
|
||||
case 'mobile_app_access':
|
||||
permissions.can_use_mobile_app = value as boolean;
|
||||
break;
|
||||
case 'advanced_reporting':
|
||||
permissions.advanced_reporting = value as boolean;
|
||||
break;
|
||||
case 'priority_support':
|
||||
permissions.priority_support = value as boolean;
|
||||
break;
|
||||
case 'dedicated_account_manager':
|
||||
permissions.dedicated_support = value as boolean;
|
||||
break;
|
||||
case 'integrations_enabled':
|
||||
permissions.can_use_webhooks = value as boolean;
|
||||
permissions.can_use_calendar_sync = value as boolean;
|
||||
break;
|
||||
case 'team_permissions':
|
||||
permissions.can_require_2fa = value as boolean;
|
||||
break;
|
||||
case 'audit_logs':
|
||||
permissions.can_download_logs = value as boolean;
|
||||
break;
|
||||
case 'custom_branding':
|
||||
permissions.can_customize_booking_page = value as boolean;
|
||||
break;
|
||||
case 'recurring_appointments':
|
||||
permissions.can_book_repeated_events = value as boolean;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return permissions;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
153
frontend/src/hooks/useLocations.ts
Normal file
153
frontend/src/hooks/useLocations.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Location Management Hooks
|
||||
*
|
||||
* Provides hooks for managing business locations in a multi-location setup.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
import { Location } from '../types';
|
||||
|
||||
interface LocationFilters {
|
||||
includeInactive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch locations with optional inactive filter
|
||||
*/
|
||||
export const useLocations = (filters?: LocationFilters) => {
|
||||
return useQuery<Location[]>({
|
||||
queryKey: ['locations', filters],
|
||||
queryFn: async () => {
|
||||
let url = '/locations/';
|
||||
if (filters?.includeInactive) {
|
||||
url += '?include_inactive=true';
|
||||
}
|
||||
const { data } = await apiClient.get(url);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single location by ID
|
||||
*/
|
||||
export const useLocation = (id: number | undefined) => {
|
||||
return useQuery<Location>({
|
||||
queryKey: ['locations', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`/locations/${id}/`);
|
||||
return data;
|
||||
},
|
||||
enabled: id !== undefined,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create a new location
|
||||
*/
|
||||
export const useCreateLocation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (locationData: Partial<Location>) => {
|
||||
const { data } = await apiClient.post('/locations/', locationData);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['locations'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a location
|
||||
*/
|
||||
export const useUpdateLocation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, updates }: { id: number; updates: Partial<Location> }) => {
|
||||
const { data } = await apiClient.patch(`/locations/${id}/`, updates);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['locations'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a location
|
||||
*/
|
||||
export const useDeleteLocation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiClient.delete(`/locations/${id}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['locations'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to set a location as primary
|
||||
*/
|
||||
export const useSetPrimaryLocation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await apiClient.post(`/locations/${id}/set_primary/`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['locations'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to activate or deactivate a location
|
||||
*/
|
||||
export const useSetLocationActive = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, isActive }: { id: number; isActive: boolean }) => {
|
||||
const { data } = await apiClient.post(`/locations/${id}/set_active/`, {
|
||||
is_active: isActive,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['locations'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get only active locations (convenience wrapper)
|
||||
*/
|
||||
export const useActiveLocations = () => {
|
||||
return useLocations();
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get all locations including inactive
|
||||
*/
|
||||
export const useAllLocations = () => {
|
||||
return useLocations({ includeInactive: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get the primary location
|
||||
*/
|
||||
export const usePrimaryLocation = () => {
|
||||
const { data: locations, ...rest } = useLocations();
|
||||
const primaryLocation = locations?.find(loc => loc.is_primary);
|
||||
return { data: primaryLocation, locations, ...rest };
|
||||
};
|
||||
@@ -93,6 +93,7 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
|
||||
pos_system: 'POS System',
|
||||
mobile_app: 'Mobile App',
|
||||
contracts: 'Contracts',
|
||||
multi_location: 'Multiple Locations',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -115,4 +116,5 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
|
||||
pos_system: 'Process in-person payments with Point of Sale',
|
||||
mobile_app: 'Access SmoothSchedule on mobile devices',
|
||||
contracts: 'Create and manage contracts with customers',
|
||||
multi_location: 'Manage multiple business locations with separate resources and services',
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
156
frontend/src/hooks/usePublicPlans.ts
Normal file
156
frontend/src/hooks/usePublicPlans.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Public Plans Hook
|
||||
*
|
||||
* Fetches public plans from the billing API for the marketing pricing page.
|
||||
* This endpoint doesn't require authentication.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { API_BASE_URL } from '../api/config';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface Feature {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
feature_type: 'boolean' | 'integer';
|
||||
}
|
||||
|
||||
export interface PlanFeature {
|
||||
id: number;
|
||||
feature: Feature;
|
||||
bool_value: boolean | null;
|
||||
int_value: number | null;
|
||||
value: boolean | number | null;
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
display_order: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface PublicPlanVersion {
|
||||
id: number;
|
||||
plan: Plan;
|
||||
version: number;
|
||||
name: string;
|
||||
is_public: boolean;
|
||||
is_legacy: boolean;
|
||||
price_monthly_cents: number;
|
||||
price_yearly_cents: number;
|
||||
transaction_fee_percent: string;
|
||||
transaction_fee_fixed_cents: number;
|
||||
trial_days: number;
|
||||
is_most_popular: boolean;
|
||||
show_price: boolean;
|
||||
marketing_features: string[];
|
||||
is_available: boolean;
|
||||
features: PlanFeature[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Client (no auth required)
|
||||
// =============================================================================
|
||||
|
||||
const publicApiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// API Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fetch public plans from the billing catalog.
|
||||
* No authentication required.
|
||||
*/
|
||||
export const fetchPublicPlans = async (): Promise<PublicPlanVersion[]> => {
|
||||
const response = await publicApiClient.get<PublicPlanVersion[]>('/billing/plans/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Hook
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch public plans for the pricing page.
|
||||
*/
|
||||
export const usePublicPlans = () => {
|
||||
return useQuery({
|
||||
queryKey: ['publicPlans'],
|
||||
queryFn: fetchPublicPlans,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime)
|
||||
});
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Format price from cents to dollars with currency symbol.
|
||||
*/
|
||||
export const formatPrice = (cents: number): string => {
|
||||
if (cents === 0) return '$0';
|
||||
return `$${(cents / 100).toFixed(0)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a feature value from a plan version by feature code.
|
||||
*/
|
||||
export const getPlanFeatureValue = (
|
||||
planVersion: PublicPlanVersion,
|
||||
featureCode: string
|
||||
): boolean | number | null => {
|
||||
const planFeature = planVersion.features.find(
|
||||
(pf) => pf.feature.code === featureCode
|
||||
);
|
||||
return planFeature?.value ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a plan has a boolean feature enabled.
|
||||
*/
|
||||
export const hasPlanFeature = (
|
||||
planVersion: PublicPlanVersion,
|
||||
featureCode: string
|
||||
): boolean => {
|
||||
const value = getPlanFeatureValue(planVersion, featureCode);
|
||||
return value === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an integer limit from a plan version.
|
||||
* Returns 0 if not set (unlimited) or the actual limit.
|
||||
*/
|
||||
export const getPlanLimit = (
|
||||
planVersion: PublicPlanVersion,
|
||||
featureCode: string
|
||||
): number => {
|
||||
const value = getPlanFeatureValue(planVersion, featureCode);
|
||||
return typeof value === 'number' ? value : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a limit value for display.
|
||||
* 0 means unlimited.
|
||||
*/
|
||||
export const formatLimit = (value: number): string => {
|
||||
if (value === 0) return 'Unlimited';
|
||||
return value.toLocaleString();
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user