I´d like to create a booking platform for a beauty shop (nail design). the customer shall be able to book a treatment. an admin backend is needed to manage articles and their durations.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "New Project",
|
||||
"description": "",
|
||||
"icon": {
|
||||
"lucide": "square-dashed",
|
||||
"background": "conic-gradient(from 42deg at 50% 50%, #18181b, #27272a)"
|
||||
}
|
||||
"background": "conic-gradient(from 42deg at 50% 50%, #ffd60a, #26cd41)",
|
||||
"lucide": "calendar"
|
||||
},
|
||||
"name": "Beauty Shop Booking Platform"
|
||||
}
|
@@ -1,12 +1,113 @@
|
||||
import { useState } from "react";
|
||||
import { BookingForm } from "@/client/components/booking-form";
|
||||
import { AdminTreatments } from "@/client/components/admin-treatments";
|
||||
import { AdminBookings } from "@/client/components/admin-bookings";
|
||||
import { InitialDataLoader } from "@/client/components/initial-data-loader";
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings">("booking");
|
||||
|
||||
const tabs = [
|
||||
{ id: "booking", label: "Book Appointment", icon: "📅" },
|
||||
{ id: "admin-treatments", label: "Manage Treatments", icon: "💅" },
|
||||
{ id: "admin-bookings", label: "Manage Bookings", icon: "📋" },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full max-w-4xl mx-auto p-4">
|
||||
{/* Replace this placeholder content with your app components */}
|
||||
<div className="text-center mt-72">
|
||||
<h1 className="text-2xl mb-4 opacity-50">
|
||||
Building your new project...
|
||||
</h1>
|
||||
</div>
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50">
|
||||
<InitialDataLoader />
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b border-pink-100">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-3xl">💅</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Bella Nails Studio</h1>
|
||||
<p className="text-sm text-gray-600">Professional Nail Design & Care</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center space-x-2 ${
|
||||
activeTab === tab.id
|
||||
? "border-pink-500 text-pink-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span>{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{activeTab === "booking" && (
|
||||
<div>
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Book Your Perfect Nail Treatment
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
Experience professional nail care with our expert technicians.
|
||||
Choose from our wide range of treatments and book your appointment today.
|
||||
</p>
|
||||
</div>
|
||||
<BookingForm />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "admin-treatments" && (
|
||||
<div>
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Treatment Management
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600">
|
||||
Add, edit, and manage your nail treatment services.
|
||||
</p>
|
||||
</div>
|
||||
<AdminTreatments />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "admin-bookings" && (
|
||||
<div>
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Booking Management
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600">
|
||||
View and manage customer appointments and bookings.
|
||||
</p>
|
||||
</div>
|
||||
<AdminBookings />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-white border-t border-pink-100 mt-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="text-center text-gray-600">
|
||||
<p>© 2024 Bella Nails Studio. Professional nail care services.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
213
src/client/components/admin-bookings.tsx
Normal file
213
src/client/components/admin-bookings.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/client/rpc-client";
|
||||
|
||||
export function AdminBookings() {
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
|
||||
const { data: bookings } = useQuery(
|
||||
queryClient.bookings.live.list.experimental_liveOptions()
|
||||
);
|
||||
|
||||
const { data: treatments } = useQuery(
|
||||
queryClient.treatments.live.list.experimental_liveOptions()
|
||||
);
|
||||
|
||||
const { mutate: updateBookingStatus } = useMutation(
|
||||
queryClient.bookings.updateStatus.mutationOptions()
|
||||
);
|
||||
|
||||
const getTreatmentName = (treatmentId: string) => {
|
||||
return treatments?.find(t => t.id === treatmentId)?.name || "Unknown Treatment";
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "pending": return "bg-yellow-100 text-yellow-800";
|
||||
case "confirmed": return "bg-green-100 text-green-800";
|
||||
case "cancelled": return "bg-red-100 text-red-800";
|
||||
case "completed": return "bg-blue-100 text-blue-800";
|
||||
default: return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const filteredBookings = bookings?.filter(booking =>
|
||||
selectedDate ? booking.appointmentDate === selectedDate : true
|
||||
).sort((a, b) => {
|
||||
if (a.appointmentDate === b.appointmentDate) {
|
||||
return a.appointmentTime.localeCompare(b.appointmentTime);
|
||||
}
|
||||
return a.appointmentDate.localeCompare(b.appointmentDate);
|
||||
});
|
||||
|
||||
const upcomingBookings = bookings?.filter(booking => {
|
||||
const bookingDate = new Date(booking.appointmentDate);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return bookingDate >= today && booking.status !== "cancelled";
|
||||
}).sort((a, b) => {
|
||||
if (a.appointmentDate === b.appointmentDate) {
|
||||
return a.appointmentTime.localeCompare(b.appointmentTime);
|
||||
}
|
||||
return a.appointmentDate.localeCompare(b.appointmentDate);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Manage Bookings</h2>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-2xl font-bold text-pink-600">
|
||||
{bookings?.filter(b => b.status === "pending").length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Pending Approval</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{bookings?.filter(b => b.status === "confirmed").length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Confirmed</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{upcomingBookings?.length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Upcoming</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{bookings?.length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Total Bookings</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Filter */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="text-sm font-medium text-gray-700">Filter by date:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
className="p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setSelectedDate("")}
|
||||
className="text-sm text-pink-600 hover:text-pink-800"
|
||||
>
|
||||
Show All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bookings Table */}
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Customer
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Treatment
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date & Time
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredBookings?.map((booking) => (
|
||||
<tr key={booking.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{booking.customerName}</div>
|
||||
<div className="text-sm text-gray-500">{booking.customerEmail}</div>
|
||||
<div className="text-sm text-gray-500">{booking.customerPhone}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{getTreatmentName(booking.treatmentId)}</div>
|
||||
{booking.notes && (
|
||||
<div className="text-sm text-gray-500">Notes: {booking.notes}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div>{new Date(booking.appointmentDate).toLocaleDateString()}</div>
|
||||
<div>{booking.appointmentTime}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(booking.status)}`}>
|
||||
{booking.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
{booking.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateBookingStatus({ id: booking.id, status: "cancelled" })}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{booking.status === "confirmed" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateBookingStatus({ id: booking.id, status: "completed" })}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Complete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateBookingStatus({ id: booking.id, status: "cancelled" })}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(booking.status === "cancelled" || booking.status === "completed") && (
|
||||
<button
|
||||
onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
>
|
||||
Reactivate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{!filteredBookings?.length && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{selectedDate
|
||||
? `No bookings found for ${new Date(selectedDate).toLocaleDateString()}`
|
||||
: "No bookings available."
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
270
src/client/components/admin-treatments.tsx
Normal file
270
src/client/components/admin-treatments.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/client/rpc-client";
|
||||
|
||||
export function AdminTreatments() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingTreatment, setEditingTreatment] = useState<any>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
duration: 60,
|
||||
price: 5000, // $50.00 in cents
|
||||
category: "Manicure",
|
||||
});
|
||||
|
||||
const { data: treatments } = useQuery(
|
||||
queryClient.treatments.live.list.experimental_liveOptions()
|
||||
);
|
||||
|
||||
const { mutate: createTreatment, isPending: isCreating } = useMutation(
|
||||
queryClient.treatments.create.mutationOptions()
|
||||
);
|
||||
|
||||
const { mutate: updateTreatment, isPending: isUpdating } = useMutation(
|
||||
queryClient.treatments.update.mutationOptions()
|
||||
);
|
||||
|
||||
const { mutate: deleteTreatment } = useMutation(
|
||||
queryClient.treatments.remove.mutationOptions()
|
||||
);
|
||||
|
||||
const categories = ["Manicure", "Pedicure", "Nail Art", "Extensions", "Other"];
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (editingTreatment) {
|
||||
updateTreatment({
|
||||
id: editingTreatment.id,
|
||||
...formData,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setEditingTreatment(null);
|
||||
setShowForm(false);
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
createTreatment(formData, {
|
||||
onSuccess: () => {
|
||||
setShowForm(false);
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: "",
|
||||
description: "",
|
||||
duration: 60,
|
||||
price: 5000,
|
||||
category: "Manicure",
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (treatment: any) => {
|
||||
setEditingTreatment(treatment);
|
||||
setFormData({
|
||||
name: treatment.name,
|
||||
description: treatment.description,
|
||||
duration: treatment.duration,
|
||||
price: treatment.price,
|
||||
category: treatment.category,
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowForm(false);
|
||||
setEditingTreatment(null);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Manage Treatments</h2>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 font-medium"
|
||||
>
|
||||
Add Treatment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{editingTreatment ? "Edit Treatment" : "Add New Treatment"}
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Treatment Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Category *
|
||||
</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({...formData, category: e.target.value})}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
required
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({...formData, description: e.target.value})}
|
||||
rows={3}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Duration (minutes) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.duration}
|
||||
onChange={(e) => setFormData({...formData, duration: parseInt(e.target.value)})}
|
||||
min="15"
|
||||
max="480"
|
||||
step="15"
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Price ($) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.price / 100}
|
||||
onChange={(e) => setFormData({...formData, price: Math.round(parseFloat(e.target.value) * 100)})}
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isCreating || isUpdating}
|
||||
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 disabled:opacity-50 font-medium"
|
||||
>
|
||||
{isCreating || isUpdating ? "Saving..." : (editingTreatment ? "Update" : "Create")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Treatment
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Duration
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Price
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{treatments?.map((treatment) => (
|
||||
<tr key={treatment.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{treatment.name}</div>
|
||||
<div className="text-sm text-gray-500">{treatment.description}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{treatment.category}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{treatment.duration} min
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
${(treatment.price / 100).toFixed(2)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
<button
|
||||
onClick={() => handleEdit(treatment)}
|
||||
className="text-pink-600 hover:text-pink-900"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Are you sure you want to delete this treatment?")) {
|
||||
deleteTreatment(treatment.id);
|
||||
}
|
||||
}}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{!treatments?.length && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No treatments available. Add your first treatment to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
225
src/client/components/booking-form.tsx
Normal file
225
src/client/components/booking-form.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/client/rpc-client";
|
||||
|
||||
export function BookingForm() {
|
||||
const [selectedTreatment, setSelectedTreatment] = useState("");
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
const [customerEmail, setCustomerEmail] = useState("");
|
||||
const [customerPhone, setCustomerPhone] = useState("");
|
||||
const [appointmentDate, setAppointmentDate] = useState("");
|
||||
const [appointmentTime, setAppointmentTime] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const { data: treatments } = useQuery(
|
||||
queryClient.treatments.live.list.experimental_liveOptions()
|
||||
);
|
||||
|
||||
const { data: allBookings } = useQuery(
|
||||
queryClient.bookings.live.list.experimental_liveOptions()
|
||||
);
|
||||
|
||||
const selectedDateBookings = allBookings?.filter(booking =>
|
||||
booking.appointmentDate === appointmentDate
|
||||
) || [];
|
||||
|
||||
const { mutate: createBooking, isPending } = useMutation(
|
||||
queryClient.bookings.create.mutationOptions()
|
||||
);
|
||||
|
||||
const selectedTreatmentData = treatments?.find(t => t.id === selectedTreatment);
|
||||
|
||||
// Generate available time slots (9 AM to 6 PM, 30-minute intervals)
|
||||
const generateTimeSlots = () => {
|
||||
const slots: string[] = [];
|
||||
for (let hour = 9; hour < 18; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 30) {
|
||||
const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
slots.push(time);
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
};
|
||||
|
||||
const availableTimeSlots = generateTimeSlots().filter(time => {
|
||||
if (!selectedDateBookings || !selectedTreatmentData) return true;
|
||||
|
||||
// Check if this time slot conflicts with existing bookings
|
||||
const timeSlotStart = new Date(`${appointmentDate}T${time}:00`);
|
||||
const timeSlotEnd = new Date(timeSlotStart.getTime() + selectedTreatmentData.duration * 60000);
|
||||
|
||||
return !selectedDateBookings.some(booking => {
|
||||
if (booking.status === 'cancelled') return false;
|
||||
|
||||
const bookingStart = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`);
|
||||
const treatment = treatments?.find(t => t.id === booking.treatmentId);
|
||||
if (!treatment) return false;
|
||||
|
||||
const bookingEnd = new Date(bookingStart.getTime() + treatment.duration * 60000);
|
||||
|
||||
return (timeSlotStart < bookingEnd && timeSlotEnd > bookingStart);
|
||||
});
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !appointmentTime) {
|
||||
alert("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
createBooking({
|
||||
treatmentId: selectedTreatment,
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
appointmentDate,
|
||||
appointmentTime,
|
||||
notes,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setSelectedTreatment("");
|
||||
setCustomerName("");
|
||||
setCustomerEmail("");
|
||||
setCustomerPhone("");
|
||||
setAppointmentDate("");
|
||||
setAppointmentTime("");
|
||||
setNotes("");
|
||||
alert("Booking created successfully! We'll contact you to confirm your appointment.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Get minimum date (today)
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Book Your Nail Treatment</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Treatment Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Treatment *
|
||||
</label>
|
||||
<select
|
||||
value={selectedTreatment}
|
||||
onChange={(e) => setSelectedTreatment(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
required
|
||||
>
|
||||
<option value="">Choose a treatment</option>
|
||||
{treatments?.map((treatment) => (
|
||||
<option key={treatment.id} value={treatment.id}>
|
||||
{treatment.name} - ${(treatment.price / 100).toFixed(2)} ({treatment.duration} min)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedTreatmentData && (
|
||||
<p className="mt-2 text-sm text-gray-600">{selectedTreatmentData.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Customer Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Full Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={customerEmail}
|
||||
onChange={(e) => setCustomerEmail(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Phone Number *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={customerPhone}
|
||||
onChange={(e) => setCustomerPhone(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date and Time Selection */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Preferred Date *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={appointmentDate}
|
||||
onChange={(e) => setAppointmentDate(e.target.value)}
|
||||
min={today}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Preferred Time *
|
||||
</label>
|
||||
<select
|
||||
value={appointmentTime}
|
||||
onChange={(e) => setAppointmentTime(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
disabled={!appointmentDate || !selectedTreatment}
|
||||
required
|
||||
>
|
||||
<option value="">Select time</option>
|
||||
{availableTimeSlots.map((time) => (
|
||||
<option key={time} value={time}>
|
||||
{time}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Additional Notes
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
placeholder="Any special requests or information..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full bg-pink-600 text-white py-3 px-4 rounded-md hover:bg-pink-700 focus:ring-2 focus:ring-pink-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{isPending ? "Booking..." : "Book Appointment"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
69
src/client/components/initial-data-loader.tsx
Normal file
69
src/client/components/initial-data-loader.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/client/rpc-client";
|
||||
|
||||
export function InitialDataLoader() {
|
||||
const { data: treatments } = useQuery(
|
||||
queryClient.treatments.list.queryOptions()
|
||||
);
|
||||
|
||||
const { mutate: createTreatment } = useMutation(
|
||||
queryClient.treatments.create.mutationOptions()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Only initialize if no treatments exist
|
||||
if (treatments && treatments.length === 0) {
|
||||
const sampleTreatments = [
|
||||
{
|
||||
name: "Classic Manicure",
|
||||
description: "Traditional nail care with cuticle treatment, shaping, and polish application",
|
||||
duration: 45,
|
||||
price: 3500, // $35.00
|
||||
category: "Manicure"
|
||||
},
|
||||
{
|
||||
name: "Gel Manicure",
|
||||
description: "Long-lasting gel polish that cures under UV light for chip-free wear",
|
||||
duration: 60,
|
||||
price: 4500, // $45.00
|
||||
category: "Manicure"
|
||||
},
|
||||
{
|
||||
name: "Classic Pedicure",
|
||||
description: "Foot soak, exfoliation, nail care, and polish application",
|
||||
duration: 60,
|
||||
price: 4000, // $40.00
|
||||
category: "Pedicure"
|
||||
},
|
||||
{
|
||||
name: "French Manicure",
|
||||
description: "Elegant white tips with natural base for a timeless look",
|
||||
duration: 50,
|
||||
price: 4000, // $40.00
|
||||
category: "Manicure"
|
||||
},
|
||||
{
|
||||
name: "Nail Art Design",
|
||||
description: "Custom nail art with intricate designs and decorations",
|
||||
duration: 90,
|
||||
price: 6500, // $65.00
|
||||
category: "Nail Art"
|
||||
},
|
||||
{
|
||||
name: "Acrylic Extensions",
|
||||
description: "Full set of acrylic nail extensions with shape and length customization",
|
||||
duration: 120,
|
||||
price: 8000, // $80.00
|
||||
category: "Extensions"
|
||||
}
|
||||
];
|
||||
|
||||
sampleTreatments.forEach(treatment => {
|
||||
createTreatment(treatment);
|
||||
});
|
||||
}
|
||||
}, [treatments, createTreatment]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
}
|
96
src/server/rpc/bookings.ts
Normal file
96
src/server/rpc/bookings.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "@/server/lib/create-kv";
|
||||
|
||||
const BookingSchema = z.object({
|
||||
id: z.string(),
|
||||
treatmentId: z.string(),
|
||||
customerName: z.string(),
|
||||
customerEmail: z.string(),
|
||||
customerPhone: z.string(),
|
||||
appointmentDate: z.string(), // ISO date string
|
||||
appointmentTime: z.string(), // HH:MM format
|
||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
||||
notes: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
type Booking = z.output<typeof BookingSchema>;
|
||||
|
||||
const kv = createKV<Booking>("bookings");
|
||||
|
||||
const create = os
|
||||
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
||||
.handler(async ({ input }) => {
|
||||
const id = randomUUID();
|
||||
const booking = {
|
||||
id,
|
||||
...input,
|
||||
status: "pending" as const,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
await kv.setItem(id, booking);
|
||||
return booking;
|
||||
});
|
||||
|
||||
const updateStatus = os
|
||||
.input(z.object({
|
||||
id: z.string(),
|
||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"])
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
const booking = await kv.getItem(input.id);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
|
||||
const updatedBooking = { ...booking, status: input.status };
|
||||
await kv.setItem(input.id, updatedBooking);
|
||||
return updatedBooking;
|
||||
});
|
||||
|
||||
const remove = os.input(z.string()).handler(async ({ input }) => {
|
||||
await kv.removeItem(input);
|
||||
});
|
||||
|
||||
const list = os.handler(async () => {
|
||||
return kv.getAllItems();
|
||||
});
|
||||
|
||||
const get = os.input(z.string()).handler(async ({ input }) => {
|
||||
return kv.getItem(input);
|
||||
});
|
||||
|
||||
const getByDate = os
|
||||
.input(z.string()) // YYYY-MM-DD format
|
||||
.handler(async ({ input }) => {
|
||||
const allBookings = await kv.getAllItems();
|
||||
return allBookings.filter(booking => booking.appointmentDate === input);
|
||||
});
|
||||
|
||||
const live = {
|
||||
list: os.handler(async function* ({ signal }) {
|
||||
yield call(list, {}, { signal });
|
||||
for await (const _ of kv.subscribe()) {
|
||||
yield call(list, {}, { signal });
|
||||
}
|
||||
}),
|
||||
|
||||
byDate: os
|
||||
.input(z.string())
|
||||
.handler(async function* ({ input, signal }) {
|
||||
yield call(getByDate, input, { signal });
|
||||
for await (const _ of kv.subscribe()) {
|
||||
yield call(getByDate, input, { signal });
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
export const router = {
|
||||
create,
|
||||
updateStatus,
|
||||
remove,
|
||||
list,
|
||||
get,
|
||||
getByDate,
|
||||
live,
|
||||
};
|
@@ -1,5 +1,9 @@
|
||||
import { demo } from "./demo";
|
||||
import { router as treatments } from "./treatments";
|
||||
import { router as bookings } from "./bookings";
|
||||
|
||||
export const router = {
|
||||
demo,
|
||||
treatments,
|
||||
bookings,
|
||||
};
|
||||
|
63
src/server/rpc/treatments.ts
Normal file
63
src/server/rpc/treatments.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "@/server/lib/create-kv";
|
||||
|
||||
const TreatmentSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
duration: z.number(), // duration in minutes
|
||||
price: z.number(), // price in cents
|
||||
category: z.string(),
|
||||
});
|
||||
|
||||
type Treatment = z.output<typeof TreatmentSchema>;
|
||||
|
||||
const kv = createKV<Treatment>("treatments");
|
||||
|
||||
const create = os
|
||||
.input(TreatmentSchema.omit({ id: true }))
|
||||
.handler(async ({ input }) => {
|
||||
const id = randomUUID();
|
||||
const treatment = { id, ...input };
|
||||
await kv.setItem(id, treatment);
|
||||
return treatment;
|
||||
});
|
||||
|
||||
const update = os
|
||||
.input(TreatmentSchema)
|
||||
.handler(async ({ input }) => {
|
||||
await kv.setItem(input.id, input);
|
||||
return input;
|
||||
});
|
||||
|
||||
const remove = os.input(z.string()).handler(async ({ input }) => {
|
||||
await kv.removeItem(input);
|
||||
});
|
||||
|
||||
const list = os.handler(async () => {
|
||||
return kv.getAllItems();
|
||||
});
|
||||
|
||||
const get = os.input(z.string()).handler(async ({ input }) => {
|
||||
return kv.getItem(input);
|
||||
});
|
||||
|
||||
const live = {
|
||||
list: os.handler(async function* ({ signal }) {
|
||||
yield call(list, {}, { signal });
|
||||
for await (const _ of kv.subscribe()) {
|
||||
yield call(list, {}, { signal });
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
export const router = {
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
list,
|
||||
get,
|
||||
live,
|
||||
};
|
Reference in New Issue
Block a user