Skip to main content
The mappers.ts module provides functions that transform Zenoti API response types into the Etienne Intelligence Platform’s domain models. This allows the dashboard to consume Zenoti data through the same interfaces it uses for seed data.

Centers → Locations

mapCenter()

Transform a single Zenoti center into an EIP Location.
export function mapCenter(c: ZenotiCenter): Location
c
ZenotiCenter
required
Zenoti center object from API
return
Location
EIP location object with fields:
  • id — Center ID
  • name — Display name (falls back to name if display_name is empty)
  • city — City name
  • state — State code or name
  • rooms — Count of active rooms
  • providers — Provider count
Implementation (mappers.ts:27-36):
export function mapCenter(c: ZenotiCenter): Location {
  return {
    id: c.id,
    name: c.display_name || c.name,
    city: c.city,
    state: c.state?.code ?? c.state?.name ?? '',
    rooms: c.rooms?.filter((r) => r.is_active).length ?? 0,
    providers: c.provider_count ?? 0,
  }
}

mapCenters()

Transform an array of centers, filtering out inactive ones.
export function mapCenters(centers: ZenotiCenter[]): Location[]
centers
ZenotiCenter[]
required
Array of Zenoti centers
return
Location[]
Array of EIP locations (only active centers)
Example:
import { listCenters, mapCenters } from '@/integrations/zenoti'

const raw = await listCenters()
const locations = mapCenters(raw)

locations.forEach(loc => {
  console.log(`${loc.name}: ${loc.rooms} rooms, ${loc.providers} providers`)
})

Services

mapService()

Transform a Zenoti service into an EIP Service.
export function mapService(s: ZenotiService): Service
s
ZenotiService
required
Zenoti service object
return
Service
EIP service object with:
  • id — Service ID
  • name — Service name
  • price — Sales price
  • duration — Duration in minutes
  • category — EIP category ('Injectable' | 'Facial' | 'Laser' | 'Body')
Category mapping (mappers.ts:44-59):
const CATEGORY_MAP: Record<string, Service['category']> = {
  injectable: 'Injectable',
  injectables: 'Injectable',
  facial: 'Facial',
  facials: 'Facial',
  laser: 'Laser',
  body: 'Body',
  'body contouring': 'Body',
}

function inferServiceCategory(
  zenotiCategory: string,
): Service['category'] {
  const key = zenotiCategory.toLowerCase().trim()
  return CATEGORY_MAP[key] ?? 'Facial' // default fallback
}

mapServices()

Transform an array of services, filtering out inactive ones.
export function mapServices(services: ZenotiService[]): Service[]
services
ZenotiService[]
required
Array of Zenoti services
return
Service[]
Array of EIP services (only active)
Example:
import { listServices, mapServices } from '@/integrations/zenoti'

const raw = await listServices('center123')
const services = mapServices(raw)

// Group by category
const byCategory = services.reduce((acc, s) => {
  acc[s.category] = [...(acc[s.category] || []), s]
  return acc
}, {})

console.log('Injectable services:', byCategory.Injectable.length)

Appointments

mapAppointment()

Transform a Zenoti appointment into an EIP Appointment.
export function mapAppointment(a: ZenotiAppointment): Appointment
a
ZenotiAppointment
required
Zenoti appointment object
return
Appointment
EIP appointment with:
  • id — Appointment ID
  • clientName — Full name (first + last)
  • clientId — Guest ID
  • service — Service name
  • provider — Therapist full name
  • locationId — Center ID
  • date — ISO date (YYYY-MM-DD)
  • startTime — HH:MM format
  • endTime — HH:MM format
  • status — EIP status ('confirmed' | 'completed' | 'no_show' | 'cancelled')
  • bookedBy — Booking source ('staff' | 'online' | 'ai')
  • noShowRisk — Always 'low' (EIP AI calculates separately)
  • room — Room number (always 1 for now)
  • revenue — Service price

Status Mapping

Zenoti status codes → EIP status strings (mappers.ts:86-103):
function mapAppointmentStatus(
  status: number,
): Appointment['status'] {
  switch (status) {
    case 0:  // Booked
    case 1:  // Confirmed
    case 2:  // Checked-in
      return 'confirmed'
    case 4:  // Completed
      return 'completed'
    case 10: // No-show
      return 'no_show'
    case -1: // Cancelled
      return 'cancelled'
    default:
      return 'confirmed'
  }
}
Zenoti StatusCodeEIP Status
New / Booked0confirmed
Confirmed1confirmed
Checked-in2confirmed
Completed4completed
No-show10no_show
Cancelled-1cancelled

