diff --git a/apps/admin/.gitignore b/apps/admin/.gitignore deleted file mode 100644 index f886745..0000000 --- a/apps/admin/.gitignore +++ /dev/null @@ -1,36 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# env files (can opt-in for commiting if needed) -.env* - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts diff --git a/apps/admin/README.md b/apps/admin/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/apps/admin/app/favicon.ico b/apps/admin/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/apps/admin/app/favicon.ico and /dev/null differ diff --git a/apps/admin/app/fonts/GeistMonoVF.woff b/apps/admin/app/fonts/GeistMonoVF.woff deleted file mode 100644 index f2ae185..0000000 Binary files a/apps/admin/app/fonts/GeistMonoVF.woff and /dev/null differ diff --git a/apps/admin/app/fonts/GeistVF.woff b/apps/admin/app/fonts/GeistVF.woff deleted file mode 100644 index 1b62daa..0000000 Binary files a/apps/admin/app/fonts/GeistVF.woff and /dev/null differ diff --git a/apps/admin/app/globals.css b/apps/admin/app/globals.css deleted file mode 100644 index 6af7ecb..0000000 --- a/apps/admin/app/globals.css +++ /dev/null @@ -1,50 +0,0 @@ -:root { - --background: #ffffff; - --foreground: #171717; -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -html, -body { - max-width: 100vw; - overflow-x: hidden; -} - -body { - color: var(--foreground); - background: var(--background); -} - -* { - 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; - } -} diff --git a/apps/admin/app/layout.tsx b/apps/admin/app/layout.tsx deleted file mode 100644 index 8469537..0000000 --- a/apps/admin/app/layout.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Metadata } from "next"; -import localFont from "next/font/local"; -import "./globals.css"; - -const geistSans = localFont({ - src: "./fonts/GeistVF.woff", - variable: "--font-geist-sans", -}); -const geistMono = localFont({ - src: "./fonts/GeistMonoVF.woff", - variable: "--font-geist-mono", -}); - -export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - {children} - - - ); -} diff --git a/apps/admin/app/page.module.css b/apps/admin/app/page.module.css deleted file mode 100644 index 3630662..0000000 --- a/apps/admin/app/page.module.css +++ /dev/null @@ -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(); - } -} diff --git a/apps/admin/app/page.tsx b/apps/admin/app/page.tsx deleted file mode 100644 index e726335..0000000 --- a/apps/admin/app/page.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import Image, { type ImageProps } from "next/image"; -import { Button } from "@repo/ui/button"; -import styles from "./page.module.css"; - -type Props = Omit & { - srcLight: string; - srcDark: string; -}; - -const ThemeImage = (props: Props) => { - const { srcLight, srcDark, ...rest } = props; - - return ( - <> - - - - ); -}; - -export default function Home() { - return ( -
-
- -
    -
  1. - Get started by editing apps/docs/app/page.tsx -
  2. -
  3. Save and see your changes instantly.
  4. -
