Adds complete CalDAV integration for syncing appointments between the web app and Nextcloud calendars with real-time availability checking and conflict resolution. Core Features: - Bidirectional sync: Web ↔ Nextcloud calendars - Real-time availability checking with instant user feedback - Conflict detection (Nextcloud is source of truth) - Pending request workflow with 'REQUEST:' prefix for unconfirmed appointments - Hard time blocking - any calendar event blocks booking slots - Graceful degradation when CalDAV unavailable New Dependencies: - tsdav@^2.0.4 - TypeScript CalDAV client - ical.js@^1.5.0 - iCalendar format parser/generator Database Changes: - New table: artist_calendars (stores calendar configuration per artist) - New table: calendar_sync_logs (tracks all sync operations) - Added caldav_uid and caldav_etag columns to appointments table - Migration: sql/migrations/20250109_add_caldav_support.sql New Services: - lib/caldav-client.ts - Core CalDAV operations and iCalendar conversion - lib/calendar-sync.ts - Bidirectional sync logic with error handling New API Endpoints: - GET /api/caldav/availability - Real-time availability checking - POST /api/caldav/sync - Manual sync trigger (admin only) - GET/POST/PUT/DELETE /api/admin/calendars - Calendar configuration CRUD Updated Components: - app/api/appointments/route.ts - Integrated CalDAV sync on CRUD operations - components/booking-form.tsx - Added real-time availability indicator - hooks/use-availability.ts - Custom hook for debounced availability checking Documentation: - docs/CALDAV-SETUP.md - Complete setup guide with troubleshooting - docs/CALDAV-IMPLEMENTATION-SUMMARY.md - Technical implementation overview Pending Tasks (for future PRs): - Admin dashboard UI for calendar management - Background sync worker (Cloudflare Workers cron) - Unit and integration tests Tested with local database migration and linting checks passed.
705 lines
30 KiB
TypeScript
705 lines
30 KiB
TypeScript
"use client"
|
|
|
|
import type React 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"
|
|
import { Calendar } from "@/components/ui/calendar"
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
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 { 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"
|
|
|
|
|
|
const timeSlots = ["10:00 AM", "11:00 AM", "12:00 PM", "1:00 PM", "2:00 PM", "3:00 PM", "4:00 PM", "5:00 PM", "6:00 PM"]
|
|
|
|
const tattooSizes = [
|
|
{ size: "Small (2-4 inches)", duration: "1-2 hours", price: "150-300" },
|
|
{ size: "Medium (4-6 inches)", duration: "2-4 hours", price: "300-600" },
|
|
{ size: "Large (6+ inches)", duration: "4-6 hours", price: "600-1000" },
|
|
{ size: "Full Session", duration: "6-8 hours", price: "1000-1500" },
|
|
]
|
|
|
|
interface BookingFormProps {
|
|
artistId?: string
|
|
}
|
|
|
|
export function BookingForm({ artistId }: BookingFormProps) {
|
|
const [step, setStep] = useState(1)
|
|
const [selectedDate, setSelectedDate] = useState<Date>()
|
|
|
|
// Fetch artists from API
|
|
const { data: artists, isLoading: artistsLoading } = useArtists({ limit: 50 })
|
|
|
|
const [formData, setFormData] = useState({
|
|
// Personal Info
|
|
firstName: "",
|
|
lastName: "",
|
|
email: "",
|
|
phone: "",
|
|
age: "",
|
|
|
|
// Appointment Details
|
|
artistId: artistId || "",
|
|
preferredDate: "",
|
|
preferredTime: "",
|
|
alternateDate: "",
|
|
alternateTime: "",
|
|
|
|
// Tattoo Details
|
|
tattooDescription: "",
|
|
tattooSize: "",
|
|
placement: "",
|
|
isFirstTattoo: false,
|
|
hasAllergies: false,
|
|
allergyDetails: "",
|
|
referenceImages: "",
|
|
|
|
// Additional Info
|
|
specialRequests: "",
|
|
depositAmount: 100,
|
|
agreeToTerms: false,
|
|
agreeToDeposit: false,
|
|
})
|
|
|
|
const selectedArtist = artists?.find((a) => a.slug === formData.artistId)
|
|
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 }))
|
|
}
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!bookingEnabled) {
|
|
// Safety: no-op when disabled
|
|
return
|
|
}
|
|
// Handle form submission
|
|
console.log("Booking submitted:", formData)
|
|
// In a real app, this would send data to your backend
|
|
}
|
|
|
|
const nextStep = () => setStep((prev) => Math.min(prev + 1, 4))
|
|
const prevStep = () => setStep((prev) => Math.max(prev - 1, 1))
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="max-w-4xl mx-auto">
|
|
{/* Header */}
|
|
<div className="text-center mb-8">
|
|
<h1 className="font-playfair text-4xl md:text-5xl font-bold mb-4">Book Your Appointment</h1>
|
|
<p className="text-lg text-muted-foreground">
|
|
Let's create something amazing together. Fill out the form below to schedule your tattoo session.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Progress Indicator */}
|
|
<div className="flex justify-center mb-8">
|
|
<div className="flex items-center space-x-4">
|
|
{[1, 2, 3, 4].map((stepNumber) => (
|
|
<div key={stepNumber} className="flex items-center">
|
|
<div
|
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
|
step >= stepNumber ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
|
}`}
|
|
>
|
|
{stepNumber}
|
|
</div>
|
|
{stepNumber < 4 && (
|
|
<div className={`w-12 h-0.5 mx-2 ${step > stepNumber ? "bg-primary" : "bg-muted"}`} />
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Booking disabled notice */}
|
|
{!bookingEnabled && (
|
|
<div className="mb-6 text-center text-sm" role="status" aria-live="polite">
|
|
Online booking is temporarily unavailable. Please
|
|
{" "}
|
|
<Link href="/contact" className="underline">
|
|
contact the studio
|
|
</Link>
|
|
.
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
{/* Step 1: Personal Information */}
|
|
{step === 1 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center space-x-2">
|
|
<User className="w-5 h-5" />
|
|
<span>Personal Information</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">First Name *</label>
|
|
<Input
|
|
value={formData.firstName}
|
|
onChange={(e) => handleInputChange("firstName", e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Last Name *</label>
|
|
<Input
|
|
value={formData.lastName}
|
|
onChange={(e) => handleInputChange("lastName", e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Email *</label>
|
|
<Input
|
|
type="email"
|
|
value={formData.email}
|
|
onChange={(e) => handleInputChange("email", e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Phone *</label>
|
|
<Input
|
|
type="tel"
|
|
value={formData.phone}
|
|
onChange={(e) => handleInputChange("phone", e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Age *</label>
|
|
<Input
|
|
type="number"
|
|
min="18"
|
|
value={formData.age}
|
|
onChange={(e) => handleInputChange("age", e.target.value)}
|
|
required
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">Must be 18 or older</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="firstTattoo"
|
|
checked={formData.isFirstTattoo}
|
|
onCheckedChange={(checked) => handleInputChange("isFirstTattoo", checked)}
|
|
/>
|
|
<label htmlFor="firstTattoo" className="text-sm">
|
|
This is my first tattoo
|
|
</label>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="allergies"
|
|
checked={formData.hasAllergies}
|
|
onCheckedChange={(checked) => handleInputChange("hasAllergies", checked)}
|
|
/>
|
|
<label htmlFor="allergies" className="text-sm">
|
|
I have allergies or medical conditions
|
|
</label>
|
|
</div>
|
|
|
|
{formData.hasAllergies && (
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Please specify:</label>
|
|
<Textarea
|
|
value={formData.allergyDetails}
|
|
onChange={(e) => handleInputChange("allergyDetails", e.target.value)}
|
|
placeholder="Please describe any allergies, medical conditions, or medications..."
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Step 2: Artist & Scheduling */}
|
|
{step === 2 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center space-x-2">
|
|
<CalendarIcon className="w-5 h-5" />
|
|
<span>Artist & Scheduling</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Select Artist *</label>
|
|
<Select
|
|
value={formData.artistId}
|
|
onValueChange={(value) => handleInputChange("artistId", value)}
|
|
disabled={artistsLoading}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={artistsLoading ? "Loading artists..." : "Choose your preferred artist"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{artistsLoading ? (
|
|
<div className="flex items-center justify-center p-4">
|
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|
<span className="text-sm text-muted-foreground">Loading...</span>
|
|
</div>
|
|
) : artists && artists.length > 0 ? (
|
|
artists.map((artist) => (
|
|
<SelectItem key={artist.slug} value={artist.slug}>
|
|
<div className="flex items-center justify-between w-full">
|
|
<div>
|
|
<p className="font-medium">{artist.name}</p>
|
|
<p className="text-sm text-muted-foreground">{artist.specialties.join(", ")}</p>
|
|
</div>
|
|
</div>
|
|
</SelectItem>
|
|
))
|
|
) : (
|
|
<div className="p-4 text-sm text-muted-foreground text-center">
|
|
No artists available
|
|
</div>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{selectedArtist && (
|
|
<div className="p-4 bg-muted/50 rounded-lg">
|
|
<h4 className="font-medium mb-2">{selectedArtist.name}</h4>
|
|
<p className="text-sm text-muted-foreground mb-2">{selectedArtist.specialties.join(", ")}</p>
|
|
{selectedArtist.hourlyRate && (
|
|
<p className="text-sm">
|
|
Starting rate: <span className="font-medium">${selectedArtist.hourlyRate}/hr</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Preferred Date *</label>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" className="w-full justify-start text-left font-normal bg-transparent">
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
{selectedDate ? format(selectedDate, "PPP") : "Pick a date"}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0">
|
|
<Calendar
|
|
mode="single"
|
|
selected={selectedDate}
|
|
onSelect={setSelectedDate}
|
|
initialFocus
|
|
disabled={(date) => date < new Date() || date.getDay() === 0} // Disable past dates and Sundays
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Preferred Time *</label>
|
|
<Select
|
|
value={formData.preferredTime}
|
|
onValueChange={(value) => handleInputChange("preferredTime", value)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select time" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{timeSlots.map((time) => (
|
|
<SelectItem key={time} value={time}>
|
|
{time}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Availability Indicator */}
|
|
{selectedArtist && selectedDate && formData.preferredTime && selectedSize && (
|
|
<div className={`p-4 rounded-lg border-2 ${
|
|
availability.checking
|
|
? 'bg-gray-50 border-gray-300'
|
|
: availability.available
|
|
? 'bg-green-50 border-green-300'
|
|
: 'bg-red-50 border-red-300'
|
|
}`}>
|
|
<div className="flex items-center space-x-2">
|
|
{availability.checking ? (
|
|
<>
|
|
<Loader2 className="w-5 h-5 animate-spin text-gray-600" />
|
|
<span className="font-medium text-gray-700">Checking availability...</span>
|
|
</>
|
|
) : availability.available ? (
|
|
<>
|
|
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
|
<span className="font-medium text-green-700">Time slot available!</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<XCircle className="w-5 h-5 text-red-600" />
|
|
<div>
|
|
<span className="font-medium text-red-700 block">Time slot not available</span>
|
|
{availability.reason && (
|
|
<span className="text-sm text-red-600">{availability.reason}</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{!availability.available && !availability.checking && (
|
|
<p className="mt-2 text-sm text-red-600">
|
|
Please select a different date or time, or provide an alternative below.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="p-4 bg-blue-50 rounded-lg">
|
|
<h4 className="font-medium mb-2 text-blue-900">Alternative Date & Time</h4>
|
|
<p className="text-sm text-blue-700 mb-4">
|
|
Please provide an alternative in case your preferred slot is unavailable.
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Alternative Date</label>
|
|
<Input
|
|
type="date"
|
|
value={formData.alternateDate}
|
|
onChange={(e) => handleInputChange("alternateDate", e.target.value)}
|
|
min={new Date().toISOString().split("T")[0]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Alternative Time</label>
|
|
<Select
|
|
value={formData.alternateTime}
|
|
onValueChange={(value) => handleInputChange("alternateTime", value)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select time" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{timeSlots.map((time) => (
|
|
<SelectItem key={time} value={time}>
|
|
{time}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Step 3: Tattoo Details */}
|
|
{step === 3 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center space-x-2">
|
|
<MessageSquare className="w-5 h-5" />
|
|
<span>Tattoo Details</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Tattoo Description *</label>
|
|
<Textarea
|
|
value={formData.tattooDescription}
|
|
onChange={(e) => handleInputChange("tattooDescription", e.target.value)}
|
|
placeholder="Describe your tattoo idea in detail. Include style, colors, themes, and any specific elements you want..."
|
|
rows={4}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Estimated Size & Duration *</label>
|
|
<Select value={formData.tattooSize} onValueChange={(value) => handleInputChange("tattooSize", value)}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select tattoo size" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{tattooSizes.map((size) => (
|
|
<SelectItem key={size.size} value={size.size}>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{size.size}</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
{size.duration} • ${size.price}
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{selectedSize && (
|
|
<div className="p-4 bg-muted/50 rounded-lg">
|
|
<h4 className="font-medium mb-2">Size Details</h4>
|
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
|
<div>
|
|
<p className="text-muted-foreground">Size</p>
|
|
<p className="font-medium">{selectedSize.size}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-muted-foreground">Duration</p>
|
|
<p className="font-medium">{selectedSize.duration}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-muted-foreground">Price Range</p>
|
|
<p className="font-medium">${selectedSize.price}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Placement on Body *</label>
|
|
<Input
|
|
value={formData.placement}
|
|
onChange={(e) => handleInputChange("placement", e.target.value)}
|
|
placeholder="e.g., Upper arm, forearm, shoulder, back, etc."
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Reference Images</label>
|
|
<Input
|
|
type="file"
|
|
multiple
|
|
accept="image/*"
|
|
onChange={(e) => handleInputChange("referenceImages", e.target.files)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Upload reference images to help your artist understand your vision
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Special Requests</label>
|
|
<Textarea
|
|
value={formData.specialRequests}
|
|
onChange={(e) => handleInputChange("specialRequests", e.target.value)}
|
|
placeholder="Any special requests, concerns, or additional information..."
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Step 4: Review & Deposit */}
|
|
{step === 4 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center space-x-2">
|
|
<DollarSign className="w-5 h-5" />
|
|
<span>Review & Deposit</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Booking Summary */}
|
|
<div className="p-6 bg-muted/50 rounded-lg">
|
|
<h3 className="font-playfair text-xl font-bold mb-4">Booking Summary</h3>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-3">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Client</p>
|
|
<p className="font-medium">
|
|
{formData.firstName} {formData.lastName}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Email</p>
|
|
<p className="font-medium">{formData.email}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Phone</p>
|
|
<p className="font-medium">{formData.phone}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Artist</p>
|
|
<p className="font-medium">{selectedArtist?.name}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Preferred Date</p>
|
|
<p className="font-medium">{selectedDate ? format(selectedDate, "PPP") : "Not selected"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Preferred Time</p>
|
|
<p className="font-medium">{formData.preferredTime || "Not selected"}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 pt-6 border-t">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Tattoo Description</p>
|
|
<p className="font-medium">{formData.tattooDescription}</p>
|
|
</div>
|
|
<div className="mt-3">
|
|
<p className="text-sm text-muted-foreground">Size & Placement</p>
|
|
<p className="font-medium">
|
|
{formData.tattooSize} • {formData.placement}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Deposit Information */}
|
|
<div className="p-6 border-2 border-primary/20 rounded-lg">
|
|
<h3 className="font-semibold mb-4 flex items-center">
|
|
<DollarSign className="w-5 h-5 mr-2 text-primary" />
|
|
Deposit Required
|
|
</h3>
|
|
<p className="text-muted-foreground mb-4">
|
|
A deposit of <span className="font-bold text-primary">${formData.depositAmount}</span> is required
|
|
to secure your appointment. This deposit will be applied to your final tattoo cost.
|
|
</p>
|
|
<ul className="text-sm text-muted-foreground space-y-1">
|
|
<li>• Deposit is non-refundable but transferable to future appointments</li>
|
|
<li>• 48-hour notice required for rescheduling</li>
|
|
<li>• Final pricing will be discussed during consultation</li>
|
|
</ul>
|
|
</div>
|
|
|
|
{/* Terms and Conditions */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-start space-x-2">
|
|
<Checkbox
|
|
id="terms"
|
|
checked={formData.agreeToTerms}
|
|
onCheckedChange={(checked) => handleInputChange("agreeToTerms", checked)}
|
|
required
|
|
/>
|
|
<label htmlFor="terms" className="text-sm leading-relaxed">
|
|
I agree to the{" "}
|
|
<Link href="/terms" className="text-primary hover:underline">
|
|
Terms and Conditions
|
|
</Link>{" "}
|
|
and{" "}
|
|
<Link href="/privacy" className="text-primary hover:underline">
|
|
Privacy Policy
|
|
</Link>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="flex items-start space-x-2">
|
|
<Checkbox
|
|
id="deposit"
|
|
checked={formData.agreeToDeposit}
|
|
onCheckedChange={(checked) => handleInputChange("agreeToDeposit", checked)}
|
|
required
|
|
/>
|
|
<label htmlFor="deposit" className="text-sm leading-relaxed">
|
|
I understand and agree to the deposit policy outlined above
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Navigation Buttons */}
|
|
<div className="flex justify-between mt-8">
|
|
<Button type="button" variant="outline" onClick={prevStep} disabled={step === 1}>
|
|
Previous
|
|
</Button>
|
|
|
|
{step < 4 ? (
|
|
<Button
|
|
type="button"
|
|
onClick={nextStep}
|
|
disabled={
|
|
// Disable if on step 2 and slot is not available or still checking
|
|
step === 2 && (availability.checking || !availability.available)
|
|
}
|
|
>
|
|
{step === 2 && availability.checking ? 'Checking...' : 'Next Step'}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="submit"
|
|
className="bg-primary hover:bg-primary/90"
|
|
disabled={!formData.agreeToTerms || !formData.agreeToDeposit || !bookingEnabled}
|
|
>
|
|
Submit Booking & Pay Deposit
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|