Booking Source Mapping

Zenoti booking source → EIP bookedBy (mappers.ts:110-123):
function mapBookingSource(source: number): Appointment['bookedBy'] {
  switch (source) {
    case 0:  // Walk-in
    case 1:  // Phone
      return 'staff'
    case 2:  // Online
    case 3:  // App
      return 'online'
    case 4:  // API
      return 'ai'
    default:
      return 'staff'
  }
}
Zenoti SourceCodeEIP bookedBy
Walk-in0staff
Phone1staff
Online2online
App3online
API4ai

mapAppointments()

Transform an array of appointments.
export function mapAppointments(
  appointments: ZenotiAppointment[],
): Appointment[]
Example:
import { listAppointments, mapAppointments } from '@/integrations/zenoti'

const raw = await listAppointments({
  centerId: 'abc123',
  startDate: '2026-03-01',
  endDate: '2026-03-31',
})

const appointments = mapAppointments(raw)

// Calculate metrics
const completed = appointments.filter(a => a.status === 'completed')
const noShows = appointments.filter(a => a.status === 'no_show')
const noShowRate = (noShows.length / appointments.length) * 100

console.log(`No-show rate: ${noShowRate.toFixed(1)}%`)

Guests → Clients

mapGuest()

Transform a Zenoti guest into an EIP Client.
export function mapGuest(g: ZenotiGuest): Client
g
ZenotiGuest
required
Zenoti guest object
return
Client
EIP client with:
  • id — Guest ID
  • name — Full name
  • email — Email address
  • phone — Mobile, home, or work phone (in that order)
  • preferredLocation — Home center ID
  • totalVisits — Visit count
  • clv — Customer lifetime value
  • lastVisit — ISO date of last appointment
  • joinDate — Account creation date
  • favoriteService — Preferred service ID
  • noShowCount — No-show count
Implementation (mappers.ts:155-170):
export function mapGuest(g: ZenotiGuest): Client {
  const p = g.personal_info
  return {
    id: g.id,
    name: `${p.first_name} ${p.last_name}`.trim(),
    email: p.email ?? '',
    phone: p.mobile_phone?.number ?? p.home_phone?.number ?? '',
    preferredLocation: g.home_center_id ?? g.center_id,
    totalVisits: g.total_visits ?? 0,
    clv: g.clv ?? 0,
    lastVisit: g.last_visit_date ?? g.creation_date,
    joinDate: g.creation_date,
    favoriteService: g.preferred_service_id ?? '',
    noShowCount: g.no_show_count ?? 0,
  }
}

mapGuests()

Transform an array of guests, filtering out inactive ones.
export function mapGuests(guests: ZenotiGuest[]): Client[]

Sales / Collections → DailyMetrics

mapDailySales()

Convert a Zenoti daily sales breakdown row into EIP DailyMetrics.
export function mapDailySales(
  d: ZenotiDailySales,
  centerId: string,
): DailyMetrics
d
ZenotiDailySales
required
Daily sales data from Zenoti sales report
centerId
string
required
Center ID (not included in ZenotiDailySales)
return
DailyMetrics
EIP daily metrics object. Fields that Zenoti doesn’t provide natively (like responseTimeAvg, callsAnswered, aiResolved) are set to 0 — EIP’s AI modules fill these in from the command center channel.
Implementation (mappers.ts:188-209):
export function mapDailySales(
  d: ZenotiDailySales,
  centerId: string,
): DailyMetrics {
  const bookings = d.bookings ?? 0
  const noShows = d.no_shows ?? 0
  return {
    date: d.date,
    locationId: centerId,
    revenue: d.revenue ?? 0,
    bookings,
    noShows,
    noShowRate: bookings > 0 ? (noShows / bookings) * 100 : 0,
    responseTimeAvg: 0, // Populated by EIP command center
    utilizationRate: (d.utilization_rate ?? 0) * 100,
    newClients: d.new_clients ?? 0,
    rebookingRate: 0, // Calculated separately by EIP
    callsAnswered: 0,
    callsMissed: 0,
    aiResolved: 0,
    escalated: 0,
    revenueRecovered: 0,
  }
}

mapSalesReport()

