Skip to main content
The hooks.ts module provides React Query hooks for fetching Zenoti data. Each hook returns EIP-typed data and automatically falls back to seed data when the Zenoti connection is inactive, so the dashboard works identically in demo mode and live mode.

Overview

All hooks use the same pattern:
  1. Check Zenoti connection state from useZenotiStore
  2. If connected, fetch from Zenoti API and map to EIP types
  3. If disconnected, use seed data as placeholder
  4. Cache results with configurable stale times

Cache Strategy

Each hook has a different stale time based on data volatility:
hooks.ts:54-61
/** Centers rarely change — cache for 30 min */
const STALE_LONG = 30 * 60 * 1000

/** Appointments / metrics — cache for 5 min */
const STALE_MEDIUM = 5 * 60 * 1000

/** Clients — cache for 10 min */
const STALE_SHORT = 10 * 60 * 1000
Data TypeStale TimeRationale
Locations30 minRarely change
Services30 minCatalog updates infrequent
Appointments5 minReal-time scheduling changes
Daily Metrics5 minRevenue updates frequently
Clients10 minProfile updates moderately frequent

Locations (Centers)

useLocations()

Fetch all locations in the organization.
export function useLocations(): UseQueryResult<Location[]>
return
UseQueryResult<Location[]>
React Query result object with:
  • data — Array of EIP Location objects
  • isLoading — Loading state
  • error — Error object if fetch failed
  • refetch — Manual refetch function
Features:
  • Query key: ['zenoti', 'locations']
  • Stale time: 30 minutes
  • Fallback: seedLocations from @/data/seed
  • Enabled: Only when isConnected === true
Example:
import { useLocations } from '@/integrations/zenoti'

function LocationSelector() {
  const { data: locations, isLoading, error } = useLocations()

  if (isLoading) return <Spinner />
  if (error) return <Error message={error.message} />

  return (
    <select>
      {locations.map(loc => (
        <option key={loc.id} value={loc.id}>
          {loc.name} ({loc.city}, {loc.state})
        </option>
      ))}
    </select>
  )
}

Services

useServices()

Fetch services for a specific center.
export function useServices(centerId?: string): UseQueryResult<Service[]>
centerId
string
Center ID to fetch services for. If omitted, returns seed data.
return
UseQueryResult<Service[]>
React Query result with array of EIP Service objects
Features:
  • Query key: ['zenoti', 'services', centerId]
  • Stale time: 30 minutes
  • Fallback: seedServices
  • Enabled: Only when connected AND centerId is provided
Example:
import { useServices } from '@/integrations/zenoti'

function ServiceList({ centerId }: { centerId: string }) {
  const { data: services, isLoading } = useServices(centerId)

  if (isLoading) return <Spinner />

  // Group by category
  const byCategory = services?.reduce((acc, s) => {
    acc[s.category] = [...(acc[s.category] || []), s]
    return acc
  }, {} as Record<string, Service[]>)

  return (
    <div>
      {Object.entries(byCategory).map(([category, services]) => (
        <div key={category}>
          <h3>{category}</h3>
          {services.map(s => (
            <ServiceCard key={s.id} service={s} />
          ))}
        </div>
      ))}
    </div>
  )
}

Appointments

useAppointments()

Fetch appointments for a date range, optionally filtered by center.
export function useAppointments(
  opts?: UseAppointmentsOpts
): UseQueryResult<Appointment[]>
opts
UseAppointmentsOpts
Query options object
opts.centerId
string
Center ID to filter by. If omitted, fetches from all centers.
opts.daysBack
number
default:30
Number of days back from today to fetch
return
UseQueryResult<Appointment[]>
React Query result with array of EIP Appointment objects
Features:
  • Query key: ['zenoti', 'appointments', centerId ?? 'all', startDate]
  • Stale time: 5 minutes
  • Fallback: seedAppointments
  • Multi-center support: Automatically aggregates all centers when centerId is omitted
