This guide explains how to use the Event Services React components in your application.

The Event Services feature provides three main React components:

  1. EventServicesManager - Main container with view state management (list/create/edit)
  2. EventServiceList - Displays list of services with conditional edit links
  3. EventServiceForm - React Hook Form + Zod validation for creating/editing services

These components are already available in the project. Import them using path aliases:

import { EventServicesManager } from '#components/react/EventServicesManager'
import { EventServiceList } from '#components/react/EventServiceList'
import { EventServiceForm } from '#components/react/EventServiceForm'

The main orchestration component that handles view modes and state management.

export type EventServicesManagerProps = {
eventId: string // UUID of the event
initialServices: EventService[] // Initial services data from server
csrfToken: string // CSRF token for mutation operations
canEdit: boolean // User permission flag
}
  • View mode management (list/create/edit)
  • Automatic service list refresh after mutations
  • Loading and error state handling
  • Permission-based UI rendering
  • Success/cancel callback handlers
src/pages/dashboard/events/[id]/services.astro
---
import { EventServicesManager } from '#components/react/EventServicesManager'
import { generateCsrfToken } from '#utils/csrf'
import { getDatabase } from '#libs/database'
import { canManageEventServices } from '#utils/event-service-authorization'
const { id } = Astro.params
const db = getDatabase()
// Fetch event and services
const event = await db.getEventById(id)
const services = await db.getEventServices(id)
// Check permissions
const canEdit = canManageEventServices({
userRole: Astro.locals.userRole,
userDbId: Astro.locals.userDbId,
event
})
// Generate CSRF token
const csrfToken = await generateCsrfToken(Astro)
---
<EventServicesManager
eventId={event.id}
initialServices={services}
csrfToken={csrfToken}
canEdit={canEdit}
client:load
/>

The component internally manages three view modes:

  • list - Default view showing all services
  • create - Form for adding new service
  • edit - Form for editing existing service

State Flow:

┌─────────────┐
│ List │ ←─────────────────┐
│ (default) │ │
└─────────────┘ │
│ │
├─► Add Service ──→ Create Form ──┘
│ │
└─► Edit Service ──→ Edit Form ───┘

Displays services with optional edit functionality.

export type EventServiceListProps = {
services: EventService[] // Array of services to display
eventId: string // Event ID for edit links
canEdit: boolean // Show/hide edit links
}
  • Empty state handling
  • Status badge rendering (active/inactive)
  • Conditional edit links based on permissions
  • Accessible list markup