- -
- - Vercel logomark - Deploy now - - - Read our docs - -
- -
- -
- ); -} diff --git a/apps/admin/eslint.config.js b/apps/admin/eslint.config.js deleted file mode 100644 index e8759ff..0000000 --- a/apps/admin/eslint.config.js +++ /dev/null @@ -1,4 +0,0 @@ -import { nextJsConfig } from "@repo/eslint-config/next-js"; - -/** @type {import("eslint").Linter.Config} */ -export default nextJsConfig; diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js deleted file mode 100644 index 4678774..0000000 --- a/apps/admin/next.config.js +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = {}; - -export default nextConfig; diff --git a/apps/admin/package.json b/apps/admin/package.json deleted file mode 100644 index af13a3d..0000000 --- a/apps/admin/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "docs", - "version": "0.1.0", - "type": "module", - "private": true, - "scripts": { - "dev": "next dev --turbopack --port 3001", - "build": "next build", - "start": "next start", - "lint": "next lint --max-warnings 0", - "check-types": "tsc --noEmit" - }, - "dependencies": { - "@repo/ui": "workspace:*", - "next": "^15.5.0", - "react": "^19.1.0", - "react-dom": "^19.1.0" - }, - "devDependencies": { - "@repo/eslint-config": "workspace:*", - "@repo/typescript-config": "workspace:*", - "@types/node": "^22.15.3", - "@types/react": "19.1.0", - "@types/react-dom": "19.1.1", - "eslint": "^9.33.0", - "typescript": "5.9.2" - } -} diff --git a/apps/admin/public/file-text.svg b/apps/admin/public/file-text.svg deleted file mode 100644 index 9cfb3c9..0000000 --- a/apps/admin/public/file-text.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/admin/public/globe.svg b/apps/admin/public/globe.svg deleted file mode 100644 index 4230a3d..0000000 --- a/apps/admin/public/globe.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/apps/admin/public/next.svg b/apps/admin/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/apps/admin/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/admin/public/turborepo-dark.svg b/apps/admin/public/turborepo-dark.svg deleted file mode 100644 index dae38fe..0000000 --- a/apps/admin/public/turborepo-dark.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/apps/admin/public/turborepo-light.svg b/apps/admin/public/turborepo-light.svg deleted file mode 100644 index ddea915..0000000 --- a/apps/admin/public/turborepo-light.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/apps/admin/public/vercel.svg b/apps/admin/public/vercel.svg deleted file mode 100644 index 0164ddc..0000000 --- a/apps/admin/public/vercel.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/apps/admin/public/window.svg b/apps/admin/public/window.svg deleted file mode 100644 index bbc7800..0000000 --- a/apps/admin/public/window.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json deleted file mode 100644 index 7aef056..0000000 --- a/apps/admin/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "@repo/typescript-config/nextjs.json", - "compilerOptions": { - "plugins": [ - { - "name": "next" - } - ] - }, - "include": [ - "**/*.ts", - "**/*.tsx", - "next-env.d.ts", - "next.config.js", - ".next/types/**/*.ts" - ], - "exclude": [ - "node_modules" - ] -} diff --git a/apps/pocketbase/pb_data/auxiliary.db b/apps/pocketbase/pb_data/auxiliary.db index 9879ecb..b0d5595 100644 Binary files a/apps/pocketbase/pb_data/auxiliary.db and b/apps/pocketbase/pb_data/auxiliary.db differ diff --git a/apps/pocketbase/pb_data/data.db b/apps/pocketbase/pb_data/data.db index bffdf41..6165769 100644 Binary files a/apps/pocketbase/pb_data/data.db and b/apps/pocketbase/pb_data/data.db differ diff --git a/apps/pocketbase/pb_migrations/1756217971_updated_bookingTypes.js b/apps/pocketbase/pb_migrations/1756217971_updated_bookingTypes.js new file mode 100644 index 0000000..6bccedc --- /dev/null +++ b/apps/pocketbase/pb_migrations/1756217971_updated_bookingTypes.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_43114331") + + // update collection data + unmarshal({ + "listRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_43114331") + + // update collection data + unmarshal({ + "listRule": null, + "viewRule": null + }, collection) + + return app.save(collection) +}) diff --git a/apps/pocketbase/pb_migrations/1756220188_updated_timeSlots.js b/apps/pocketbase/pb_migrations/1756220188_updated_timeSlots.js new file mode 100644 index 0000000..115beb0 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1756220188_updated_timeSlots.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_586073990") + + // update collection data + unmarshal({ + "listRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_586073990") + + // update collection data + unmarshal({ + "listRule": null, + "viewRule": null + }, collection) + + return app.save(collection) +}) diff --git a/apps/pocketbase/pb_migrations/1756241295_updated_timeSlots.js b/apps/pocketbase/pb_migrations/1756241295_updated_timeSlots.js new file mode 100644 index 0000000..8b0b815 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1756241295_updated_timeSlots.js @@ -0,0 +1,38 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_586073990") + + // update field + collection.fields.addAt(7, new Field({ + "hidden": false, + "id": "number3301820327", + "max": null, + "min": 1, + "name": "max_booking_capacity", + "onlyInt": false, + "presentable": false, + "required": true, + "system": false, + "type": "number" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_586073990") + + // update field + collection.fields.addAt(7, new Field({ + "hidden": false, + "id": "number3301820327", + "max": null, + "min": 1, + "name": "max_capacity", + "onlyInt": false, + "presentable": false, + "required": true, + "system": false, + "type": "number" + })) + + return app.save(collection) +}) diff --git a/apps/pocketbase/pb_migrations/1756241390_updated_bookingTypes.js b/apps/pocketbase/pb_migrations/1756241390_updated_bookingTypes.js new file mode 100644 index 0000000..00bd671 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1756241390_updated_bookingTypes.js @@ -0,0 +1,66 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_43114331") + + // update field + collection.fields.addAt(9, new Field({ + "hidden": false, + "id": "number1421793101", + "max": null, + "min": null, + "name": "min_participants_capacity", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + })) + + // update field + collection.fields.addAt(10, new Field({ + "hidden": false, + "id": "number3301820327", + "max": null, + "min": null, + "name": "max_participants_capacity", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_43114331") + + // update field + collection.fields.addAt(9, new Field({ + "hidden": false, + "id": "number1421793101", + "max": null, + "min": null, + "name": "min_capacity", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + })) + + // update field + collection.fields.addAt(10, new Field({ + "hidden": false, + "id": "number3301820327", + "max": null, + "min": null, + "name": "max_capacity", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + })) + + return app.save(collection) +}) diff --git a/apps/pocketbase/pb_migrations/1756242359_updated_timeSlots.js b/apps/pocketbase/pb_migrations/1756242359_updated_timeSlots.js new file mode 100644 index 0000000..515f381 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1756242359_updated_timeSlots.js @@ -0,0 +1,27 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_586073990") + + // remove field + collection.fields.removeById("number3301820327") + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_586073990") + + // add field + collection.fields.addAt(7, new Field({ + "hidden": false, + "id": "number3301820327", + "max": null, + "min": 1, + "name": "max_booking_capacity", + "onlyInt": false, + "presentable": false, + "required": true, + "system": false, + "type": "number" + })) + + return app.save(collection) +}) diff --git a/apps/pocketbase/pb_migrations/1756242388_updated_bookingTypes.js b/apps/pocketbase/pb_migrations/1756242388_updated_bookingTypes.js new file mode 100644 index 0000000..c51eac8 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1756242388_updated_bookingTypes.js @@ -0,0 +1,27 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_43114331") + + // add field + collection.fields.addAt(12, new Field({ + "hidden": false, + "id": "number2396794873", + "max": null, + "min": null, + "name": "booking_capacity", + "onlyInt": false, + "presentable": false, + "required": true, + "system": false, + "type": "number" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_43114331") + + // remove field + collection.fields.removeById("number2396794873") + + return app.save(collection) +}) diff --git a/apps/pocketbase/pb_migrations/1756243147_updated_bookings.js b/apps/pocketbase/pb_migrations/1756243147_updated_bookings.js new file mode 100644 index 0000000..c839e41 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1756243147_updated_bookings.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_986407980") + + // update collection data + unmarshal({ + "listRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_986407980") + + // update collection data + unmarshal({ + "listRule": null, + "viewRule": null + }, collection) + + return app.save(collection) +}) diff --git a/apps/pocketbase/pb_migrations/1756248097_updated_bookingTypes.js b/apps/pocketbase/pb_migrations/1756248097_updated_bookingTypes.js new file mode 100644 index 0000000..c7df331 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1756248097_updated_bookingTypes.js @@ -0,0 +1,29 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_43114331") + + // add field + collection.fields.addAt(13, new Field({ + "hidden": false, + "id": "select2363381545", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "workshop", + "rental" + ] + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_43114331") + + // remove field + collection.fields.removeById("select2363381545") + + return app.save(collection) +}) diff --git a/apps/web/app/[locale]/layout.tsx b/apps/web/app/[locale]/layout.tsx new file mode 100644 index 0000000..1da8936 --- /dev/null +++ b/apps/web/app/[locale]/layout.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from "next"; +import localFont from "next/font/local"; +import {NextIntlClientProvider} from 'next-intl'; +import {getMessages} from 'next-intl/server'; +import "../globals.css"; + +const geistSans = localFont({ + src: "../fonts/GeistVF.woff", + variable: "--font-geist-sans", +}); +const geistMono = localFont({ + src: "../fonts/GeistMonoVF.woff", + variable: "--font-geist-mono", +}); + +export const metadata: Metadata = { + title: "Vitrify - Book Your Pottery Session", + description: "Book pottery sessions and workshops", +}; + +export default async function LocaleLayout({ + children, + params +}: { + children: React.ReactNode; + params: Promise<{locale: string}>; +}) { + const {locale} = await params; + const messages = await getMessages(); + + return ( + + + + {children} + + + + ); +} \ No newline at end of file diff --git a/apps/web/app/page.module.css b/apps/web/app/[locale]/page.module.css similarity index 100% rename from apps/web/app/page.module.css rename to apps/web/app/[locale]/page.module.css diff --git a/apps/web/app/[locale]/page.tsx b/apps/web/app/[locale]/page.tsx new file mode 100644 index 0000000..3176d7f --- /dev/null +++ b/apps/web/app/[locale]/page.tsx @@ -0,0 +1,27 @@ +import BookingInterface from "../../components/BookingForm"; +import {getTranslations} from 'next-intl/server'; +import LanguageSwitcher from "../../components/LanguageSwitcher"; + +export default async function Home() { + const t = await getTranslations('footer'); + + return ( +
+
+ + +
+ +
+ ); +} diff --git a/apps/web/app/api/booking-types/[id]/available-dates/route.ts b/apps/web/app/api/booking-types/[id]/available-dates/route.ts new file mode 100644 index 0000000..9b9e43a --- /dev/null +++ b/apps/web/app/api/booking-types/[id]/available-dates/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { bookingApi } from '@/lib/pocketbase'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: bookingTypeId } = await params; + + // Get booking type details to get the actual capacity + const bookingTypes = await bookingApi.getBookingTypes(); + const bookingType = bookingTypes.find(bt => bt.id === bookingTypeId); + + if (!bookingType) { + return NextResponse.json({ error: 'Booking type not found' }, { status: 404 }); + } + + const bookingCapacity = bookingType.booking_capacity; + + // Get time slots for this booking type + const timeSlots = await bookingApi.getTimeSlotsForBookingType(bookingTypeId); + + // Generate all available dates based on recurrence patterns + const availableSlotsByDate = await bookingApi.generateAvailableTimeSlots(timeSlots); + + // Get all dates and check capacity for each + const availableDates: string[] = []; + + for (const date of Object.keys(availableSlotsByDate)) { + // Get bookings for this date + const bookings = await bookingApi.getBookingsForDate(date, [bookingTypeId]); + + // Check if any time slots have capacity available + const slotsForDate = availableSlotsByDate[date]; + const hasAvailableSlots = slotsForDate.some(slot => { + const slotStart = new Date(slot.start_time); + const slotEnd = new Date(slot.end_time); + + const overlappingBookings = bookings.filter(booking => { + const bookingStart = new Date(booking.start_time); + const bookingEnd = new Date(booking.end_time); + return bookingStart < slotEnd && bookingEnd > slotStart; + }); + + const totalParticipants = overlappingBookings.reduce((sum, booking) => + sum + (booking.participants_count || 0), 0 + ); + + return totalParticipants < bookingCapacity; + }); + + if (hasAvailableSlots) { + availableDates.push(date); + } + } + + return NextResponse.json({ + bookingTypeId, + availableDates: availableDates.sort() + }); + + } catch (error) { + console.error('Error fetching available dates:', error); + return NextResponse.json( + { error: 'Failed to fetch available dates' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/web/app/api/booking-types/[id]/time-slots/route.ts b/apps/web/app/api/booking-types/[id]/time-slots/route.ts new file mode 100644 index 0000000..1b19f3b --- /dev/null +++ b/apps/web/app/api/booking-types/[id]/time-slots/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { bookingApi } from '@/lib/pocketbase'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: bookingTypeId } = await params; + const { searchParams } = new URL(request.url); + const date = searchParams.get('date'); + + if (!date) { + return NextResponse.json({ error: 'Date parameter required' }, { status: 400 }); + } + + // Get booking type details to get the actual capacity + const bookingTypes = await bookingApi.getBookingTypes(); + const bookingType = bookingTypes.find(bt => bt.id === bookingTypeId); + + if (!bookingType) { + return NextResponse.json({ error: 'Booking type not found' }, { status: 404 }); + } + + const bookingCapacity = bookingType.booking_capacity; + + // Get time slots for this booking type + const timeSlots = await bookingApi.getTimeSlotsForBookingType(bookingTypeId); + + // Generate available slots for all dates + const availableSlotsByDate = await bookingApi.generateAvailableTimeSlots(timeSlots); + const slotsForDate = availableSlotsByDate[date] || []; + + if (slotsForDate.length === 0) { + return NextResponse.json({ + date, + bookingTypeId, + timeSlots: [] + }); + } + + // Get existing bookings for this date + const bookings = await bookingApi.getBookingsForDate(date, [bookingTypeId]); + console.log(`\n=== Bookings for ${date} and booking type ${bookingTypeId} ===`); + console.log('Number of bookings found:', bookings.length); + bookings.forEach((booking, i) => { + console.log(`Booking ${i+1}: ${booking.start_time} - ${booking.end_time}, participants: ${booking.participants_count}`); + }); + + // Calculate capacity for each slot + const slotsWithCapacity = slotsForDate.map((slot, index) => { + const slotStart = new Date(slot.start_time); + const slotEnd = new Date(slot.end_time); + + console.log(`\n=== Checking slot ${slot.start_time} - ${slot.end_time} ===`); + console.log('Available bookings for date:', bookings.length); + + const overlappingBookings = bookings.filter(booking => { + const bookingStart = new Date(booking.start_time); + const bookingEnd = new Date(booking.end_time); + const overlaps = bookingStart < slotEnd && bookingEnd > slotStart; + + console.log(`Booking ${booking.start_time} - ${booking.end_time}: overlaps = ${overlaps}`); + return overlaps; + }); + + const totalParticipants = overlappingBookings.reduce((sum, booking) => + sum + (booking.participants_count || 0), 0 + ); + + const availableCapacity = Math.max(0, bookingCapacity - totalParticipants); + + console.log(`Total participants: ${totalParticipants}, Capacity: ${bookingCapacity}, Available: ${availableCapacity}`); + + return { + id: `slot-${date}-${index}`, + start_time: slot.start_time, + end_time: slot.end_time, + availableCapacity, + totalBookings: totalParticipants, + maxCapacity: bookingCapacity, + is_active: availableCapacity > 0 + }; + }); + + // Filter out fully booked slots + const availableTimeSlots = slotsWithCapacity.filter(slot => slot.availableCapacity > 0); + + return NextResponse.json({ + date, + bookingTypeId, + timeSlots: availableTimeSlots + }); + + } catch (error) { + console.error('Error fetching time slots:', error); + return NextResponse.json( + { error: 'Failed to fetch time slots' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8469537..8d50d18 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -12,17 +12,17 @@ const geistMono = localFont({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Vitrify - Book Your Pottery Session", + description: "Book pottery sessions and workshops", }; export default function RootLayout({ children, -}: Readonly<{ +}: { children: React.ReactNode; -}>) { +}) { return ( - + {children} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index e1000e2..ff1861d 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,22 +1,5 @@ -import BookingInterface from "../components/BookingForm"; +import {redirect} from 'next/navigation'; -export default function Home() { - return ( -
-
- -
- -
- ); -} +export default function RootPage() { + redirect('/en'); +} \ No newline at end of file diff --git a/apps/web/components/BookingForm.tsx b/apps/web/components/BookingForm.tsx index 738d227..e433f95 100644 --- a/apps/web/components/BookingForm.tsx +++ b/apps/web/components/BookingForm.tsx @@ -1,271 +1,246 @@ -'use client' +'use client'; -import React, { useState } from 'react'; -import { Calendar, Clock, User, Mail, Users } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslations } from 'next-intl'; +import { BookingService } from '@/lib/bookingService'; +import { BookingType, TimeSlot } from '@/types/bookings'; +import BookingTypeSelector from './BookingTypeSelector'; +import DateSelector from './DateSelector'; +import TimeSlotSelector from './TimeSlotSelector'; +import CustomerDetails from './CustomerDetails'; +import BookingSummary from './BookingSummary'; + +interface FormData { + customerName: string; + customerEmail: string; + participantsCount: number; +} const BookingInterface = () => { + const t = useTranslations('bookingForm'); 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); + const [bookingTypes, setBookingTypes] = useState([]); + const [timeSlots, setTimeSlots] = useState([]); + const [availableDates, setAvailableDates] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - // 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 { register, handleSubmit, formState: { errors, isValid }, setValue, trigger } = useForm({ + mode: 'onChange', + defaultValues: { + customerName: '', + customerEmail: '', + participantsCount: 1 + } + }); const selectedBookingTypeData = bookingTypes.find(bt => bt.id === selectedBookingType); - const handleSubmit = () => { - console.log({ - selectedBookingType, - selectedDate, - selectedTimeSlot, - customerName, - customerEmail, - participantsCount - }); - }; + // Fetch booking types on mount + useEffect(() => { + const fetchBookingTypes = async () => { + try { + setLoading(true); + setError(null); + const types = await BookingService.getBookingTypes(); + setBookingTypes(types); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load booking types'); + } finally { + setLoading(false); + } + }; - const generateCalendarDays = () => { - const today = new Date(); - const days = []; + fetchBookingTypes(); + }, []); + + // Available dates are now handled in handleBookingTypeChange + + // Time slots are now handled in handleDateChange + + useEffect(() => { + setValue('participantsCount', participantsCount); + trigger('participantsCount'); + }, [participantsCount, setValue, trigger]); + + const onSubmit = (data: FormData) => { + const selectedTimeSlotData = timeSlots.find(slot => slot.id === selectedTimeSlot); - 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 formData = { + bookingTypeId: selectedBookingType, + customerName: data.customerName, + customerEmail: data.customerEmail, + startTime: selectedDate && selectedTimeSlotData + ? `${selectedDate}T${selectedTimeSlotData.start_time}` + : '', + endTime: selectedDate && selectedTimeSlotData + ? `${selectedDate}T${selectedTimeSlotData.end_time}` + : '', + participantsCount: data.participantsCount, + }; + console.log(formData); }; - const calendarDays = generateCalendarDays(); + const handleParticipantsCountChange = (count: number) => { + setParticipantsCount(count); + }; + + const handleBookingTypeChange = async (typeId: string) => { + setSelectedBookingType(typeId); + setSelectedDate(''); + setSelectedTimeSlot(''); + setTimeSlots([]); + + // Set participants count to booking type's minimum capacity + const bookingType = bookingTypes.find(bt => bt.id === typeId); + if (bookingType) { + setParticipantsCount(bookingType.min_participants_capacity); + } + + // Fetch available dates from server API (with capacity pre-calculated) + try { + const response = await fetch(`/api/booking-types/${typeId}/available-dates`); + const data = await response.json(); + + if (response.ok) { + setAvailableDates(data.availableDates); + } else { + console.error('Error fetching available dates:', data.error); + setAvailableDates([]); + } + } catch (error) { + console.error('Error fetching available dates:', error); + setAvailableDates([]); + } + }; + + const handleDateChange = async (date: string) => { + setSelectedDate(date); + setSelectedTimeSlot(''); + + // Fetch time slots with capacity from server API + try { + const response = await fetch(`/api/booking-types/${selectedBookingType}/time-slots?date=${date}`); + const data = await response.json(); + + if (response.ok) { + // Convert server response to TimeSlot format + const formattedTimeSlots: TimeSlot[] = data.timeSlots.map((slot: any) => ({ + id: slot.id, + start_time: slot.start_time, + end_time: slot.end_time, + is_active: slot.is_active, + booking_capacity: slot.maxCapacity, + booking_types: [selectedBookingType], + is_reccuring: false, + recurrence_pattern: undefined, + resources: [], + created: new Date().toISOString(), + updated: new Date().toISOString(), + availableCapacity: slot.availableCapacity // Add capacity info for UI + })); + + setTimeSlots(formattedTimeSlots); + } else { + console.error('Error fetching time slots:', data.error); + setTimeSlots([]); + } + } catch (error) { + console.error('Error fetching time slots:', error); + setTimeSlots([]); + } + }; return (
{/* Header */}
-