Implementation (hooks.ts:112-124):
export function useAppointments(opts: UseAppointmentsOpts = {}) {
  const connected = useZenotiStore((s) => s.isConnected)
  const { centerId, daysBack = 30 } = opts
  const startDate = daysAgoISO(daysBack)
  const endDate = todayISO()

  return useQuery<Appointment[]>({
    queryKey: ['zenoti', 'appointments', centerId ?? 'all', startDate],
    queryFn: async () => {
      const raw = centerId
        ? await api.listAppointments({ centerId, startDate, endDate })
        : await api.listAppointmentsAllCenters(startDate, endDate)
      return mapAppointments(raw)
    },
    enabled: connected,
    staleTime: STALE_MEDIUM,
    placeholderData: seedAppointments,
  })
}
Examples:
import { useAppointments } from '@/integrations/zenoti'

function CenterSchedule({ centerId }: { centerId: string }) {
  const { data: appointments, isLoading } = useAppointments({ centerId })

  if (isLoading) return <Spinner />

  return <ScheduleGrid appointments={appointments} />
}

Clients (Guests)

useClients()

Fetch clients for a specific center.
export function useClients(centerId?: string): UseQueryResult<Client[]>
centerId
string
Center ID to fetch clients for. If omitted, returns seed data.
return
UseQueryResult<Client[]>
React Query result with array of EIP Client objects
Features:
  • Query key: ['zenoti', 'clients', centerId ?? 'all']
  • Stale time: 10 minutes
  • Fallback: seedClients
  • Max results: 200 (configurable via endpoint)
Example:
import { useClients } from '@/integrations/zenoti'

function ClientList({ centerId }: { centerId: string }) {
  const { data: clients, isLoading } = useClients(centerId)

  if (isLoading) return <Spinner />

  // Sort by CLV descending
  const topClients = [...(clients ?? [])]
    .sort((a, b) => b.clv - a.clv)
    .slice(0, 10)

  return (
    <div>
      <h2>Top 10 Clients by CLV</h2>
      {topClients.map(client => (
        <ClientCard key={client.id} client={client} />
      ))}
    </div>
  )
}

Daily Metrics (Sales Reports)

useDailyMetrics()

Fetch daily sales metrics for a date range.
export function useDailyMetrics(
  opts?: UseDailyMetricsOpts
): UseQueryResult<DailyMetrics[]>
opts
UseDailyMetricsOpts
Query options object
opts.centerId
string
Center ID to filter by. If omitted, aggregates all centers.
opts.daysBack
number
default:30
Number of days back from today
return
UseQueryResult<DailyMetrics[]>
React Query result with array of EIP DailyMetrics objects (one per day)
Features:
  • Query key: ['zenoti', 'dailyMetrics', centerId ?? 'all', startDate]
  • Stale time: 5 minutes
  • Fallback: seedDailyMetrics
  • Multi-center aggregation: Combines all centers when centerId is omitted
Implementation (hooks.ts:158-177):
export function useDailyMetrics(opts: UseDailyMetricsOpts = {}) {
  const connected = useZenotiStore((s) => s.isConnected)
  const { centerId, daysBack = 30 } = opts
  const startDate = daysAgoISO(daysBack)
  const endDate = todayISO()

  return useQuery<DailyMetrics[]>({
    queryKey: ['zenoti', 'dailyMetrics', centerId ?? 'all', startDate],
    queryFn: async () => {
      if (centerId) {
        const report = await api.getSalesReport({ centerId, startDate, endDate })
        return mapSalesReport(report)
      }
      // All centers — aggregate
      const reports = await api.getSalesReportsAllCenters(startDate, endDate)
      return reports.flatMap(mapSalesReport)
    },
    enabled: connected,
    staleTime: STALE_MEDIUM,
    placeholderData: seedDailyMetrics,
  })
}
Examples:
import { useDailyMetrics } from '@/integrations/zenoti'
import { LineChart } from '@/components/charts'

function RevenueChart({ centerId }: { centerId: string }) {
  const { data: metrics } = useDailyMetrics({ centerId, daysBack: 30 })

  const chartData = metrics?.map(m => ({
    date: m.date,
    revenue: m.revenue,
  })) ?? []

  return <LineChart data={chartData} xKey="date" yKey="revenue" />
}

Connection Test

useZenotiConnectionTest()

Lightweight query to verify Zenoti credentials. Used by the Settings page “Test Connection” button.
export function useZenotiConnectionTest(): UseQueryResult<{
  success: boolean
  centerCount: number
  centerNames: string[]
}>
return
UseQueryResult
React Query result with connection test data:
  • success — Always true if query succeeds
  • centerCount — Number of centers found
  • centerNames — Array of center names