import { EventServiceList } from '#components/react/EventServiceList'
import type { EventService } from '#libs/database-types'
const services: EventService[] = [
{
id: 'service-1',
event_id: 'event-123',
name: 'Haircuts',
description: 'Professional haircuts',
capacity: 20,
status: 'active',
display_order: 0,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
]
export function ServicesDisplay() {
return (
<EventServiceList
services={services}
eventId="event-123"
canEdit={true}
/>
)
}

When no services exist, the component displays:

"No services available for this event."

React Hook Form component with Zod validation for creating and editing services.

export type EventServiceFormProps = {
eventId: string // Required: Event UUID
csrfToken: string // Required: CSRF token
initialData?: EventServiceFormData // Optional: For edit mode
serviceId?: string // Optional: Service UUID (edit mode)
onSuccess?: () => void // Optional: Success callback
onCancel?: () => void // Optional: Cancel callback
}
  • React Hook Form integration
  • Zod schema validation (eventServiceSchema)
  • @fpkit/acss components (Field, Input, Textarea, Button, Alert)
  • Automatic create/edit mode detection
  • Duplicate service error handling (409 Conflict)
  • Loading state management
  • Accessibility attributes (aria-invalid, role=“alert”)
import { EventServiceForm } from '#components/react/EventServiceForm'
export function CreateServicePage({ eventId, csrfToken }: Props) {
const handleSuccess = () => {
// Redirect to services list
window.location.href = `/dashboard/events/${eventId}/services`
}
return (
<div>
<h1>Add New Service</h1>
<EventServiceForm
eventId={eventId}
csrfToken={csrfToken}
onSuccess={handleSuccess}
/>
</div>
)
}
FieldTypeRequiredValidation
nametext✅1-100 chars, trimmed
descriptiontextarea❌Max 500 chars, trimmed
capacitynumber❌1-10,000, positive integer
statusselect✅‘active’ or ‘inactive’
display_ordernumber✅Non-negative integer, default 0
src/schemas/event-service.schema.ts
import { z } from 'zod'
export const eventServiceSchema = z.object({
name: z.string().min(1).max(100).trim(),
description: z.string().max(500).trim().optional().or(z.literal('')),
capacity: z.number().int().positive().max(10000).nullable().optional(),
status: z.enum(['active', 'inactive']).default('active'),
display_order: z.number().int().nonnegative().default(0),
})
export type EventServiceFormData = z.infer<typeof eventServiceSchema>

The form handles errors at multiple levels:

Validation Errors (Client-side):

{errors.name && (
<p role="alert">{errors.name.message}</p>
)}

API Errors:

  • 409 Conflict - Duplicate service name
  • 401 Unauthorized - Not authenticated
  • 403 Forbidden - Insufficient permissions
  • 500 Server Error - Unexpected error

Example Error Display:

{error && <Alert severity="error">{error}</Alert>}

Recommended for dashboard pages with authentication.

src/pages/dashboard/events/[id]/services.astro
---
import { EventServicesManager } from '#components/react/EventServicesManager'
import { getDatabase } from '#libs/database'
import { generateCsrfToken } from '#utils/csrf'
import { canManageEventServices } from '#utils/event-service-authorization'
const { id } = Astro.params
// 1. Check authentication
if (!Astro.locals.userId) {
return Astro.redirect('/login')
}
// 2. Fetch data
const db = getDatabase()
const event = await db.getEventById(id)
if (!event) {
return new Response('Event not found', { status: 404 })
}
const services = await db.getEventServices(id)
// 3. Check permissions
const canEdit = canManageEventServices({
userRole: Astro.locals.userRole,
userDbId: Astro.locals.userDbId,
event,
})
// 4. Generate CSRF token
const csrfToken = await generateCsrfToken(Astro)
---
<Layout title={`Manage Services - ${event.title}`}>
<EventServicesManager
eventId={event.id}
initialServices={services}
csrfToken={csrfToken}
canEdit={canEdit}
client:load
/>
</Layout>

For dynamic React applications.

import { useEffect, useState } from 'react'
import { EventServicesManager } from '#components/react/EventServicesManager'
import type { EventService } from '#libs/database-types'
export function EventServicesPage({ eventId }: Props) {
const [services, setServices] = useState<EventService[]>([])
const [csrfToken, setCsrfToken] = useState('')
const [canEdit, setCanEdit] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
async function loadData() {
try {
// Fetch services
const servicesRes = await fetch(`/api/event-services?event_id=${eventId}`)
const servicesData = await servicesRes.json()
setServices(servicesData.services || [])
// Fetch CSRF token
const tokenRes = await fetch('/api/csrf-token')
const tokenData = await tokenRes.json()
setCsrfToken(tokenData.token)
// Check permissions
const permRes = await fetch(`/api/events/${eventId}/permissions`)
const permData = await permRes.json()
setCanEdit(permData.canManageServices)
} catch (error) {
console.error('Failed to load services:', error)
} finally {
setLoading(false)
}
}
loadData()
}, [eventId])
if (loading) return <div>Loading...</div>
return (
<EventServicesManager
eventId={eventId}
initialServices={services}
csrfToken={csrfToken}
canEdit={canEdit}
/>
)
}

For custom workflows or modals.

import { EventServiceForm } from '#components/react/EventServiceForm'
import { Modal } from '#components/react/Modal'
export function ServiceModal({
isOpen,
onClose,
eventId,
csrfToken
}: Props) {
const handleSuccess = () => {
// Close modal and refresh data
onClose()
refreshServices()
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
<h2>Add Service</h2>
<EventServiceForm
eventId={eventId}
csrfToken={csrfToken}
onSuccess={handleSuccess}
onCancel={onClose}
/>
</Modal>
)
}

Use the canManageEventServices utility for authorization:

import { canManageEventServices } from '#utils/event-service-authorization'
import type { Event } from '#libs/database-types'
const canEdit = canManageEventServices({
userRole: 'team_admin', // From Astro.locals.userRole
userDbId: 'user-uuid', // From Astro.locals.userDbId
event: eventObject, // Event from database
})
// Returns: boolean

