Add global navigation search, cancellation policies, and UI improvements

- Add global search in top bar for navigating to dashboard pages
- Add cancellation policy settings (window hours, late fee, deposit refund)
- Display booking policies on customer confirmation page
- Filter API tokens by sandbox/live mode
- Widen settings layout and full-width site builder
- Add help documentation search with OpenAI integration
- Add blocked time ranges API for calendar visualization
- Update business hours settings with holiday management

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-25 23:39:07 -05:00
parent 8391ecbf88
commit 416cd7059b
174 changed files with 31835 additions and 4921 deletions

View File

@@ -0,0 +1,277 @@
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';
import {
useServiceAddons,
usePublicServiceAddons,
useServiceAddon,
useCreateServiceAddon,
useUpdateServiceAddon,
useDeleteServiceAddon,
useToggleServiceAddon,
useReorderServiceAddons,
} from '../useServiceAddons';
import apiClient from '../../api/client';
vi.mock('../../api/client');
const mockAddon = {
id: 1,
service: 1,
resource: 2,
resource_name: 'John Doe',
resource_type: 'STAFF',
name: 'Extra Massage',
description: 'Additional massage time',
display_order: 0,
price: '25.00',
price_cents: 2500,
duration_mode: 'SEQUENTIAL' as const,
additional_duration: 30,
is_active: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
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('useServiceAddons hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useServiceAddons', () => {
it('fetches addons for a service', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [mockAddon] });
const { result } = renderHook(() => useServiceAddons(1), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/service-addons/', {
params: { service: 1, show_inactive: 'true' },
});
expect(result.current.data).toHaveLength(1);
expect(result.current.data?.[0].name).toBe('Extra Massage');
});
it('transforms price correctly', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [mockAddon] });
const { result } = renderHook(() => useServiceAddons(1), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.[0].price).toBe(25);
expect(result.current.data?.[0].price_cents).toBe(2500);
});
it('does not fetch when serviceId is null', () => {
const { result } = renderHook(() => useServiceAddons(null), { wrapper: createWrapper() });
expect(result.current.fetchStatus).toBe('idle');
expect(apiClient.get).not.toHaveBeenCalled();
});
it('handles string service IDs', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
const { result } = renderHook(() => useServiceAddons('123'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/service-addons/', {
params: { service: '123', show_inactive: 'true' },
});
});
});
describe('usePublicServiceAddons', () => {
it('fetches public addons for a service', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: { addons: [mockAddon], count: 1 },
});
const { result } = renderHook(() => usePublicServiceAddons(1), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/public/service-addons/', {
params: { service_id: 1 },
});
expect(result.current.data?.addons).toHaveLength(1);
expect(result.current.data?.count).toBe(1);
});
it('does not fetch when serviceId is null', () => {
const { result } = renderHook(() => usePublicServiceAddons(null), { wrapper: createWrapper() });
expect(result.current.fetchStatus).toBe('idle');
expect(apiClient.get).not.toHaveBeenCalled();
});
});
describe('useServiceAddon', () => {
it('fetches a single addon by ID', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockAddon });
const { result } = renderHook(() => useServiceAddon(1), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/service-addons/1/');
expect(result.current.data?.name).toBe('Extra Massage');
});
it('does not fetch when id is null', () => {
const { result } = renderHook(() => useServiceAddon(null), { wrapper: createWrapper() });
expect(result.current.fetchStatus).toBe('idle');
expect(apiClient.get).not.toHaveBeenCalled();
});
});
describe('useCreateServiceAddon', () => {
it('creates a new addon', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockAddon });
const { result } = renderHook(() => useCreateServiceAddon(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
service: 1,
resource: 2,
name: 'Extra Massage',
price_cents: 2500,
duration_mode: 'SEQUENTIAL',
additional_duration: 30,
});
});
expect(apiClient.post).toHaveBeenCalledWith('/service-addons/', {
service: 1,
resource: 2,
name: 'Extra Massage',
price_cents: 2500,
duration_mode: 'SEQUENTIAL',
additional_duration: 30,
});
});
it('creates addon without resource (price-only addon)', async () => {
const priceOnlyAddon = { ...mockAddon, resource: null, resource_name: null };
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: priceOnlyAddon });
const { result } = renderHook(() => useCreateServiceAddon(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
service: 1,
resource: null,
name: 'Gift Wrapping',
price_cents: 500,
duration_mode: 'PARALLEL',
});
});
expect(apiClient.post).toHaveBeenCalledWith('/service-addons/', expect.objectContaining({
resource: null,
duration_mode: 'PARALLEL',
}));
});
});
describe('useUpdateServiceAddon', () => {
it('updates an addon', async () => {
const updatedAddon = { ...mockAddon, name: 'Updated Addon' };
vi.mocked(apiClient.patch).mockResolvedValueOnce({ data: updatedAddon });
const { result } = renderHook(() => useUpdateServiceAddon(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
id: 1,
updates: { name: 'Updated Addon' },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/service-addons/1/', { name: 'Updated Addon' });
});
it('updates price_cents', async () => {
const updatedAddon = { ...mockAddon, price_cents: 3000 };
vi.mocked(apiClient.patch).mockResolvedValueOnce({ data: updatedAddon });
const { result } = renderHook(() => useUpdateServiceAddon(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
id: 1,
updates: { price_cents: 3000 },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/service-addons/1/', { price_cents: 3000 });
});
});
describe('useDeleteServiceAddon', () => {
it('deletes an addon', async () => {
vi.mocked(apiClient.delete).mockResolvedValueOnce({});
const { result } = renderHook(() => useDeleteServiceAddon(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ id: 1, serviceId: 1 });
});
expect(apiClient.delete).toHaveBeenCalledWith('/service-addons/1/');
});
});
describe('useToggleServiceAddon', () => {
it('toggles addon active status', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({
data: { ...mockAddon, is_active: false },
});
const { result } = renderHook(() => useToggleServiceAddon(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ id: 1, serviceId: 1 });
});
expect(apiClient.post).toHaveBeenCalledWith('/service-addons/1/toggle_active/');
});
});
describe('useReorderServiceAddons', () => {
it('reorders addons', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({
data: { success: true },
});
const { result } = renderHook(() => useReorderServiceAddons(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
serviceId: 1,
orderedIds: [3, 1, 2],
});
});
expect(apiClient.post).toHaveBeenCalledWith('/service-addons/reorder/', { order: [3, 1, 2] });
});
});
});