Features:
  • Query key: ['zenoti', 'connectionTest']
  • Enabled: false (only runs when explicitly refetched)
  • Retry: false (fails fast)
Implementation (hooks.ts:186-200):
export function useZenotiConnectionTest() {
  return useQuery({
    queryKey: ['zenoti', 'connectionTest'],
    queryFn: async () => {
      const centers = await api.listCenters()
      return {
        success: true,
        centerCount: centers.length,
        centerNames: centers.map((c) => c.name),
      }
    },
    enabled: false, // Only run when explicitly refetched
    retry: false,
  })
}
Example:
import { useZenotiConnectionTest } from '@/integrations/zenoti'

function SettingsPage() {
  const { refetch, data, error, isLoading } = useZenotiConnectionTest()

  const handleTestConnection = async () => {
    const result = await refetch()
    
    if (result.isSuccess) {
      toast.success(`Connected to ${result.data.centerCount} centers`)
      console.log('Centers:', result.data.centerNames)
    } else {
      toast.error(`Connection failed: ${result.error.message}`)
    }
  }

  return (
    <div>
      <h2>Zenoti Connection</h2>
      <button onClick={handleTestConnection} disabled={isLoading}>
        {isLoading ? 'Testing...' : 'Test Connection'}
      </button>
      
      {data && (
        <div className="success">
          Connected to {data.centerCount} locations:
          <ul>
            {data.centerNames.map(name => <li key={name}>{name}</li>)}
          </ul>
        </div>
      )}
      
      {error && (
        <div className="error">
          Connection failed: {error.message}
        </div>
      )}
    </div>
  )
}

Hook Patterns

Demo Mode Fallback

All hooks automatically use seed data when disconnected:
const connected = useZenotiStore((s) => s.isConnected)

return useQuery({
  queryKey: ['zenoti', 'resource'],
  queryFn: async () => { /* fetch from API */ },
  enabled: connected, // Only fetch when connected
  placeholderData: seedData, // Use seed data otherwise
})

Manual Refetch

Force refresh data:
function RefreshButton() {
  const { refetch, isLoading } = useAppointments()

  return (
    <button onClick={() => refetch()} disabled={isLoading}>
      {isLoading ? 'Refreshing...' : 'Refresh'}
    </button>
  )
}

Dependent Queries

Fetch data based on previous query:
function ServiceSelector() {
  const { data: locations } = useLocations()
  const [selectedLocationId, setSelectedLocationId] = useState()
  
  // Only fetch services after location is selected
  const { data: services } = useServices(selectedLocationId)

  return (
    <div>
      <select onChange={(e) => setSelectedLocationId(e.target.value)}>
        {locations?.map(loc => <option value={loc.id}>{loc.name}</option>)}
      </select>
      
      {services && (
        <select>
          {services.map(s => <option value={s.id}>{s.name}</option>)}
        </select>
      )}
    </div>
  )
}

Error Handling

function DataComponent() {
  const { data, error, isLoading } = useAppointments()

  if (isLoading) return <Spinner />
  
  if (error) {
    return (
      <ErrorMessage>
        Failed to load appointments: {error.message}
      </ErrorMessage>
    )
  }

  return <AppointmentList appointments={data} />
}

Query Keys

All hooks use consistent query key patterns:
HookQuery Key Pattern
useLocations()['zenoti', 'locations']
useServices(centerId)['zenoti', 'services', centerId]
useAppointments(opts)['zenoti', 'appointments', centerId || 'all', startDate]
useClients(centerId)['zenoti', 'clients', centerId || 'all']
useDailyMetrics(opts)['zenoti', 'dailyMetrics', centerId || 'all', startDate]
useZenotiConnectionTest()['zenoti', 'connectionTest']
Invalidating cache:
import { useQueryClient } from '@tanstack/react-query'

function InvalidateButton() {
  const queryClient = useQueryClient()

  const handleInvalidate = () => {
    // Invalidate all Zenoti queries
    queryClient.invalidateQueries({ queryKey: ['zenoti'] })
    
    // Or invalidate specific resource
    queryClient.invalidateQueries({ queryKey: ['zenoti', 'appointments'] })
  }

  return <button onClick={handleInvalidate}>Invalidate Cache</button>
}

API Endpoints

Raw API methods used by hooks

Data Mappers

Transformation functions applied to query results

Error Handling

Handle query errors

Build docs developers (and LLMs) love