Requirements:

  • User must have team_admin, admin, or super_admin role
  • User must own the event OR be super_admin

Example:

// Alice (team_admin) owns the event
canManageEventServices({
userRole: 'team_admin',
userDbId: 'alice-id',
event: { created_by: 'alice-id', ... }
}) // ✅ true
// Bob (team_admin) does NOT own the event
canManageEventServices({
userRole: 'team_admin',
userDbId: 'bob-id',
event: { created_by: 'alice-id', ... }
}) // ❌ false
// Carol (super_admin) can manage any event
canManageEventServices({
userRole: 'super_admin',
userDbId: 'carol-id',
event: { created_by: 'alice-id', ... }
}) // ✅ true

The components interact with these API endpoints:

Fetches services for an event.

Query Parameters:

  • event_id (required) - Event UUID

Response:

{
"services": [
{
"id": "service-uuid",
"event_id": "event-uuid",
"name": "Haircuts",
"description": "Professional haircuts",
"capacity": 20,
"status": "active",
"display_order": 0,
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}
]
}

Creates a new service.

Request:

{
"event_id": "event-uuid",
"name": "Haircuts",
"description": "Professional haircuts",
"capacity": 20,
"status": "active",
"display_order": 0,
"csrf_token": "token"
}

Response (201):

{
"success": true,
"serviceId": "new-service-uuid"
}

Updates an existing service.

Request: Partial service data + CSRF token

Response (200):

{
"success": true
}

Deletes a service.

Request:

{
"csrf_token": "token"
}

Response (200):

{
"success": true
}

src/libs/database-types.ts
export type EventService = {
id: string
event_id: string
name: string
description: string | null
capacity: number | null
status: 'active' | 'inactive'
display_order: number
created_at: string
updated_at: string
}
src/schemas/event-service.schema.ts
export type EventServiceFormData = {
name: string
description?: string | ''
capacity?: number | null
status: 'active' | 'inactive'
display_order: number
}

All components follow WCAG 2.1 Level AA standards:

  • ✅ Form labels with labelFor attribute
  • ✅ Required fields marked with required attribute
  • ✅ Error messages with role="alert"
  • ✅ Invalid fields marked with aria-invalid
  • ✅ Semantic HTML5 form elements
  • ✅ Keyboard navigation support
  • ✅ Semantic <ul> and <li> markup
  • ✅ Accessible links with context
  • ✅ Status badges with clear visual indicators
  • ✅ Loading states announced
  • ✅ Error alerts with role="alert"
  • ✅ Disabled states during loading

Interactive component documentation available in Storybook:

Terminal window
npm run storybook

Visit: http://localhost:6006

Stories:

  • Components/EventServicesManager - All manager stories
  • Components/EventServiceForm - Form variations
  • Components/EventServiceList - List states

Story Examples:

  • Default view with services
  • Read-only mode (canEdit=false)
  • Empty list state
  • Single service
  • Many services
  • Mixed statuses (active/inactive)

import { render, screen } from '@testing-library/react'
import { EventServiceList } from '#components/react/EventServiceList'
describe('EventServiceList', () => {
it('displays empty state when no services', () => {
render(
<EventServiceList
services={[]}
eventId="event-123"
canEdit={false}
/>
)
expect(screen.getByText('No services available for this event.')).toBeInTheDocument()
})
it('displays services with edit links when canEdit=true', () => {
const services = [
{
id: 'service-1',
event_id: 'event-123',
name: 'Haircuts',
// ... other fields
}
]
render(
<EventServiceList
services={services}
eventId="event-123"
canEdit={true}
/>
)
expect(screen.getByText('Haircuts')).toBeInTheDocument()
expect(screen.getByText('Edit')).toBeInTheDocument()
})
})

Test components with mocked API responses:

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { EventServiceForm } from '#components/react/EventServiceForm'
describe('EventServiceForm', () => {
it('submits form with valid data', async () => {
const onSuccess = vi.fn()
render(
<EventServiceForm
eventId="event-123"
csrfToken="token"
onSuccess={onSuccess}
/>
)
await userEvent.type(screen.getByLabelText(/service name/i), 'Haircuts')
await userEvent.click(screen.getByRole('button', { name: /create service/i }))
await waitFor(() => {
expect(onSuccess).toHaveBeenCalled()
})
})
})