Map a full Zenoti sales report into an array of DailyMetrics.
export function mapSalesReport(report: ZenotiSalesReport): DailyMetrics[]
report
ZenotiSalesReport
required
Sales report from getSalesReport()
return
DailyMetrics[]
Array of daily metrics (one per day in the report’s daily_breakdown)
Example:
import { getSalesReport, mapSalesReport } from '@/integrations/zenoti'

const report = await getSalesReport({
  centerId: 'abc123',
  startDate: '2026-03-01',
  endDate: '2026-03-31',
})

const dailyMetrics = mapSalesReport(report)

// Calculate average metrics
const avgRevenue = dailyMetrics.reduce((sum, d) => sum + d.revenue, 0) / dailyMetrics.length
const avgUtilization = dailyMetrics.reduce((sum, d) => sum + d.utilizationRate, 0) / dailyMetrics.length

console.log(`Avg daily revenue: $${avgRevenue.toFixed(2)}`)
console.log(`Avg utilization: ${avgUtilization.toFixed(1)}%`)

mapCollection()

Map a Zenoti collection (daily revenue summary) into DailyMetrics. Lightweight alternative when the v2 sales report endpoint is unavailable.
export function mapCollection(c: ZenotiCollection): DailyMetrics
c
ZenotiCollection
required
Collection object from listCollections()
return
DailyMetrics
Daily metrics with revenue and transaction count. Most fields are set to 0.

mapCollections()

Transform an array of collections.
export function mapCollections(
  collections: ZenotiCollection[],
): DailyMetrics[]

Field Mapping Tables

Center → Location

Zenoti FieldEIP FieldTransformation
ididDirect
display_name or namenameFallback to name
citycityDirect
state.code or state.namestatePrefer code
rooms (filtered is_active)roomsCount active
provider_countprovidersDirect

Service → Service

Zenoti FieldEIP FieldTransformation
ididDirect
namenameDirect
price.salespriceDirect
durationdurationDirect
category.namecategoryMapped via CATEGORY_MAP

Appointment → Appointment

Zenoti FieldEIP FieldTransformation
appointment_ididDirect
guest.first_name + last_nameclientNameConcatenated
guest.idclientIdDirect
service.nameserviceDirect
therapist.first_name + last_nameproviderConcatenated
center_idlocationIdDirect
start_timedate, startTimeParsed to ISO date + HH:MM
end_timeendTimeParsed to HH:MM
statusstatusMapped via mapAppointmentStatus()
booking_sourcebookedByMapped via mapBookingSource()
price.salesrevenueDirect

Guest → Client

Zenoti FieldEIP FieldTransformation
ididDirect
personal_info.first_name + last_namenameConcatenated
personal_info.emailemailDirect
personal_info.mobile_phone.numberphoneFallback to home/work
home_center_idpreferredLocationDirect
total_visitstotalVisitsDirect
clvclvDirect
last_visit_datelastVisitDirect
creation_datejoinDateDirect
preferred_service_idfavoriteServiceDirect
no_show_countnoShowCountDirect

DailySales → DailyMetrics

Zenoti FieldEIP FieldTransformation
datedateDirect
locationIdFrom parameter
revenuerevenueDirect
bookingsbookingsDirect
no_showsnoShowsDirect
noShowRateCalculated: (noShows / bookings) * 100
utilization_rateutilizationRateConverted to percentage
new_clientsnewClientsDirect
All call/AI metricsSet to 0 (EIP fills these)

Usage Patterns

Fetch and Map

import { listAppointments, mapAppointments } from '@/integrations/zenoti'

const raw = await listAppointments({ centerId, startDate, endDate })
const appointments = mapAppointments(raw)

Use with React Query Hooks

The React Query hooks automatically apply mappers:
import { useAppointments } from '@/integrations/zenoti'

// Returns EIP Appointment[], not ZenotiAppointment[]
const { data } = useAppointments({ centerId: 'abc123' })

Extend Mappers

You can compose custom mappers for specific use cases:
import { mapAppointment } from '@/integrations/zenoti'
import type { ZenotiAppointment, Appointment } from '@/integrations/zenoti'

function mapAppointmentWithCustomFields(
  a: ZenotiAppointment
): Appointment & { customField: string } {
  return {
    ...mapAppointment(a),
    customField: a.custom_data?.myField ?? 'default',
  }
}

API Endpoints

Fetch raw Zenoti data

React Hooks

Pre-mapped queries with caching

TypeScript Types

Full type definitions

Build docs developers (and LLMs) love