init commit
This commit is contained in:
@@ -27,10 +27,4 @@ To learn more about Next.js, take a look at the following resources:
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
@@ -1,15 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
@@ -23,28 +18,9 @@ body {
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.imgDark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.imgLight {
|
||||
display: none;
|
||||
}
|
||||
.imgDark {
|
||||
display: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
.page {
|
||||
--gray-rgb: 0, 0, 0;
|
||||
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
|
||||
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
|
||||
|
||||
--button-primary-hover: #383838;
|
||||
--button-secondary-hover: #f2f2f2;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: 20px 1fr 20px;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
min-height: 100svh;
|
||||
padding: 80px;
|
||||
gap: 64px;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.page {
|
||||
--gray-rgb: 255, 255, 255;
|
||||
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
|
||||
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
|
||||
|
||||
--button-primary-hover: #ccc;
|
||||
--button-secondary-hover: #1a1a1a;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
grid-row-start: 2;
|
||||
}
|
||||
|
||||
.main ol {
|
||||
font-family: var(--font-geist-mono);
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.01em;
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.main li:not(:last-of-type) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.main code {
|
||||
font-family: inherit;
|
||||
background: var(--gray-alpha-100);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ctas {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ctas a {
|
||||
appearance: none;
|
||||
border-radius: 128px;
|
||||
height: 48px;
|
||||
padding: 0 20px;
|
||||
border: none;
|
||||
font-family: var(--font-geist-sans);
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a.primary {
|
||||
background: var(--foreground);
|
||||
color: var(--background);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
a.secondary {
|
||||
border-color: var(--gray-alpha-200);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
appearance: none;
|
||||
border-radius: 128px;
|
||||
height: 48px;
|
||||
padding: 0 20px;
|
||||
border: none;
|
||||
font-family: var(--font-geist-sans);
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
border-color: var(--gray-alpha-200);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-family: var(--font-geist-sans);
|
||||
grid-row-start: 3;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.footer img {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Enable hover only on non-touch devices */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
a.primary:hover {
|
||||
background: var(--button-primary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
a.secondary:hover {
|
||||
background: var(--button-secondary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page {
|
||||
padding: 32px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.main {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main ol {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ctas {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ctas a {
|
||||
font-size: 14px;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
a.secondary {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.logo {
|
||||
filter: invert();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +1,21 @@
|
||||
import Image, { type ImageProps } from "next/image";
|
||||
import { Button } from "@repo/ui/button";
|
||||
import styles from "./page.module.css";
|
||||
|
||||
type Props = Omit<ImageProps, "src"> & {
|
||||
srcLight: string;
|
||||
srcDark: string;
|
||||
};
|
||||
|
||||
const ThemeImage = (props: Props) => {
|
||||
const { srcLight, srcDark, ...rest } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image {...rest} src={srcLight} className="imgLight" />
|
||||
<Image {...rest} src={srcDark} className="imgDark" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
import BookingInterface from "../components/BookingForm";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<main className={styles.main}>
|
||||
<ThemeImage
|
||||
className={styles.logo}
|
||||
srcLight="turborepo-dark.svg"
|
||||
srcDark="turborepo-light.svg"
|
||||
alt="Turborepo logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol>
|
||||
<li>
|
||||
Get started by editing <code>apps/web/app/page.tsx</code>
|
||||
</li>
|
||||
<li>Save and see your changes instantly.</li>
|
||||
</ol>
|
||||
|
||||
<div className={styles.ctas}>
|
||||
<div>
|
||||
<main>
|
||||
<BookingInterface />
|
||||
</main>
|
||||
<footer className="bg-white text-black py-8 mt-12">
|
||||
<div className="max-w-2xl mx-auto px-4 text-center">
|
||||
<p className="text-gray-600 mb-2">Questions? Get in touch</p>
|
||||
<a
|
||||
className={styles.primary}
|
||||
href="https://vercel.com/new/clone?demo-description=Learn+to+implement+a+monorepo+with+a+two+Next.js+sites+that+has+installed+three+local+packages.&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F4K8ZISWAzJ8X1504ca0zmC%2F0b21a1c6246add355e55816278ef54bc%2FBasic.png&demo-title=Monorepo+with+Turborepo&demo-url=https%3A%2F%2Fexamples-basic-web.vercel.sh%2F&from=templates&project-name=Monorepo+with+Turborepo&repository-name=monorepo-turborepo&repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fturborepo%2Ftree%2Fmain%2Fexamples%2Fbasic&root-directory=apps%2Fdocs&skippable-integrations=1&teamSlug=vercel&utm_source=create-turbo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="mailto:vitrify@asyavee.me"
|
||||
className="text-blue-600 hover:text-blue-800 transition-colors font-medium"
|
||||
>
|
||||
<Image
|
||||
className={styles.logo}
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
href="https://turborepo.com/docs?utm_source"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.secondary}
|
||||
>
|
||||
Read our docs
|
||||
vitrify@asyavee.me
|
||||
</a>
|
||||
</div>
|
||||
<Button appName="web" className={styles.secondary}>
|
||||
Open alert
|
||||
</Button>
|
||||
</main>
|
||||
<footer className={styles.footer}>
|
||||
<a
|
||||
href="https://vercel.com/templates?search=turborepo&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
href="https://turborepo.com?utm_source=create-turbo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to turborepo.com →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
276
apps/web/components/BookingForm.tsx
Normal file
276
apps/web/components/BookingForm.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, Clock, User, Mail, Users } from 'lucide-react';
|
||||
|
||||
const BookingInterface = () => {
|
||||
const [selectedDate, setSelectedDate] = useState('');
|
||||
const [selectedTimeSlot, setSelectedTimeSlot] = useState('');
|
||||
const [selectedBookingType, setSelectedBookingType] = useState('');
|
||||
const [customerName, setCustomerName] = useState('');
|
||||
const [customerEmail, setCustomerEmail] = useState('');
|
||||
const [participantsCount, setParticipantsCount] = useState(1);
|
||||
|
||||
// Mock data - will be replaced with API calls
|
||||
const bookingTypes = [
|
||||
{ id: '1', name: 'wheel_rental', display_name: 'Wheel Rental', requires_payment: false, price_per_person: 0 },
|
||||
{ id: '2', name: 'hand_building', display_name: 'Hand Building Coworking', requires_payment: false, price_per_person: 0 },
|
||||
{ id: '3', name: 'personal_workshop', display_name: 'Personal Workshop', requires_payment: true, price_per_person: 75 },
|
||||
{ id: '4', name: 'group_workshop', display_name: 'Group Workshop', requires_payment: true, price_per_person: 50 }
|
||||
];
|
||||
|
||||
// Mock available time slots
|
||||
const timeSlots = [
|
||||
{ id: '1', start_time: '09:00', end_time: '11:00', available: true },
|
||||
{ id: '2', start_time: '11:30', end_time: '13:30', available: true },
|
||||
{ id: '3', start_time: '14:00', end_time: '16:00', available: false },
|
||||
{ id: '4', start_time: '16:30', end_time: '18:30', available: true },
|
||||
{ id: '5', start_time: '19:00', end_time: '21:00', available: true }
|
||||
];
|
||||
|
||||
const selectedBookingTypeData = bookingTypes.find(bt => bt.id === selectedBookingType);
|
||||
|
||||
const handleSubmit = () => {
|
||||
console.log({
|
||||
selectedBookingType,
|
||||
selectedDate,
|
||||
selectedTimeSlot,
|
||||
customerName,
|
||||
customerEmail,
|
||||
participantsCount
|
||||
});
|
||||
};
|
||||
|
||||
const generateCalendarDays = () => {
|
||||
const today = new Date();
|
||||
const days = [];
|
||||
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() + i);
|
||||
days.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
day: date.getDate(),
|
||||
month: date.toLocaleDateString('en', { month: 'short' }),
|
||||
dayName: date.toLocaleDateString('en', { weekday: 'short' })
|
||||
});
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
const calendarDays = generateCalendarDays();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Book Your Session</h1>
|
||||
<p className="text-gray-600">Choose your pottery experience</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Booking Type Selection */}
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" />
|
||||
Select Experience
|
||||
</h2>
|
||||
<div className="grid gap-3">
|
||||
{bookingTypes.map((type) => (
|
||||
<label key={type.id} className="cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="bookingType"
|
||||
value={type.id}
|
||||
checked={selectedBookingType === type.id}
|
||||
onChange={(e) => setSelectedBookingType(e.target.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`p-4 rounded-lg border-2 transition-all ${
|
||||
selectedBookingType === type.id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{type.display_name}</h3>
|
||||
{type.requires_payment && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
${type.price_per_person} per person
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{type.requires_payment && (
|
||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
||||
Payment Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Selection */}
|
||||
{selectedBookingType && (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" />
|
||||
Choose Date
|
||||
</h2>
|
||||
<div className="grid grid-cols-7 gap-2 md:gap-3">
|
||||
{calendarDays.map((day) => (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
onClick={() => setSelectedDate(day.date)}
|
||||
className={`p-3 rounded-lg text-center transition-all ${
|
||||
selectedDate === day.date
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium">{day.dayName}</div>
|
||||
<div className="text-sm font-semibold">{day.day}</div>
|
||||
<div className="text-xs">{day.month}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Slot Selection */}
|
||||
{selectedDate && (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
Available Times
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{timeSlots.map((slot) => (
|
||||
<button
|
||||
key={slot.id}
|
||||
type="button"
|
||||
disabled={!slot.available}
|
||||
onClick={() => setSelectedTimeSlot(slot.id)}
|
||||
className={`p-3 rounded-lg text-center transition-all ${
|
||||
!slot.available
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: selectedTimeSlot === slot.id
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{slot.start_time} - {slot.end_time}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customer Details */}
|
||||
{selectedTimeSlot && (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<User className="w-5 h-5" />
|
||||
Your Details
|
||||
</h2>
|
||||
|
||||
<div className="space-y-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-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your full name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={customerEmail}
|
||||
onChange={(e) => setCustomerEmail(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
Number of Participants
|
||||
</label>
|
||||
<select
|
||||
value={participantsCount}
|
||||
onChange={(e) => setParticipantsCount(parseInt(e.target.value))}
|
||||
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map(num => (
|
||||
<option key={num} value={num}>{num} participant{num > 1 ? 's' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary & Submit */}
|
||||
{selectedTimeSlot && customerName && customerEmail && (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Booking Summary</h2>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Experience:</span>
|
||||
<span className="font-medium">{selectedBookingTypeData?.display_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Date:</span>
|
||||
<span className="font-medium">{selectedDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Time:</span>
|
||||
<span className="font-medium">
|
||||
{timeSlots.find(s => s.id === selectedTimeSlot)?.start_time} -
|
||||
{timeSlots.find(s => s.id === selectedTimeSlot)?.end_time}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Participants:</span>
|
||||
<span className="font-medium">{participantsCount}</span>
|
||||
</div>
|
||||
{selectedBookingTypeData?.requires_payment && (
|
||||
<div className="flex justify-between text-lg font-semibold border-t pt-3">
|
||||
<span>Total:</span>
|
||||
<span>${selectedBookingTypeData.price_per_person * participantsCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="w-full bg-blue-600 text-white py-4 px-6 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{selectedBookingTypeData?.requires_payment ? 'Continue to Payment' : 'Confirm Booking'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingInterface;
|
||||
144
apps/web/lib/pocketbase.ts
Normal file
144
apps/web/lib/pocketbase.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
import { BookingType, Resource, TimeSlot, Booking } from '@/types/booking';
|
||||
|
||||
// Initialize PocketBase client
|
||||
export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || 'http://127.0.0.1:8090');
|
||||
|
||||
// Disable auto cancellation for SSR
|
||||
pb.autoCancellation(false);
|
||||
|
||||
// API functions for booking system
|
||||
|
||||
export const bookingApi = {
|
||||
// Get all active booking types
|
||||
async getBookingTypes(): Promise<BookingType[]> {
|
||||
try {
|
||||
const records = await pb.collection('bookingTypes').getFullList<BookingType>({
|
||||
sort: 'created',
|
||||
expand: 'resources',
|
||||
});
|
||||
return records;
|
||||
} catch (error) {
|
||||
console.error('Error fetching booking types:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get available resources
|
||||
async getResources(): Promise<Resource[]> {
|
||||
try {
|
||||
const records = await pb.collection('resources').getFullList<Resource>({
|
||||
filter: 'is_active = true',
|
||||
sort: 'type',
|
||||
});
|
||||
return records;
|
||||
} catch (error) {
|
||||
console.error('Error fetching resources:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get available time slots for a specific booking type and date range
|
||||
async getAvailableTimeSlots(
|
||||
bookingTypeId: string,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<TimeSlot[]> {
|
||||
try {
|
||||
const startDateStr = startDate.toISOString().split('T')[0];
|
||||
const endDateStr = endDate.toISOString().split('T')[0];
|
||||
|
||||
const records = await pb.collection('timeSlots').getFullList<TimeSlot>({
|
||||
filter: `is_active = true && booking_types ~ "${bookingTypeId}" && start_time >= "${startDateStr}" && start_time <= "${endDateStr}"`,
|
||||
sort: 'start_time',
|
||||
});
|
||||
return records;
|
||||
} catch (error) {
|
||||
console.error('Error fetching time slots:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Check if a time slot has availability
|
||||
async checkTimeSlotAvailability(
|
||||
timeSlotId: string,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
participantsCount: number
|
||||
): Promise<{ available: boolean; currentBookings: number }> {
|
||||
try {
|
||||
// Get existing confirmed bookings for this time slot
|
||||
const existingBookings = await pb.collection('bookings').getFullList<Booking>({
|
||||
filter: `status = "confirmed" && start_time >= "${startTime}" && end_time <= "${endTime}"`,
|
||||
});
|
||||
|
||||
// Get the time slot to check max capacity
|
||||
const timeSlot = await pb.collection('timeSlots').getOne<TimeSlot>(timeSlotId);
|
||||
|
||||
const currentBookings = existingBookings.reduce((sum, booking) =>
|
||||
sum + (booking.participants_count || 0), 0
|
||||
);
|
||||
|
||||
const availableSpots = timeSlot.max_capacity - currentBookings;
|
||||
const available = availableSpots >= participantsCount;
|
||||
|
||||
return {
|
||||
available,
|
||||
currentBookings,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking availability:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Create a new booking
|
||||
async createBooking(bookingData: {
|
||||
booking_type: string;
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
participants_count: number;
|
||||
internal_notes?: string;
|
||||
}): Promise<Booking> {
|
||||
try {
|
||||
const record = await pb.collection('bookings').create<Booking>({
|
||||
...bookingData,
|
||||
status: 'confirmed',
|
||||
payment_status: 'not_required', // We'll handle payment logic later
|
||||
payment_required: false,
|
||||
});
|
||||
return record;
|
||||
} catch (error) {
|
||||
console.error('Error creating booking:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get booking by cancellation token (for cancellation flow)
|
||||
async getBookingByToken(token: string): Promise<Booking | null> {
|
||||
try {
|
||||
const records = await pb.collection('bookings').getFullList<Booking>({
|
||||
filter: `cancellation_token = "${token}"`,
|
||||
});
|
||||
return records[0] || null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching booking by token:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Cancel booking
|
||||
async cancelBooking(bookingId: string): Promise<boolean> {
|
||||
try {
|
||||
await pb.collection('bookings').update(bookingId, {
|
||||
status: 'cancelled',
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error cancelling booking:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -12,9 +12,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/ui": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "^15.5.0",
|
||||
"pocketbase": "^0.26.2",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwindcss": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
|
||||
6
apps/web/postcss.config.mjs
Normal file
6
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
79
apps/web/types/bookings.ts
Normal file
79
apps/web/types/bookings.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// types/booking.ts
|
||||
|
||||
export interface BookingType {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
requires_payment: boolean;
|
||||
default_duration: number; // in minutes
|
||||
description?: string;
|
||||
resources: string; // relation to resources collection
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
id: string;
|
||||
type: 'wheel' | 'workstation';
|
||||
capacity: number;
|
||||
is_active: boolean;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface TimeSlot {
|
||||
id: string;
|
||||
booking_types: string[]; // relation to booking types
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
is_reccuring: boolean;
|
||||
max_capacity: number;
|
||||
is_active: boolean;
|
||||
recurrence_pattern?: {
|
||||
type: string;
|
||||
days: number[];
|
||||
end_date: string;
|
||||
};
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface Booking {
|
||||
id: string;
|
||||
booking_type: string; // relation to booking type
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
internal_notes?: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
participants_count: number;
|
||||
status: 'confirmed' | 'cancelled' | 'completed';
|
||||
payment_status: 'not_required' | 'paid' | 'refunded' | 'partially_refunded' | 'pending';
|
||||
payment_required: boolean;
|
||||
cancellation_token: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface BookingFormData {
|
||||
bookingTypeId: string;
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
participantsCount: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
}
|
||||
|
||||
// For the booking flow state
|
||||
export interface BookingFlowState {
|
||||
step: 'booking-type' | 'date-time' | 'customer-details' | 'confirmation';
|
||||
selectedBookingType?: BookingType;
|
||||
selectedDate?: Date;
|
||||
selectedTimeSlot?: string; // time slot ID or time string
|
||||
customerDetails?: {
|
||||
name: string;
|
||||
email: string;
|
||||
participantsCount: number;
|
||||
notes?: string;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user