Problem: Component appears blank or doesn’t load.

Solutions:

  1. Check Astro client directive: client:load
  2. Verify all required props are passed
  3. Check browser console for errors
  4. Ensure @fpkit/acss is installed

Problem: Form shows “Failed to save service” error.

Solutions:

  1. Verify CSRF token is valid (not expired)
  2. Check user has required permissions
  3. Ensure event_id exists in database
  4. Review API endpoint logs for errors

Problem: List doesn’t update after create/edit.

Solutions:

  1. Ensure onSuccess callback is implemented
  2. Check fetchServices() is called after mutation
  3. Verify API endpoint returns updated data
  4. Clear browser cache if stale data persists

// ✅ CORRECT
const canEdit = canManageEventServices({ userRole, userDbId, event })
<EventServicesManager canEdit={canEdit} ... />
// ❌ WRONG - Never hardcode permissions
<EventServicesManager canEdit={true} ... />
---
// ✅ CORRECT - Generate on server
const csrfToken = await generateCsrfToken(Astro)
---
// ❌ WRONG - Never generate client-side
// ✅ CORRECT
if (loading) return <Spinner />
return <EventServicesManager ... />
// ❌ WRONG - No loading indicator
// ✅ CORRECT
const handleSuccess = async () => {
await fetchServices() // Refresh data
setViewMode('list') // Update UI
}
// ❌ WRONG - No data refresh
const handleSuccess = () => {
setViewMode('list') // UI updates but data is stale
}
// ✅ CORRECT
import type { EventService } from '#libs/database-types'
const [services, setServices] = useState<EventService[]>([])
// ❌ WRONG - No type safety
const [services, setServices] = useState([])

For the JSON services approach, use the EventFormRHF component with built-in service checkboxes from the predefined catalog.

import type { Event } from '#libs/database-types'
export type EventFormRHFProps = {
csrfToken: string
event?: Event
apiEndpoint: string
}
src/pages/dashboard/events/create.astro
---
import { EventFormRHF } from '#components/react/EventFormRHF'
import { generateCsrfToken } from '#utils/csrf'
const csrfToken = await generateCsrfToken(Astro)
---
<EventFormRHF
csrfToken={csrfToken}
apiEndpoint="/api/events/create"
client:load
/>

The component automatically renders checkboxes for all active services from services.config.ts:

// Rendered automatically:
// ☐ Haircuts - Professional haircutting services
// ☐ Showers - Shower facilities and maintenance
// ☐ Clothing - Professional clothing and wardrobe services
// ☐ Videography - Professional video recording and editing

Services are validated against the predefined catalog using Zod schema:

// Schema validation (automatic)
services: z.array(z.string()).refine(
ids => validateServiceIds(ids).length === 0,
{ message: 'One or more service IDs are invalid or inactive' }
)

Use EventServiceList to display services for an event:

import { EventServiceList } from '#components/react/EventServiceList'
import type { Event } from '#libs/database-types'
type Props = {
event: Event
}
export const EventDetails: React.FC<Props> = ({ event }) => {
return (
<div>
<h2>{event.name}</h2>
{/* Display services (works with both JSON and M2M) */}
<h3>Services Offered</h3>
<EventServiceList event={event} />
</div>
)
}

The EventServiceList component automatically detects JSON vs M2M services and renders accordingly:

  • JSON services: Resolves service IDs from services.config.ts
  • M2M services: Fetches assignments from database
FeatureJSON (EventFormRHF)M2M (EventServicesManager)
SetupZero - uses config fileRequires service DB records
RenderingAutomatic checkboxesFetch and display services
ValidationZod schema + configZod schema + DB lookup
FlexibilityFixed catalogDynamic catalog
PerformanceExcellentGood

Use EventFormRHF (JSON):

  • Services are predefined and rarely change
  • Simple event categorization
  • Minimal setup required
  • Maximum performance

Use EventServicesManager (M2M):

  • Services are created/managed by admins
  • Need rich service metadata (capacity, pricing)
  • Dynamic service catalog
  • Service analytics required

Learn more: Event Services JSON Guide



Need Help?

  • Check Storybook for interactive examples: npm run storybook
  • Review existing implementations in src/pages/dashboard/events/
  • Consult CLAUDE-PATTERNS.md for coding standards