Book Your Session

-

Choose your pottery experience

+

{t('title')}

+

{t('subtitle')}

- {/* Booking Type Selection */} -
-

- - Select Experience -

-
- {bookingTypes.map((type) => ( - - ))} + {/* Loading State */} + {loading && ( +
+
+

{t('loading')}

-
+ )} + + {/* Error State */} + {error && ( +
+
⚠️
+

{t('noBookings')}

+

{error}

+
+ )} + + {/* Booking Type Selection */} + {!loading && !error && bookingTypes.length === 0 && ( +
+

{t('noBookings')}

+

{t('checkBackLater')}

+
+ )} + + {!loading && !error && bookingTypes.length > 0 && ( + + )} {/* Date Selection */} {selectedBookingType && ( -
-

- - Choose Date -

-
- {calendarDays.map((day) => ( - - ))} -
-
+ )} {/* Time Slot Selection */} {selectedDate && ( -
-

- - Available Times -

-
- {timeSlots.map((slot) => ( - - ))} -
-
+ )} {/* Customer Details */} - {selectedTimeSlot && ( -
-

- - Your Details -

+ {selectedTimeSlot && selectedBookingTypeData && ( +
+ -
-
- - 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" + {/* Summary & Submit */} + {isValid && ( +
+
- -
- - 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" - /> -
- -
- - -
-
-
- )} - - {/* Summary & Submit */} - {selectedTimeSlot && customerName && customerEmail && ( -
-

Booking Summary

- -
-
- Experience: - {selectedBookingTypeData?.display_name} -
-
- Date: - {selectedDate} -
-
- Time: - - {timeSlots.find(s => s.id === selectedTimeSlot)?.start_time} - - {timeSlots.find(s => s.id === selectedTimeSlot)?.end_time} - -
-
- Participants: - {participantsCount} -
- {selectedBookingTypeData?.requires_payment && ( -
- Total: - ${selectedBookingTypeData.price_per_person * participantsCount} -
- )} -
- - -
+ )} + )}
diff --git a/apps/web/components/BookingSummary.tsx b/apps/web/components/BookingSummary.tsx new file mode 100644 index 0000000..35b823c --- /dev/null +++ b/apps/web/components/BookingSummary.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { BookingType, TimeSlot } from '@/types/bookings'; + +interface BookingSummaryProps { + selectedBookingTypeData: BookingType | undefined; + selectedDate: string; + selectedTimeSlot: string; + timeSlots: TimeSlot[]; + participantsCount: number; + onSubmit: () => void; +} + +const BookingSummary: React.FC = ({ + selectedBookingTypeData, + selectedDate, + selectedTimeSlot, + timeSlots, + participantsCount, + onSubmit, +}) => { + const selectedTimeSlotData = timeSlots.find(s => s.id === selectedTimeSlot); + + // Format time to display in Tbilisi timezone (UTC+4) + const formatTime = (time: string) => { + if (time.match(/^\d{2}:\d{2}$/)) { + return time; + } + if (time.includes('T') || time.includes(' ') || time.includes('Z')) { + const date = new Date(time); + return date.toLocaleTimeString('en-US', { + timeZone: 'Asia/Tbilisi', + hour12: false, + hour: '2-digit', + minute: '2-digit' + }); + } + return time; + }; + + return ( +
+

Booking Summary

+ +
+
+ Experience: + {selectedBookingTypeData?.display_name} +
+
+ Date: + {selectedDate} +
+
+ Time: + + {selectedTimeSlotData?.start_time && selectedTimeSlotData?.end_time + ? `${formatTime(selectedTimeSlotData.start_time)}—${formatTime(selectedTimeSlotData.end_time)}` + : 'Not selected' + } + +
+
+ Participants: + {participantsCount} +
+ {selectedBookingTypeData?.requires_payment && ( +
+ Total: + ₾{selectedBookingTypeData.price_per_person * participantsCount} +
+ )} +
+ + +
+ ); +}; + +export default BookingSummary; \ No newline at end of file diff --git a/apps/web/components/BookingTypeSelector.tsx b/apps/web/components/BookingTypeSelector.tsx new file mode 100644 index 0000000..1ad76ee --- /dev/null +++ b/apps/web/components/BookingTypeSelector.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Calendar } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { BookingType } from '@/types/bookings'; + +interface BookingTypeSelectorProps { + bookingTypes: BookingType[]; + selectedBookingType: string; + onBookingTypeChange: (typeId: string) => void; +} + +const BookingTypeSelector: React.FC = ({ + bookingTypes, + selectedBookingType, + onBookingTypeChange, +}) => { + const t = useTranslations('bookingForm'); + + return ( +
+

+ + {t('selectExperience')} +

+
+ {bookingTypes.map((type) => ( + + ))} +
+
+ ); +}; + +export default BookingTypeSelector; \ No newline at end of file diff --git a/apps/web/components/CustomerDetails.tsx b/apps/web/components/CustomerDetails.tsx new file mode 100644 index 0000000..0e3cb8c --- /dev/null +++ b/apps/web/components/CustomerDetails.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { User, Users } from 'lucide-react'; +import { UseFormRegister, FieldErrors } from 'react-hook-form'; +import { useTranslations } from 'next-intl'; +import { BookingType } from '@/types/bookings'; + +interface FormData { + customerName: string; + customerEmail: string; + participantsCount: number; +} + +interface CustomerDetailsProps { + register: UseFormRegister; + errors: FieldErrors; + participantsCount: number; + onParticipantsCountChange: (count: number) => void; + bookingType: BookingType; +} + +const CustomerDetails: React.FC = ({ + register, + errors, + participantsCount, + onParticipantsCountChange, + bookingType, +}) => { + const t = useTranslations('bookingForm'); + + // Calculate min and max based on booking type + const minParticipants = bookingType.min_participants_capacity; + const maxParticipants = bookingType.max_participants_capacity; + return ( +
+

+ + Your Details +

+ +
+
+ + + {errors.customerName && ( +

{errors.customerName.message}

+ )} +
+ +
+ + + {errors.customerEmail && ( +

{errors.customerEmail.message}

+ )} +
+ +
+ +
+ +
+ {participantsCount} +
participant{participantsCount > 1 ? 's' : ''}
+
+ +
+ +
+ Min: {minParticipants} • Max: {maxParticipants} +
+ + +
+
+
+ ); +}; + +export default CustomerDetails; \ No newline at end of file diff --git a/apps/web/components/DateSelector.tsx b/apps/web/components/DateSelector.tsx new file mode 100644 index 0000000..f5ae5c1 --- /dev/null +++ b/apps/web/components/DateSelector.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { Calendar } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +interface CalendarDay { + date: string; + day: number; + month: string; + dayName: string; + available: boolean; +} + +interface DateSelectorProps { + selectedDate: string; + availableDates: string[]; + onDateChange: (date: string) => void; + loading?: boolean; +} + +const DateSelector: React.FC = ({ + selectedDate, + availableDates, + onDateChange, + loading = false, +}) => { + const t = useTranslations('bookingForm'); + + const generateCalendarDays = (): CalendarDay[] => { + const today = new Date(); + const days: CalendarDay[] = []; + + // Show 30 days instead of 14 for better selection + for (let i = 0; i < 30; i++) { + const date = new Date(today); + date.setDate(today.getDate() + i); + const dateString = date.toISOString().split('T')[0] || ''; + + // Create date object from the ISO string to avoid timezone issues + const displayDate = new Date(dateString + 'T12:00:00.000Z'); + + days.push({ + date: dateString, + day: parseInt(dateString.split('-')[2]), + month: displayDate.toLocaleDateString('en', { month: 'short', timeZone: 'UTC' }), + dayName: displayDate.toLocaleDateString('en', { weekday: 'short', timeZone: 'UTC' }), + available: availableDates.includes(dateString) + }); + } + return days; + }; + + const calendarDays = generateCalendarDays(); + + return ( +
+

+ + {t('chooseDate')} +

+ + {loading ? ( +
+
+ Loading available dates... +
+ ) : ( +
+ {calendarDays.map((day) => ( + + ))} +
+ )} +
+ ); +}; + +export default DateSelector; \ No newline at end of file diff --git a/apps/web/components/LanguageSwitcher.tsx b/apps/web/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..6f03fb0 --- /dev/null +++ b/apps/web/components/LanguageSwitcher.tsx @@ -0,0 +1,30 @@ +'use client'; + +import {useLocale, useTranslations} from 'next-intl'; +import {usePathname, useRouter} from 'next/navigation'; +import {Globe} from 'lucide-react'; + +export default function LanguageSwitcher() { + const locale = useLocale(); + const t = useTranslations('language'); + const router = useRouter(); + const pathname = usePathname(); + + const switchLocale = () => { + const newLocale = locale === 'en' ? 'ka' : 'en'; + const newPath = pathname.replace(`/${locale}`, `/${newLocale}`); + router.push(newPath); + }; + + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/components/TimeSlotSelector.tsx b/apps/web/components/TimeSlotSelector.tsx new file mode 100644 index 0000000..56d262b --- /dev/null +++ b/apps/web/components/TimeSlotSelector.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { Clock } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { TimeSlot } from '@/types/bookings'; + +interface TimeSlotSelectorProps { + timeSlots: (TimeSlot & { availableCapacity?: number })[]; + selectedTimeSlot: string; + onTimeSlotChange: (slotId: string) => void; + loading?: boolean; + error?: string | null; +} + +const TimeSlotSelector: React.FC = ({ + timeSlots, + selectedTimeSlot, + onTimeSlotChange, + loading = false, + error = null, +}) => { + const t = useTranslations('bookingForm'); + + return ( +
+

+ + {t('availableTimes')} +

+ + {loading ? ( +
+
+ Loading available times... +
+ ) : error ? ( +
+

{error}

+
+ ) : timeSlots.length === 0 ? ( +
+

No available time slots for this date

+
+ ) : ( +
+ {timeSlots + .sort((a, b) => a.start_time.localeCompare(b.start_time)) + .map((slot) => { + const isAvailable = slot.is_active; + + // Format time to display as '11:30—12:30' in Tbilisi timezone (UTC+4) + const formatTime = (time: string) => { + // If it's already in HH:MM format, return as is + if (time.match(/^\d{2}:\d{2}$/)) { + return time; + } + // If it's ISO format or contains date, extract time portion in Tbilisi timezone + if (time.includes('T') || time.includes(' ') || time.includes('Z')) { + const date = new Date(time); + return date.toLocaleTimeString('en-US', { + timeZone: 'Asia/Tbilisi', + hour12: false, + hour: '2-digit', + minute: '2-digit' + }); + } + return time; + }; + + const formattedTime = `${formatTime(slot.start_time)}—${formatTime(slot.end_time)}`; + + return ( + + ); + })} +
+ )} +
+ ); +}; + +export default TimeSlotSelector; \ No newline at end of file diff --git a/apps/web/lib/bookingService.ts b/apps/web/lib/bookingService.ts new file mode 100644 index 0000000..76789e1 --- /dev/null +++ b/apps/web/lib/bookingService.ts @@ -0,0 +1,15 @@ +import { bookingApi } from './pocketbase'; +import { BookingType } from '@/types/bookings'; + +export class BookingService { + static async getBookingTypes(): Promise { + try { + const bookingTypes = await bookingApi.getBookingTypes(); + return bookingTypes.filter(type => type.is_active); + } catch (error) { + console.error('Failed to fetch booking types:', error); + throw new Error('Unable to load booking options. Please try again later.'); + } + } + +} \ No newline at end of file diff --git a/apps/web/lib/i18n.ts b/apps/web/lib/i18n.ts new file mode 100644 index 0000000..5bcd23a --- /dev/null +++ b/apps/web/lib/i18n.ts @@ -0,0 +1,16 @@ +import {getRequestConfig} from 'next-intl/server'; + +export default getRequestConfig(async ({requestLocale}) => { + // This typically corresponds to the `[locale]` segment + let locale = await requestLocale; + + // Ensure that a valid locale is used + if (!locale || !['en', 'ka'].includes(locale)) { + locale = 'en'; + } + + return { + locale, + messages: (await import(`../messages/${locale}.json`)).default + }; +}); \ No newline at end of file diff --git a/apps/web/lib/pocketbase.ts b/apps/web/lib/pocketbase.ts index 21471af..7c7233e 100644 --- a/apps/web/lib/pocketbase.ts +++ b/apps/web/lib/pocketbase.ts @@ -1,5 +1,5 @@ import PocketBase from 'pocketbase'; -import { BookingType, Resource, TimeSlot, Booking } from '@/types/booking'; +import { BookingType, TimeSlot, Booking } from '@/types/bookings'; // Initialize PocketBase client export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || 'http://127.0.0.1:8090'); @@ -8,7 +8,6 @@ export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || 'http pb.autoCancellation(false); // API functions for booking system - export const bookingApi = { // Get all active booking types async getBookingTypes(): Promise { @@ -24,121 +23,125 @@ export const bookingApi = { } }, - // Get available resources - async getResources(): Promise { + // Get time slots for a specific booking type + async getTimeSlotsForBookingType(bookingTypeId: string): Promise { try { - const records = await pb.collection('resources').getFullList({ - filter: 'is_active = true', - sort: 'type', - }); - return records; - } catch (error) { - console.error('Error fetching resources:', error); - throw error; - } - }, + const today = new Date(); + const oneMonthFromToday = new Date(); + oneMonthFromToday.setMonth(today.getMonth() + 1); - // Get available time slots for a specific booking type and date range - async getAvailableTimeSlots( - bookingTypeId: string, - startDate: Date, - endDate: Date - ): Promise { - try { - const startDateStr = startDate.toISOString().split('T')[0]; - const endDateStr = endDate.toISOString().split('T')[0]; + const todayStr = today.toISOString().split('T')[0]; + const oneMonthStr = oneMonthFromToday.toISOString().split('T')[0]; const records = await pb.collection('timeSlots').getFullList({ - filter: `is_active = true && booking_types ~ "${bookingTypeId}" && start_time >= "${startDateStr}" && start_time <= "${endDateStr}"`, + filter: `is_active = true && booking_types ~ "${bookingTypeId}" && start_time >= "${todayStr}" && end_time <= "${oneMonthStr}"`, sort: 'start_time', }); return records; } catch (error) { - console.error('Error fetching time slots:', error); + console.error('Error fetching time slots for booking type:', 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({ - filter: `status = "confirmed" && start_time >= "${startTime}" && end_time <= "${endTime}"`, - }); + // Generate available time slots grouped by date + generateAvailableTimeSlots(timeSlots: TimeSlot[]): { [date: string]: { start_time: string; end_time: string }[] } { + const today = new Date(); + const oneMonthFromToday = new Date(); + oneMonthFromToday.setMonth(today.getMonth() + 1); - // Get the time slot to check max capacity - const timeSlot = await pb.collection('timeSlots').getOne(timeSlotId); + // Step 1: Generate massive array of individual time slots with specific dates + const allTimeSlots: { start_time: string; end_time: string }[] = []; - const currentBookings = existingBookings.reduce((sum, booking) => - sum + (booking.participants_count || 0), 0 - ); + timeSlots.forEach(slot => { + if (!slot.is_reccuring || !slot.recurrence_pattern) { + // Handle non-recurring slots - use as is + allTimeSlots.push({ + start_time: slot.start_time, + end_time: slot.end_time + }); + return; + } - const availableSpots = timeSlot.max_capacity - currentBookings; - const available = availableSpots >= participantsCount; + // Handle recurring slots - generate dates based on recurrence pattern + const pattern = slot.recurrence_pattern; + const endDate = pattern.end_date ? new Date(pattern.end_date) : oneMonthFromToday; + const finalEndDate = endDate < oneMonthFromToday ? endDate : oneMonthFromToday; - return { - available, - currentBookings, - }; - } catch (error) { - console.error('Error checking availability:', error); - throw error; - } + // Extract time from original start_time and end_time + const originalStartTime = new Date(slot.start_time); + const originalEndTime = new Date(slot.end_time); + const startHours = originalStartTime.getHours(); + const startMinutes = originalStartTime.getMinutes(); + const endHours = originalEndTime.getHours(); + const endMinutes = originalEndTime.getMinutes(); + + let currentDate = new Date(today); + + while (currentDate <= finalEndDate) { + const dayOfWeek = currentDate.getDay(); + // Convert Sunday (0) to 6, Monday (1) to 0, etc. to match pattern format + const patternDay = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + + if (pattern.days && pattern.days.includes(patternDay)) { + // Create specific datetime for this occurrence + const specificStartTime = new Date(currentDate); + specificStartTime.setHours(startHours, startMinutes, 0, 0); + + const specificEndTime = new Date(currentDate); + specificEndTime.setHours(endHours, endMinutes, 0, 0); + + allTimeSlots.push({ + start_time: specificStartTime.toISOString(), + end_time: specificEndTime.toISOString() + }); + } + + currentDate.setDate(currentDate.getDate() + 1); + } + }); + + // Step 2: Group time slots by date (without time) + const availableSlotsByDate: { [date: string]: { start_time: string; end_time: string }[] } = {}; + + allTimeSlots.forEach(timeSlot => { + const dateStr = timeSlot.start_time.split('T')[0]; + + if (dateStr) { + if (!availableSlotsByDate[dateStr]) { + availableSlotsByDate[dateStr] = []; + } + availableSlotsByDate[dateStr].push(timeSlot); + } + }); + + // Step 3: Sort time slots within each date + Object.keys(availableSlotsByDate).forEach(date => { + availableSlotsByDate[date]?.sort((a, b) => a.start_time.localeCompare(b.start_time)); + }); + + return availableSlotsByDate; }, - // 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 { + // Get all bookings for a specific date filtered by booking type IDs + async getBookingsForDate( + date: string, + bookingTypeIds: string[] + ): Promise { try { - const record = await pb.collection('bookings').create({ - ...bookingData, - status: 'confirmed', - payment_status: 'not_required', // We'll handle payment logic later - payment_required: false, + // Create filter for booking type IDs + const bookingTypeFilter = bookingTypeIds.map(id => `booking_type = "${id}"`).join(' || '); + + const bookings = await pb.collection('bookings').getFullList({ + filter: `status = "confirmed" && start_time ~ "${date}" && (${bookingTypeFilter})`, + sort: 'start_time' }); - return record; + + console.log(`Bookings for ${date}:`, bookings); + return bookings; } catch (error) { - console.error('Error creating booking:', error); - throw error; - } - }, - - // Get booking by cancellation token (for cancellation flow) - async getBookingByToken(token: string): Promise { - try { - const records = await pb.collection('bookings').getFullList({ - 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 { - try { - await pb.collection('bookings').update(bookingId, { - status: 'cancelled', - }); - return true; - } catch (error) { - console.error('Error cancelling booking:', error); - return false; + console.error('Error fetching bookings for date:', error); + return []; } }, }; \ No newline at end of file diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json new file mode 100644 index 0000000..11f1f40 --- /dev/null +++ b/apps/web/messages/en.json @@ -0,0 +1,67 @@ +{ + "bookingForm": { + "title": "Book Your Session", + "subtitle": "Choose your pottery experience", + "loading": "Loading booking options...", + "noBookings": "No bookings available at the moment", + "checkBackLater": "Please check back later", + "selectExperience": "Select Experience", + "chooseDate": "Choose Date", + "availableTimes": "Available Times", + "yourDetails": "Your Details", + "bookingSummary": "Booking Summary", + "fullName": "Full Name", + "fullNameRequired": "Full name is required", + "nameMinLength": "Name must be at least 2 characters", + "emailAddress": "Email Address", + "emailRequired": "Email address is required", + "emailInvalid": "Please enter a valid email address", + "participants": "Number of Participants", + "participant": "participant", + "participantsPlural": "participants", + "participantsMin": "At least 1 participant required", + "participantsMax": "Maximum 8 participants allowed", + "experience": "Experience", + "date": "Date", + "time": "Time", + "total": "Total", + "paymentRequired": "Payment Required", + "continueToPayment": "Continue to Payment", + "confirmBooking": "Confirm Booking", + "perPerson": "per person" + }, + "footer": { + "getInTouch": "Get in touch" + }, + "language": { + "switchTo": "Switch to Georgian", + "current": "English" + }, + "weekdays": { + "short": { + "sun": "Sun", + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat" + } + }, + "months": { + "short": { + "jan": "Jan", + "feb": "Feb", + "mar": "Mar", + "apr": "Apr", + "may": "May", + "jun": "Jun", + "jul": "Jul", + "aug": "Aug", + "sep": "Sep", + "oct": "Oct", + "nov": "Nov", + "dec": "Dec" + } + } +} \ No newline at end of file diff --git a/apps/web/messages/ka.json b/apps/web/messages/ka.json new file mode 100644 index 0000000..d46b216 --- /dev/null +++ b/apps/web/messages/ka.json @@ -0,0 +1,67 @@ +{ + "bookingForm": { + "title": "დაჯავშნე შენი სესია", + "subtitle": "აირჩიე ხელოვნების გამოცდილება", + "loading": "იტვირთება ჯავშნის ვარიანტები...", + "noBookings": "ამჟამად ჯავშნები არ არის ხელმისაწვდომი", + "checkBackLater": "გთხოვთ შეამოწმოთ მოგვიანებით", + "selectExperience": "აირჩიე გამოცდილება", + "chooseDate": "აირჩიე თარიღი", + "availableTimes": "ხელმისაწვდომი დრო", + "yourDetails": "შენი დეტალები", + "bookingSummary": "ჯავშნის რეზიუმე", + "fullName": "სრული სახელი", + "fullNameRequired": "სრული სახელი აუცილებელია", + "nameMinLength": "სახელი უნდა იყოს მინიმუმ 2 სიმბოლო", + "emailAddress": "ელ. ფოსტის მისამართი", + "emailRequired": "ელ. ფოსტის მისამართი აუცილებელია", + "emailInvalid": "გთხოვთ შეიყვანოთ სწორი ელ. ფოსტის მისამართი", + "participants": "მონაწილეთა რაოდენობა", + "participant": "მონაწილე", + "participantsPlural": "მონაწილე", + "participantsMin": "საჭიროა მინიმუმ 1 მონაწილე", + "participantsMax": "მაქსიმუმ 8 მონაწილე", + "experience": "გამოცდილება", + "date": "თარიღი", + "time": "დრო", + "total": "სულ", + "paymentRequired": "გადახდა საჭიროა", + "continueToPayment": "გადაიდი გადახდაზე", + "confirmBooking": "დაადასტურე ჯავშანი", + "perPerson": "ერთ ადამიანზე" + }, + "footer": { + "getInTouch": "დაგვიკავშირდი" + }, + "language": { + "switchTo": "გადაიარე ინგლისურზე", + "current": "ქართული" + }, + "weekdays": { + "short": { + "sun": "კვი", + "mon": "ორშ", + "tue": "სამ", + "wed": "ოთხ", + "thu": "ხუთ", + "fri": "პარ", + "sat": "შაბ" + } + }, + "months": { + "short": { + "jan": "იან", + "feb": "თებ", + "mar": "მარ", + "apr": "აპრ", + "may": "მაი", + "jun": "ივნ", + "jul": "ივლ", + "aug": "აგვ", + "sep": "სექ", + "oct": "ოქტ", + "nov": "ნოე", + "dec": "დეკ" + } + } +} \ No newline at end of file diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts new file mode 100644 index 0000000..1889214 --- /dev/null +++ b/apps/web/middleware.ts @@ -0,0 +1,22 @@ +import createMiddleware from 'next-intl/middleware'; + +export default createMiddleware({ + // A list of all locales that are supported + locales: ['en', 'ka'], + + // Used when no locale matches + defaultLocale: 'en', + + // Always redirect to locale-prefixed paths + localePrefix: 'always' +}); + +export const config = { + // Match all pathnames except for + // - api routes + // - _next (Next.js internal) + // - _vercel (Vercel internal) + // - all files with extensions + matcher: ['/((?!api|_next|_vercel|.*\\..*).*)', '/'], + runtime: 'nodejs' +}; \ No newline at end of file diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 4678774..03b6b21 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,4 +1,8 @@ +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin('./lib/i18n.ts'); + /** @type {import('next').NextConfig} */ const nextConfig = {}; - -export default nextConfig; + +export default withNextIntl(nextConfig); diff --git a/apps/web/package.json b/apps/web/package.json index 7b5c546..94504b4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,10 +15,12 @@ "@tailwindcss/postcss": "^4.1.12", "lucide-react": "^0.542.0", "next": "^15.5.0", + "next-intl": "^4.3.5", "pocketbase": "^0.26.2", "postcss": "^8.5.6", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-hook-form": "^7.62.0", "tailwindcss": "^4.1.12" }, "devDependencies": { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7aef056..ea3921e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,6 +1,10 @@ { "extends": "@repo/typescript-config/nextjs.json", "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + }, "plugins": [ { "name": "next" diff --git a/apps/web/types/bookings.ts b/apps/web/types/bookings.ts index 326663b..9ed78aa 100644 --- a/apps/web/types/bookings.ts +++ b/apps/web/types/bookings.ts @@ -1,79 +1,187 @@ -// 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; +// Base types for PocketBase records +export interface BaseRecord { + id: string; + created: string; + updated: string; } -export interface Resource { - id: string; - type: 'wheel' | 'workstation'; - capacity: number; - is_active: boolean; - created: string; - updated: string; +// Resource entity +export interface Resource extends BaseRecord { + name: string; + type: 'wheel' | 'workstation'; + usage_capacity: number; + is_active: boolean; } -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; +// Booking Type entity +export interface BookingType extends BaseRecord { + name: string; + type: 'workshop' | 'rental'; + display_name: string; + description?: string; + requires_payment?: boolean; + base_duration: number; // minutes, min 30 + min_duration: number; + price_per_person: number; + resources: string; // relation to Resource + min_participants_capacity: number; + max_participants_capacity: number; + is_active: boolean; + booking_capacity: number; } -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; +// Time Slot entity +export interface TimeSlot extends BaseRecord { + booking_types: string[]; // relation to BookingType (many-to-many) + start_time: string; + end_time: string; + is_active: boolean; + is_reccuring?: boolean; + recurrence_pattern?: RecurrencePattern; } +// Recurrence pattern structure +export interface RecurrencePattern { + // type: 'daily' | 'weekly' | 'monthly'; + // interval: number; + days?: number[]; // 0-6, Sunday = 0 + end_date?: string; + // occurrences?: number; +} + +// Booking entity +export interface Booking extends BaseRecord { + booking_type: string; // relation to BookingType + 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; + use_subscription?: string; // relation to subscription (future feature) +} + +// Frontend form types export interface BookingFormData { - bookingTypeId: string; - customerName: string; - customerEmail: string; - participantsCount: number; - startTime: Date; - endTime: Date; + bookingTypeId: string; + customerName: string; + customerEmail: string; + startTime: string; + endTime: string; + participantsCount: number; + internalNotes?: string; } -// 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; - }; -} \ No newline at end of file +// API response types +export interface BookingResponse { + success: boolean; + booking?: Booking; + error?: string; + cancellationUrl?: string; +} + +export interface AvailabilityResponse { + success: boolean; + availableSlots?: TimeSlot[]; + error?: string; +} + +// Expanded types with relations +export interface BookingWithRelations extends Omit { + expand?: { + booking_type?: BookingType; + }; +} + +export interface BookingTypeWithRelations extends Omit { + expand?: { + resources?: Resource; + }; +} + +export interface TimeSlotWithRelations extends Omit { + expand?: { + booking_types?: BookingType[]; + }; +} + +// Utility types for API operations +export interface CreateBookingData { + booking_type: string; + customer_name: string; + customer_email: string; + start_time: string; + end_time: string; + participants_count: number; + internal_notes?: string; + status: 'confirmed'; + payment_status: 'not_required' | 'pending'; + payment_required: boolean; +} + +export interface UpdateBookingData { + status?: 'confirmed' | 'cancelled' | 'completed'; + payment_status?: 'not_required' | 'paid' | 'refunded' | 'partially_refunded' | 'pending'; + internal_notes?: string; +} + +// Filter and query types +export interface BookingFilters { + status?: string; + payment_status?: string; + booking_type?: string; + date_from?: string; + date_to?: string; + customer_email?: string; +} + +export interface TimeSlotFilters { + booking_type?: string; + date_from?: string; + date_to?: string; + is_active?: boolean; +} + +// Error types +export interface BookingError { + code: string; + message: string; + field?: string; +} + +// Validation types +export interface BookingValidation { + isValid: boolean; + errors: BookingError[]; +} + +// Constants +export const BOOKING_TYPES = { + WHEEL_RENTAL: 'wheel_rental', + HAND_BUILDING: 'hand_building_coworking', + PERSONAL_WORKSHOP: 'personal_workshop', + GROUP_WORKSHOP: 'group_workshop', +} as const; + +export const BOOKING_STATUS = { + CONFIRMED: 'confirmed', + CANCELLED: 'cancelled', + COMPLETED: 'completed', +} as const; + +export const PAYMENT_STATUS = { + NOT_REQUIRED: 'not_required', + PAID: 'paid', + REFUNDED: 'refunded', + PARTIALLY_REFUNDED: 'partially_refunded', + PENDING: 'pending', +} as const; + +export const RESOURCE_TYPES = { + WHEEL: 'wheel', + WORKSTATION: 'workstation', +} as const; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38d1ad5..b1289c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,9 @@ importers: next: specifier: ^15.5.0 version: 15.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next-intl: + specifier: ^4.3.5 + version: 4.3.5(next@15.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.9.2) pocketbase: specifier: ^0.26.2 version: 0.26.2 @@ -85,6 +88,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) + react-hook-form: + specifier: ^7.62.0 + version: 7.62.0(react@19.1.0) tailwindcss: specifier: ^4.1.12 version: 4.1.12 @@ -229,6 +235,24 @@ packages: resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@formatjs/ecma402-abstract@2.3.4': + resolution: {integrity: sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==} + + '@formatjs/fast-memoize@2.2.7': + resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} + + '@formatjs/icu-messageformat-parser@2.11.2': + resolution: {integrity: sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==} + + '@formatjs/icu-skeleton-parser@1.8.14': + resolution: {integrity: sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==} + + '@formatjs/intl-localematcher@0.5.10': + resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==} + + '@formatjs/intl-localematcher@0.6.1': + resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -457,6 +481,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@schummar/icu-type-parser@1.21.5': + resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -767,6 +794,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1046,6 +1076,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + intl-messageformat@10.7.16: + resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -1314,6 +1347,20 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-intl@4.3.5: + resolution: {integrity: sha512-tT3SltfpPOCAQ9kVNr+8t6FUtVf8G0WFlJcVc8zj4WCMfuF8XFk4gZCN/MtjgDgkUISw5aKamOClJB4EsV95WQ==} + peerDependencies: + next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + next@15.5.0: resolution: {integrity: sha512-N1lp9Hatw3a9XLt0307lGB4uTKsXDhyOKQo7uYMzX4i0nF/c27grcGXkLdb7VcT8QPYLBa8ouIyEoUQJ2OyeNQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -1439,6 +1486,12 @@ packages: peerDependencies: react: ^19.1.0 + react-hook-form@7.62.0: + resolution: {integrity: sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -1684,6 +1737,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-intl@4.3.5: + resolution: {integrity: sha512-qyL1TZNesVbzj/75ZbYsi+xzNSiFqp5rIVsiAN0JT8rPMSjX0/3KQz76aJIrngI1/wIQdVYFVdImWh5yAv+dWA==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -1769,6 +1827,36 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 + '@formatjs/ecma402-abstract@2.3.4': + dependencies: + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/intl-localematcher': 0.6.1 + decimal.js: 10.6.0 + tslib: 2.8.1 + + '@formatjs/fast-memoize@2.2.7': + dependencies: + tslib: 2.8.1 + + '@formatjs/icu-messageformat-parser@2.11.2': + dependencies: + '@formatjs/ecma402-abstract': 2.3.4 + '@formatjs/icu-skeleton-parser': 1.8.14 + tslib: 2.8.1 + + '@formatjs/icu-skeleton-parser@1.8.14': + dependencies: + '@formatjs/ecma402-abstract': 2.3.4 + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.5.10': + dependencies: + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.6.1': + dependencies: + tslib: 2.8.1 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -1933,6 +2021,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@schummar/icu-type-parser@1.21.5': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -2295,6 +2385,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -2687,6 +2779,13 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + intl-messageformat@10.7.16: + dependencies: + '@formatjs/ecma402-abstract': 2.3.4 + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/icu-messageformat-parser': 2.11.2 + tslib: 2.8.1 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -2934,6 +3033,18 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + + next-intl@4.3.5(next@15.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.9.2): + dependencies: + '@formatjs/intl-localematcher': 0.5.10 + negotiator: 1.0.0 + next: 15.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + use-intl: 4.3.5(react@19.1.0) + optionalDependencies: + typescript: 5.9.2 + next@15.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.5.0 @@ -3065,6 +3176,10 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 + react-hook-form@7.62.0(react@19.1.0): + dependencies: + react: 19.1.0 + react-is@16.13.1: {} react@19.1.0: {} @@ -3396,6 +3511,13 @@ snapshots: dependencies: punycode: 2.3.1 + use-intl@4.3.5(react@19.1.0): + dependencies: + '@formatjs/fast-memoize': 2.2.7 + '@schummar/icu-type-parser': 1.21.5 + intl-messageformat: 10.7.16 + react: 19.1.0 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0