diff --git a/app/api/admin/calendars/route.ts b/app/api/admin/calendars/route.ts
new file mode 100644
index 000000000..8e1ab9b16
--- /dev/null
+++ b/app/api/admin/calendars/route.ts
@@ -0,0 +1,343 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { getDB } from '@/lib/db'
+import { createCalDAVClient } from '@/lib/caldav-client'
+import { z } from 'zod'
+
+export const dynamic = "force-dynamic"
+
+const createCalendarSchema = z.object({
+ artistId: z.string().min(1),
+ calendarUrl: z.string().url(),
+ calendarId: z.string().min(1),
+})
+
+const updateCalendarSchema = createCalendarSchema.partial().extend({
+ id: z.string().min(1),
+})
+
+/**
+ * GET /api/admin/calendars
+ *
+ * Get all artist calendar configurations
+ * Admin only
+ */
+export async function GET(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const db = getDB(context?.env)
+
+ // Check if user is admin
+ const user = await db
+ .prepare('SELECT role FROM users WHERE email = ?')
+ .bind(session.user.email)
+ .first()
+
+ if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+ }
+
+ // Get all calendar configurations with artist info and last sync log
+ const calendars = await db
+ .prepare(`
+ SELECT
+ ac.*,
+ a.name as artist_name,
+ a.slug as artist_slug,
+ (
+ SELECT created_at
+ FROM calendar_sync_logs
+ WHERE artist_id = ac.artist_id
+ ORDER BY created_at DESC
+ LIMIT 1
+ ) as last_sync_log_time,
+ (
+ SELECT status
+ FROM calendar_sync_logs
+ WHERE artist_id = ac.artist_id
+ ORDER BY created_at DESC
+ LIMIT 1
+ ) as last_sync_status
+ FROM artist_calendars ac
+ INNER JOIN artists a ON ac.artist_id = a.id
+ ORDER BY a.name
+ `)
+ .all()
+
+ return NextResponse.json({ calendars: calendars.results })
+ } catch (error) {
+ console.error('Error fetching calendars:', error)
+ return NextResponse.json(
+ { error: 'Failed to fetch calendars' },
+ { status: 500 }
+ )
+ }
+}
+
+/**
+ * POST /api/admin/calendars
+ *
+ * Create a new artist calendar configuration
+ * Admin only
+ */
+export async function POST(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const db = getDB(context?.env)
+
+ // Check if user is admin
+ const user = await db
+ .prepare('SELECT role FROM users WHERE email = ?')
+ .bind(session.user.email)
+ .first()
+
+ if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+ }
+
+ const body = await request.json()
+ const validatedData = createCalendarSchema.parse(body)
+
+ // Check if artist exists
+ const artist = await db
+ .prepare('SELECT id FROM artists WHERE id = ?')
+ .bind(validatedData.artistId)
+ .first()
+
+ if (!artist) {
+ return NextResponse.json(
+ { error: 'Artist not found' },
+ { status: 404 }
+ )
+ }
+
+ // Check if calendar config already exists for this artist
+ const existing = await db
+ .prepare('SELECT id FROM artist_calendars WHERE artist_id = ?')
+ .bind(validatedData.artistId)
+ .first()
+
+ if (existing) {
+ return NextResponse.json(
+ { error: 'Calendar configuration already exists for this artist' },
+ { status: 409 }
+ )
+ }
+
+ // Test calendar connection
+ const client = createCalDAVClient()
+ if (client) {
+ try {
+ await client.login()
+ // Try to fetch calendars to verify connection
+ await client.fetchCalendars()
+ } catch (testError) {
+ return NextResponse.json(
+ { error: 'Failed to connect to CalDAV server. Please check your credentials and calendar URL.' },
+ { status: 400 }
+ )
+ }
+ }
+
+ // Create calendar configuration
+ const calendarId = crypto.randomUUID()
+ await db
+ .prepare(`
+ INSERT INTO artist_calendars (
+ id, artist_id, calendar_url, calendar_id, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ `)
+ .bind(
+ calendarId,
+ validatedData.artistId,
+ validatedData.calendarUrl,
+ validatedData.calendarId
+ )
+ .run()
+
+ // Fetch the created configuration
+ const calendar = await db
+ .prepare('SELECT * FROM artist_calendars WHERE id = ?')
+ .bind(calendarId)
+ .first()
+
+ return NextResponse.json({ calendar }, { status: 201 })
+ } catch (error) {
+ console.error('Error creating calendar:', error)
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'Invalid calendar data', details: error.errors },
+ { status: 400 }
+ )
+ }
+
+ return NextResponse.json(
+ { error: 'Failed to create calendar configuration' },
+ { status: 500 }
+ )
+ }
+}
+
+/**
+ * PUT /api/admin/calendars
+ *
+ * Update an artist calendar configuration
+ * Admin only
+ */
+export async function PUT(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const db = getDB(context?.env)
+
+ // Check if user is admin
+ const user = await db
+ .prepare('SELECT role FROM users WHERE email = ?')
+ .bind(session.user.email)
+ .first()
+
+ if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+ }
+
+ const body = await request.json()
+ const validatedData = updateCalendarSchema.parse(body)
+
+ // Check if calendar exists
+ const existing = await db
+ .prepare('SELECT * FROM artist_calendars WHERE id = ?')
+ .bind(validatedData.id)
+ .first()
+
+ if (!existing) {
+ return NextResponse.json(
+ { error: 'Calendar configuration not found' },
+ { status: 404 }
+ )
+ }
+
+ // Build update query
+ const updateFields = []
+ const updateValues = []
+
+ if (validatedData.calendarUrl) {
+ updateFields.push('calendar_url = ?')
+ updateValues.push(validatedData.calendarUrl)
+ }
+ if (validatedData.calendarId) {
+ updateFields.push('calendar_id = ?')
+ updateValues.push(validatedData.calendarId)
+ }
+
+ if (updateFields.length === 0) {
+ return NextResponse.json(
+ { error: 'No fields to update' },
+ { status: 400 }
+ )
+ }
+
+ updateFields.push('updated_at = CURRENT_TIMESTAMP')
+ updateValues.push(validatedData.id)
+
+ await db
+ .prepare(`
+ UPDATE artist_calendars
+ SET ${updateFields.join(', ')}
+ WHERE id = ?
+ `)
+ .bind(...updateValues)
+ .run()
+
+ // Fetch updated configuration
+ const calendar = await db
+ .prepare('SELECT * FROM artist_calendars WHERE id = ?')
+ .bind(validatedData.id)
+ .first()
+
+ return NextResponse.json({ calendar })
+ } catch (error) {
+ console.error('Error updating calendar:', error)
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'Invalid calendar data', details: error.errors },
+ { status: 400 }
+ )
+ }
+
+ return NextResponse.json(
+ { error: 'Failed to update calendar configuration' },
+ { status: 500 }
+ )
+ }
+}
+
+/**
+ * DELETE /api/admin/calendars
+ *
+ * Delete an artist calendar configuration
+ * Admin only
+ */
+export async function DELETE(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const db = getDB(context?.env)
+
+ // Check if user is admin
+ const user = await db
+ .prepare('SELECT role FROM users WHERE email = ?')
+ .bind(session.user.email)
+ .first()
+
+ if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+ }
+
+ const { searchParams } = new URL(request.url)
+ const id = searchParams.get('id')
+
+ if (!id) {
+ return NextResponse.json(
+ { error: 'Calendar ID is required' },
+ { status: 400 }
+ )
+ }
+
+ const deleteStmt = db.prepare('DELETE FROM artist_calendars WHERE id = ?')
+ const result = await deleteStmt.bind(id).run()
+
+ const written = (result as any)?.meta?.changes ?? (result as any)?.meta?.rows_written ?? 0
+ if (written === 0) {
+ return NextResponse.json(
+ { error: 'Calendar configuration not found' },
+ { status: 404 }
+ )
+ }
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('Error deleting calendar:', error)
+ return NextResponse.json(
+ { error: 'Failed to delete calendar configuration' },
+ { status: 500 }
+ )
+ }
+}
+
diff --git a/app/api/appointments/route.ts b/app/api/appointments/route.ts
index 44f214680..c044cf260 100644
--- a/app/api/appointments/route.ts
+++ b/app/api/appointments/route.ts
@@ -3,6 +3,11 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { getDB } from '@/lib/db'
import { Flags } from '@/lib/flags'
+import {
+ syncAppointmentToCalendar,
+ deleteAppointmentFromCalendar,
+ checkArtistAvailability,
+} from '@/lib/calendar-sync'
import { z } from 'zod'
export const dynamic = "force-dynamic";
@@ -103,33 +108,30 @@ export async function POST(request: NextRequest, { params }: { params?: any } =
const body = await request.json()
const validatedData = createAppointmentSchema.parse(body)
- // Check for scheduling conflicts
const db = getDB(context?.env)
- const conflictCheck = db.prepare(`
- SELECT id FROM appointments
- WHERE artist_id = ?
- AND status NOT IN ('CANCELLED', 'COMPLETED')
- AND (
- (start_time <= ? AND end_time > ?) OR
- (start_time < ? AND end_time >= ?) OR
- (start_time >= ? AND end_time <= ?)
- )
- `)
- const conflictResult = await conflictCheck.bind(
+ // IMPORTANT: Check CalDAV availability first (Nextcloud is source of truth)
+ const startDate = new Date(validatedData.startTime)
+ const endDate = new Date(validatedData.endTime)
+
+ const availabilityCheck = await checkArtistAvailability(
validatedData.artistId,
- validatedData.startTime, validatedData.startTime,
- validatedData.endTime, validatedData.endTime,
- validatedData.startTime, validatedData.endTime
- ).all()
+ startDate,
+ endDate,
+ context
+ )
- if (conflictResult.results.length > 0) {
+ if (!availabilityCheck.available) {
return NextResponse.json(
- { error: 'Time slot conflicts with existing appointment' },
+ {
+ error: 'Time slot not available',
+ reason: availabilityCheck.reason || 'Selected time slot conflicts with existing booking. Please select a different time.'
+ },
{ status: 409 }
)
}
+ // Create appointment in database with PENDING status
const appointmentId = crypto.randomUUID()
const insertStmt = db.prepare(`
INSERT INTO appointments (
@@ -166,6 +168,14 @@ export async function POST(request: NextRequest, { params }: { params?: any } =
const appointment = await selectStmt.bind(appointmentId).first()
+ // Sync to CalDAV calendar (non-blocking - failure won't prevent appointment creation)
+ try {
+ await syncAppointmentToCalendar(appointment as any, context)
+ } catch (syncError) {
+ console.error('Failed to sync appointment to calendar:', syncError)
+ // Continue - appointment is created in DB even if CalDAV sync fails
+ }
+
return NextResponse.json({ appointment }, { status: 201 })
} catch (error) {
console.error('Error creating appointment:', error)
@@ -286,6 +296,14 @@ export async function PUT(request: NextRequest, { params }: { params?: any } = {
const appointment = await selectStmt.bind(validatedData.id).first()
+ // Sync updated appointment to CalDAV (non-blocking)
+ try {
+ await syncAppointmentToCalendar(appointment as any, context)
+ } catch (syncError) {
+ console.error('Failed to sync updated appointment to calendar:', syncError)
+ // Continue - appointment is updated in DB even if CalDAV sync fails
+ }
+
return NextResponse.json({ appointment })
} catch (error) {
console.error('Error updating appointment:', error)
@@ -323,17 +341,29 @@ export async function DELETE(request: NextRequest, { params }: { params?: any }
}
const db = getDB(context?.env)
- const deleteStmt = db.prepare('DELETE FROM appointments WHERE id = ?')
- const result = await deleteStmt.bind(id).run()
-
- const written = (result as any)?.meta?.changes ?? (result as any)?.meta?.rows_written ?? 0
- if (written === 0) {
+
+ // Fetch appointment before deleting (needed for CalDAV sync)
+ const appointment = await db.prepare('SELECT * FROM appointments WHERE id = ?').bind(id).first()
+
+ if (!appointment) {
return NextResponse.json(
{ error: 'Appointment not found' },
{ status: 404 }
)
}
+ // Delete from CalDAV calendar first (non-blocking)
+ try {
+ await deleteAppointmentFromCalendar(appointment as any, context)
+ } catch (syncError) {
+ console.error('Failed to delete appointment from calendar:', syncError)
+ // Continue with DB deletion even if CalDAV deletion fails
+ }
+
+ // Delete from database
+ const deleteStmt = db.prepare('DELETE FROM appointments WHERE id = ?')
+ await deleteStmt.bind(id).run()
+
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting appointment:', error)
diff --git a/app/api/caldav/availability/route.ts b/app/api/caldav/availability/route.ts
new file mode 100644
index 000000000..f414f84ce
--- /dev/null
+++ b/app/api/caldav/availability/route.ts
@@ -0,0 +1,72 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { checkArtistAvailability } from '@/lib/calendar-sync'
+import { z } from 'zod'
+
+export const dynamic = "force-dynamic"
+
+const availabilitySchema = z.object({
+ artistId: z.string().min(1),
+ startTime: z.string().datetime(),
+ endTime: z.string().datetime(),
+})
+
+/**
+ * GET /api/caldav/availability
+ *
+ * Check availability for an artist at a specific time slot
+ *
+ * Query params:
+ * - artistId: string
+ * - startTime: ISO datetime string
+ * - endTime: ISO datetime string
+ */
+export async function GET(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
+ try {
+ const { searchParams } = new URL(request.url)
+
+ const artistId = searchParams.get('artistId')
+ const startTime = searchParams.get('startTime')
+ const endTime = searchParams.get('endTime')
+
+ // Validate inputs
+ const validatedData = availabilitySchema.parse({
+ artistId,
+ startTime,
+ endTime,
+ })
+
+ const startDate = new Date(validatedData.startTime)
+ const endDate = new Date(validatedData.endTime)
+
+ // Check availability (checks both CalDAV and database)
+ const result = await checkArtistAvailability(
+ validatedData.artistId,
+ startDate,
+ endDate,
+ context
+ )
+
+ return NextResponse.json({
+ artistId: validatedData.artistId,
+ startTime: validatedData.startTime,
+ endTime: validatedData.endTime,
+ available: result.available,
+ reason: result.reason,
+ })
+ } catch (error) {
+ console.error('Error checking availability:', error)
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'Invalid request parameters', details: error.errors },
+ { status: 400 }
+ )
+ }
+
+ return NextResponse.json(
+ { error: 'Failed to check availability' },
+ { status: 500 }
+ )
+ }
+}
+
diff --git a/app/api/caldav/sync/route.ts b/app/api/caldav/sync/route.ts
new file mode 100644
index 000000000..bd5b8a2c9
--- /dev/null
+++ b/app/api/caldav/sync/route.ts
@@ -0,0 +1,160 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { getDB } from '@/lib/db'
+import { pullCalendarEventsToDatabase, logSync } from '@/lib/calendar-sync'
+import { z } from 'zod'
+
+export const dynamic = "force-dynamic"
+
+const syncSchema = z.object({
+ artistId: z.string().min(1).optional(),
+ startDate: z.string().datetime().optional(),
+ endDate: z.string().datetime().optional(),
+})
+
+/**
+ * POST /api/caldav/sync
+ *
+ * Manually trigger calendar sync from Nextcloud to database
+ * Admin only endpoint
+ *
+ * Body:
+ * - artistId?: string (if omitted, syncs all artists)
+ * - startDate?: ISO datetime (defaults to 30 days ago)
+ * - endDate?: ISO datetime (defaults to 90 days from now)
+ */
+export async function POST(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
+ try {
+ // Check authentication and authorization
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const db = getDB(context?.env)
+
+ // Check if user is admin
+ const user = await db
+ .prepare('SELECT role FROM users WHERE email = ?')
+ .bind(session.user.email)
+ .first()
+
+ if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) {
+ return NextResponse.json({ error: 'Forbidden: Admin access required' }, { status: 403 })
+ }
+
+ const body = await request.json()
+ const validatedData = syncSchema.parse(body)
+
+ // Set default date range
+ const startDate = validatedData.startDate
+ ? new Date(validatedData.startDate)
+ : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // 30 days ago
+
+ const endDate = validatedData.endDate
+ ? new Date(validatedData.endDate)
+ : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days from now
+
+ // Get artists to sync
+ let artistsToSync: any[] = []
+
+ if (validatedData.artistId) {
+ const artist = await db
+ .prepare('SELECT id FROM artists WHERE id = ?')
+ .bind(validatedData.artistId)
+ .first()
+
+ if (artist) {
+ artistsToSync = [artist]
+ }
+ } else {
+ // Get all artists with calendar configurations
+ const artists = await db
+ .prepare(`
+ SELECT DISTINCT a.id
+ FROM artists a
+ INNER JOIN artist_calendars ac ON a.id = ac.artist_id
+ WHERE a.is_active = TRUE
+ `)
+ .all()
+
+ artistsToSync = artists.results
+ }
+
+ if (artistsToSync.length === 0) {
+ return NextResponse.json({
+ message: 'No artists with calendar configurations found',
+ synced: 0,
+ })
+ }
+
+ // Perform sync for each artist
+ const syncResults = []
+ const startTime = Date.now()
+
+ for (const artist of artistsToSync) {
+ const artistStartTime = Date.now()
+
+ const result = await pullCalendarEventsToDatabase(
+ artist.id,
+ startDate,
+ endDate,
+ context
+ )
+
+ const duration = Date.now() - artistStartTime
+
+ // Log the sync operation
+ await logSync({
+ artistId: artist.id,
+ syncType: 'PULL',
+ status: result.success ? 'SUCCESS' : 'FAILED',
+ errorMessage: result.error,
+ eventsProcessed: result.eventsProcessed,
+ eventsCreated: result.eventsCreated,
+ eventsUpdated: result.eventsUpdated,
+ eventsDeleted: result.eventsDeleted,
+ durationMs: duration,
+ }, context)
+
+ syncResults.push({
+ artistId: artist.id,
+ ...result,
+ durationMs: duration,
+ })
+ }
+
+ const totalDuration = Date.now() - startTime
+
+ return NextResponse.json({
+ message: 'Sync completed',
+ totalArtists: artistsToSync.length,
+ totalDurationMs: totalDuration,
+ results: syncResults,
+ summary: {
+ totalEventsProcessed: syncResults.reduce((sum, r) => sum + r.eventsProcessed, 0),
+ totalEventsCreated: syncResults.reduce((sum, r) => sum + r.eventsCreated, 0),
+ totalEventsUpdated: syncResults.reduce((sum, r) => sum + r.eventsUpdated, 0),
+ totalEventsDeleted: syncResults.reduce((sum, r) => sum + r.eventsDeleted, 0),
+ successCount: syncResults.filter(r => r.success).length,
+ failureCount: syncResults.filter(r => !r.success).length,
+ },
+ })
+ } catch (error) {
+ console.error('Error during sync:', error)
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'Invalid request parameters', details: error.errors },
+ { status: 400 }
+ )
+ }
+
+ return NextResponse.json(
+ { error: 'Sync failed', message: error instanceof Error ? error.message : 'Unknown error' },
+ { status: 500 }
+ )
+ }
+}
+
diff --git a/components/booking-form.tsx b/components/booking-form.tsx
index def770147..03a044f00 100644
--- a/components/booking-form.tsx
+++ b/components/booking-form.tsx
@@ -2,7 +2,7 @@
import type React from "react"
-import { useState } from "react"
+import { useState, useMemo } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
@@ -13,7 +13,8 @@ import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { useFeatureFlag } from "@/components/feature-flags-provider"
import { useArtists } from "@/hooks/use-artist-data"
-import { CalendarIcon, DollarSign, MessageSquare, User, Loader2 } from "lucide-react"
+import { useAvailability } from "@/hooks/use-availability"
+import { CalendarIcon, DollarSign, MessageSquare, User, Loader2, CheckCircle2, XCircle, AlertCircle } from "lucide-react"
import { format } from "date-fns"
import Link from "next/link"
@@ -73,6 +74,44 @@ export function BookingForm({ artistId }: BookingFormProps) {
const selectedSize = tattooSizes.find((size) => size.size === formData.tattooSize)
const bookingEnabled = useFeatureFlag("BOOKING_ENABLED")
+ // Calculate appointment start and end times for availability checking
+ const { appointmentStart, appointmentEnd } = useMemo(() => {
+ if (!selectedDate || !formData.preferredTime || !selectedSize) {
+ return { appointmentStart: null, appointmentEnd: null }
+ }
+
+ // Parse time slot (e.g., "2:00 PM")
+ const timeParts = formData.preferredTime.match(/(\d+):(\d+)\s*(AM|PM)/i)
+ if (!timeParts) return { appointmentStart: null, appointmentEnd: null }
+
+ let hours = parseInt(timeParts[1])
+ const minutes = parseInt(timeParts[2])
+ const meridiem = timeParts[3].toUpperCase()
+
+ if (meridiem === 'PM' && hours !== 12) hours += 12
+ if (meridiem === 'AM' && hours === 12) hours = 0
+
+ const start = new Date(selectedDate)
+ start.setHours(hours, minutes, 0, 0)
+
+ // Estimate duration from tattoo size (use max hours)
+ const durationHours = parseInt(selectedSize.duration.split('-')[1] || selectedSize.duration.split('-')[0])
+ const end = new Date(start.getTime() + durationHours * 60 * 60 * 1000)
+
+ return {
+ appointmentStart: start.toISOString(),
+ appointmentEnd: end.toISOString(),
+ }
+ }, [selectedDate, formData.preferredTime, selectedSize])
+
+ // Check availability in real-time
+ const availability = useAvailability({
+ artistId: selectedArtist?.id || null,
+ startTime: appointmentStart,
+ endTime: appointmentEnd,
+ enabled: !!selectedArtist && !!appointmentStart && !!appointmentEnd && step === 2,
+ })
+
const handleInputChange = (field: string, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
@@ -337,6 +376,46 @@ export function BookingForm({ artistId }: BookingFormProps) {
+ {/* Availability Indicator */}
+ {selectedArtist && selectedDate && formData.preferredTime && selectedSize && (
+
+
+ {availability.checking ? (
+ <>
+
+
Checking availability...
+ >
+ ) : availability.available ? (
+ <>
+
+
Time slot available!
+ >
+ ) : (
+ <>
+
+
+ Time slot not available
+ {availability.reason && (
+ {availability.reason}
+ )}
+
+ >
+ )}
+
+ {!availability.available && !availability.checking && (
+
+ Please select a different date or time, or provide an alternative below.
+
+ )}
+
+ )}
+
Alternative Date & Time
@@ -598,8 +677,15 @@ export function BookingForm({ artistId }: BookingFormProps) {
{step < 4 ? (
-