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 ad61474..e1b3286 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..293ae71 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/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/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 c3f1ca3..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 9eaba99..4ce1370 100644 --- a/apps/web/components/BookingForm.tsx +++ b/apps/web/components/BookingForm.tsx @@ -1,7 +1,11 @@ -'use client' +'use client'; import React, { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; +import { useTranslations } from 'next-intl'; +import { BookingService } from '@/lib/bookingService'; +import { bookingApi } from '@/lib/pocketbase'; +import { BookingType, TimeSlot } from '@/types/bookings'; import BookingTypeSelector from './BookingTypeSelector'; import DateSelector from './DateSelector'; import TimeSlotSelector from './TimeSlotSelector'; @@ -15,10 +19,17 @@ interface FormData { } const BookingInterface = () => { + const t = useTranslations('bookingForm'); const [selectedDate, setSelectedDate] = useState(''); const [selectedTimeSlot, setSelectedTimeSlot] = useState(''); const [selectedBookingType, setSelectedBookingType] = useState(''); const [participantsCount, setParticipantsCount] = useState(1); + const [bookingTypes, setBookingTypes] = useState([]); + const [timeSlots, setTimeSlots] = useState([]); + const [availableDates, setAvailableDates] = useState([]); + const [availableSlotsByDate, setAvailableSlotsByDate] = useState<{ [date: string]: { start_time: string; end_time: string }[] }>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const { register, handleSubmit, formState: { errors, isValid }, setValue, trigger } = useForm({ mode: 'onChange', @@ -29,25 +40,30 @@ const BookingInterface = () => { } }); - // 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); + // 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); + } + }; + + fetchBookingTypes(); + }, []); + + // Available dates are now handled in handleBookingTypeChange + + // Time slots are now handled in handleDateChange + useEffect(() => { setValue('participantsCount', participantsCount); trigger('participantsCount'); @@ -61,14 +77,13 @@ const BookingInterface = () => { customerName: data.customerName, customerEmail: data.customerEmail, startTime: selectedDate && selectedTimeSlotData - ? `${selectedDate}T${selectedTimeSlotData.start_time}:00` + ? `${selectedDate}T${selectedTimeSlotData.start_time}` : '', endTime: selectedDate && selectedTimeSlotData - ? `${selectedDate}T${selectedTimeSlotData.end_time}:00` + ? `${selectedDate}T${selectedTimeSlotData.end_time}` : '', participantsCount: data.participantsCount, }; - console.log(formData); }; @@ -76,15 +91,58 @@ const BookingInterface = () => { setParticipantsCount(count); }; - const handleBookingTypeChange = (typeId: string) => { + 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_capacity); + } + + // Fetch time slots for the selected booking type + try { + const timeSlots = await bookingApi.getTimeSlotsForBookingType(typeId); + + // Generate available time slots grouped by date + const generatedSlotsByDate = bookingApi.generateAvailableTimeSlots(timeSlots); + const availableDatesFromAPI = Object.keys(generatedSlotsByDate).sort(); + + // Update available dates and slots data for the date selector + setAvailableDates(availableDatesFromAPI); + setAvailableSlotsByDate(generatedSlotsByDate); + } catch (error) { + console.error('Error fetching time slots:', error); + } }; const handleDateChange = (date: string) => { setSelectedDate(date); setSelectedTimeSlot(''); + + // Get time slots for the selected date from availableSlotsByDate + const slotsForDate = availableSlotsByDate[date] || []; + console.log(`Time slots for ${date}:`, slotsForDate); + + // Convert to TimeSlot format for TimeSlotSelector + const formattedTimeSlots: TimeSlot[] = slotsForDate.map((slot, index) => ({ + id: `slot-${date}-${index}`, + start_time: slot.start_time, + end_time: slot.end_time, + is_active: true, + max_capacity: 8, + booking_types: [selectedBookingType], + is_reccuring: false, + recurrence_pattern: undefined, + resources: [], + created: new Date().toISOString(), + updated: new Date().toISOString() + })); + + setTimeSlots(formattedTimeSlots); }; return ( @@ -92,22 +150,49 @@ const BookingInterface = () => {
{/* Header */}
-

Book Your Session

-

Choose your pottery experience

+

{t('title')}

+

{t('subtitle')}

+ {/* 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 && ( )} @@ -122,13 +207,14 @@ const BookingInterface = () => { )} {/* Customer Details */} - {selectedTimeSlot && ( + {selectedTimeSlot && selectedBookingTypeData && (
{/* Summary & Submit */} diff --git a/apps/web/components/BookingSummary.tsx b/apps/web/components/BookingSummary.tsx index 7ce927a..35b823c 100644 --- a/apps/web/components/BookingSummary.tsx +++ b/apps/web/components/BookingSummary.tsx @@ -1,19 +1,5 @@ import React from 'react'; - -interface BookingType { - id: string; - name: string; - display_name: string; - requires_payment: boolean; - price_per_person: number; -} - -interface TimeSlot { - id: string; - start_time: string; - end_time: string; - available: boolean; -} +import { BookingType, TimeSlot } from '@/types/bookings'; interface BookingSummaryProps { selectedBookingTypeData: BookingType | undefined; @@ -34,6 +20,23 @@ const BookingSummary: React.FC = ({ }) => { 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

@@ -50,7 +53,10 @@ const BookingSummary: React.FC = ({
Time: - {selectedTimeSlotData?.start_time} - {selectedTimeSlotData?.end_time} + {selectedTimeSlotData?.start_time && selectedTimeSlotData?.end_time + ? `${formatTime(selectedTimeSlotData.start_time)}—${formatTime(selectedTimeSlotData.end_time)}` + : 'Not selected' + }
diff --git a/apps/web/components/BookingTypeSelector.tsx b/apps/web/components/BookingTypeSelector.tsx index 143e9cb..1ad76ee 100644 --- a/apps/web/components/BookingTypeSelector.tsx +++ b/apps/web/components/BookingTypeSelector.tsx @@ -1,13 +1,7 @@ import React from 'react'; import { Calendar } from 'lucide-react'; - -interface BookingType { - id: string; - name: string; - display_name: string; - requires_payment: boolean; - price_per_person: number; -} +import { useTranslations } from 'next-intl'; +import { BookingType } from '@/types/bookings'; interface BookingTypeSelectorProps { bookingTypes: BookingType[]; @@ -20,11 +14,13 @@ const BookingTypeSelector: React.FC = ({ selectedBookingType, onBookingTypeChange, }) => { + const t = useTranslations('bookingForm'); + return (

- Select Experience + {t('selectExperience')}

{bookingTypes.map((type) => ( @@ -47,13 +43,13 @@ const BookingTypeSelector: React.FC = ({

{type.display_name}

{type.requires_payment && (

- ${type.price_per_person} per person + ₾{type.price_per_person} {t('perPerson')}

)}
{type.requires_payment && ( - Payment Required + {t('paymentRequired')} )}
diff --git a/apps/web/components/CustomerDetails.tsx b/apps/web/components/CustomerDetails.tsx index 17658a2..912fea3 100644 --- a/apps/web/components/CustomerDetails.tsx +++ b/apps/web/components/CustomerDetails.tsx @@ -1,6 +1,8 @@ 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; @@ -13,6 +15,7 @@ interface CustomerDetailsProps { errors: FieldErrors; participantsCount: number; onParticipantsCountChange: (count: number) => void; + bookingType: BookingType; } const CustomerDetails: React.FC = ({ @@ -20,7 +23,13 @@ const CustomerDetails: React.FC = ({ errors, participantsCount, onParticipantsCountChange, + bookingType, }) => { + const t = useTranslations('bookingForm'); + + // Calculate min and max based on booking type + const minParticipants = bookingType.min_capacity; + const maxParticipants = bookingType.max_capacity; return (

@@ -80,9 +89,9 @@ const CustomerDetails: React.FC = ({
@@ -92,18 +101,23 @@ const CustomerDetails: React.FC = ({

+ +
+ Min: {minParticipants} • Max: {maxParticipants} +
+ diff --git a/apps/web/components/DateSelector.tsx b/apps/web/components/DateSelector.tsx index 9fe386c..5a4660e 100644 --- a/apps/web/components/DateSelector.tsx +++ b/apps/web/components/DateSelector.tsx @@ -1,34 +1,46 @@ 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 = []; + const days: CalendarDay[] = []; - for (let i = 0; i < 14; i++) { + // 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] || ''; + days.push({ - date: date.toISOString().split('T')[0], + date: dateString, day: date.getDate(), month: date.toLocaleDateString('en', { month: 'short' }), - dayName: date.toLocaleDateString('en', { weekday: 'short' }) + dayName: date.toLocaleDateString('en', { weekday: 'short' }), + available: availableDates.includes(dateString) }); } return days; @@ -40,26 +52,37 @@ const DateSelector: React.FC = ({

- Choose Date + {t('chooseDate')}

-
- {calendarDays.map((day) => ( - - ))} -
+ + {loading ? ( +
+
+ Loading available dates... +
+ ) : ( +
+ {calendarDays.map((day) => ( + + ))} +
+ )}
); }; 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 index 52e6475..e07d589 100644 --- a/apps/web/components/TimeSlotSelector.tsx +++ b/apps/web/components/TimeSlotSelector.tsx @@ -1,51 +1,98 @@ import React from 'react'; import { Clock } from 'lucide-react'; - -interface TimeSlot { - id: string; - start_time: string; - end_time: string; - available: boolean; -} +import { useTranslations } from 'next-intl'; +import { TimeSlot } from '@/types/bookings'; interface TimeSlotSelectorProps { timeSlots: TimeSlot[]; 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 (

- Available Times + {t('availableTimes')}

-
- {timeSlots.map((slot) => ( - - ))} -
+ + {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 ( + + ); + })} +
+ )}
); }; 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..63c28be 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 } 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,103 @@ 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(); - // 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 { - try { - const record = await pb.collection('bookings').create({ - ...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; - } - }, + 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); + } + }); - // 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; - } - }, + // 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); + } + }); - // 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; - } + // 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; }, }; \ 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 98cb035..94504b4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@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", 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 4b58809..eb6fe27 100644 --- a/apps/web/types/bookings.ts +++ b/apps/web/types/bookings.ts @@ -41,11 +41,11 @@ export interface TimeSlot extends BaseRecord { // Recurrence pattern structure export interface RecurrencePattern { - type: 'daily' | 'weekly' | 'monthly'; - interval: number; - days_of_week?: number[]; // 0-6, Sunday = 0 + // type: 'daily' | 'weekly' | 'monthly'; + // interval: number; + days?: number[]; // 0-6, Sunday = 0 end_date?: string; - occurrences?: number; + // occurrences?: number; } // Booking entity diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f244ecc..088ae00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,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 @@ -230,6 +233,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'} @@ -458,6 +479,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==} @@ -768,6 +792,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==} @@ -1047,6 +1074,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'} @@ -1315,6 +1345,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} @@ -1691,6 +1735,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'} @@ -1776,6 +1825,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': @@ -1940,6 +2019,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 @@ -2302,6 +2383,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -2694,6 +2777,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 @@ -2941,6 +3031,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 @@ -3407,6 +3509,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