Compare commits

...

10 Commits

Author SHA1 Message Date
b94e205787 Merge pull request 'feature/add-docker-build' (#4) from feature/add-docker-build into dev
Some checks failed
Docker Build / docker (push) Failing after 40s
Reviewed-on: #4
2025-09-16 14:14:37 +02:00
7ff599aae0 2025-09-16 16:10:10+04:00 2025-09-16 14:14:37 +02:00
9d8ef03fea 2025-09-16 15:51:23+04:00 2025-09-16 14:14:37 +02:00
a4a95e8649 2025-09-16 13:59:28+04:00 2025-09-16 14:14:37 +02:00
c7f6ad43c4 2025-09-16 10:35:19+04:00 2025-09-16 14:14:37 +02:00
a4ad1d8645 add dockerfile 2025-09-16 14:14:37 +02:00
Asya Vee
ff6de00988 fix the capacity and NaN participants issue 2025-08-27 02:50:13 +04:00
Asya Vee
459289df2d booking capacity check for time slots 2025-08-27 02:08:23 +04:00
Asya Vee
c0647fe512 timeslots API 2025-08-26 23:36:24 +04:00
Asya Vee
47baf8dfe2 booking form validation and data types 2025-08-26 18:06:44 +04:00
59 changed files with 1945 additions and 950 deletions

View File

@@ -0,0 +1,64 @@
# docker-build.yaml
name: Docker Build
on:
push:
branches:
- "**"
tags:
- "**"
pull_request:
workflow_dispatch:
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract tag name
if: startsWith(github.ref, 'refs/tags/')
run: echo "GIT_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: Log in to registry
if: secrets.REGISTRY != '' && secrets.REGISTRY_USER != '' && secrets.REGISTRY_PASSWORD != ''
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Check if SHA image exists
if: env.GIT_TAG != '' && secrets.REGISTRY != ''
id: check_image
run: |
if docker manifest inspect ${{ secrets.REGISTRY }}5mdt/vitrify-me:${{ github.sha }} > /dev/null 2>&1; then
echo "image_exists=true" >> $GITHUB_OUTPUT
else
echo "image_exists=false" >> $GITHUB_OUTPUT
fi
- name: Pull existing image
if: env.GIT_TAG != '' && steps.check_image.outputs.image_exists == 'true'
run: docker pull ${{ secrets.REGISTRY }}5mdt/vitrify-me:${{ github.sha }}
- name: Build Docker image
if: env.GIT_TAG == '' || steps.check_image.outputs.image_exists == 'false'
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ secrets.REGISTRY != '' }}
tags: ${{ secrets.REGISTRY }}5mdt/vitrify-me:${{ github.sha }}
- name: Tag and push with git tag
if: env.GIT_TAG != '' && secrets.REGISTRY != ''
run: |
docker tag ${{ secrets.REGISTRY }}5mdt/vitrify-me:${{ github.sha }} ${{ secrets.REGISTRY }}5mdt/vitrify-me:${{ env.GIT_TAG }}
docker push ${{ secrets.REGISTRY }}5mdt/vitrify-me:${{ env.GIT_TAG }}

16
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,16 @@
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflict
- id: detect-private-key
- id: check-added-large-files
- id: check-ast
- id: check-json
- id: check-toml
- id: check-xml

53
Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
RUN npm install -g pnpm@9.0.0
# Copy dependency files first for better caching
COPY package.json pnpm-lock.yaml turbo.json ./
COPY apps/web/package.json ./apps/web/
# Add other app package.json files as needed
RUN pnpm install --frozen-lockfile
# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm@9.0.0
# Copy dependencies from previous stage
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/package.json /app/pnpm-lock.yaml /app/turbo.json ./
# Copy source code
COPY . .
# Build the application
RUN pnpm build
# Stage 3: Runtime
FROM node:20-alpine AS runtime
WORKDIR /app
# Install pnpm for production
RUN npm install -g pnpm@9.0.0
# Copy package files
COPY package.json pnpm-lock.yaml turbo.json ./
COPY apps/web/package.json ./apps/web/
# Install only production dependencies
RUN pnpm install --prod --frozen-lockfile
# Copy built application from builder
COPY --from=builder /app/apps/web/dist ./apps/web/dist
COPY --from=builder /app/apps/web/server.js ./apps/web/
# Copy other necessary runtime files
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
USER nextjs
EXPOSE 3000
CMD ["node", "apps/web/server.js"]

36
apps/admin/.gitignore vendored
View File

@@ -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

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

View File

@@ -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;
}
}

View File

@@ -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 (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
</html>
);
}

View File

@@ -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();
}
}

View File

@@ -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<ImageProps, "src"> & {
srcLight: string;
srcDark: string;
};
const ThemeImage = (props: Props) => {
const { srcLight, srcDark, ...rest } = props;
return (
<>
<Image {...rest} src={srcLight} className="imgLight" />
<Image {...rest} src={srcDark} className="imgDark" />
</>
);
};
export default function Home() {
return (
<div className={styles.page}>
<main className={styles.main}>
<ThemeImage
className={styles.logo}
srcLight="turborepo-dark.svg"
srcDark="turborepo-light.svg"
alt="Turborepo logo"
width={180}
height={38}
priority
/>
<ol>
<li>
Get started by editing <code>apps/docs/app/page.tsx</code>
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className={styles.ctas}>
<a
className={styles.primary}
href="https://vercel.com/new/clone?demo-description=Learn+to+implement+a+monorepo+with+a+two+Next.js+sites+that+has+installed+three+local+packages.&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F4K8ZISWAzJ8X1504ca0zmC%2F0b21a1c6246add355e55816278ef54bc%2FBasic.png&demo-title=Monorepo+with+Turborepo&demo-url=https%3A%2F%2Fexamples-basic-web.vercel.sh%2F&from=templates&project-name=Monorepo+with+Turborepo&repository-name=monorepo-turborepo&repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fturborepo%2Ftree%2Fmain%2Fexamples%2Fbasic&root-directory=apps%2Fdocs&skippable-integrations=1&teamSlug=vercel&utm_source=create-turbo"
target="_blank"
rel="noopener noreferrer"
>
<Image
className={styles.logo}
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
href="https://turborepo.com/docs?utm_source"
target="_blank"
rel="noopener noreferrer"
className={styles.secondary}
>
Read our docs
</a>
</div>
<Button appName="docs" className={styles.secondary}>
Open alert
</Button>
</main>
<footer className={styles.footer}>
<a
href="https://vercel.com/templates?search=turborepo&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
href="https://turborepo.com?utm_source=create-turbo"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to turborepo.com
</a>
</footer>
</div>
);
}

View File

@@ -1,4 +0,0 @@
import { nextJsConfig } from "@repo/eslint-config/next-js";
/** @type {import("eslint").Linter.Config} */
export default nextJsConfig;

View File

@@ -1,4 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

View File

@@ -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"
}
}

View File

@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.5 13.5V6.5V5.41421C14.5 5.149 14.3946 4.89464 14.2071 4.70711L9.79289 0.292893C9.60536 0.105357 9.351 0 9.08579 0H8H3H1.5V1.5V13.5C1.5 14.8807 2.61929 16 4 16H12C13.3807 16 14.5 14.8807 14.5 13.5ZM13 13.5V6.5H9.5H8V5V1.5H3V13.5C3 14.0523 3.44772 14.5 4 14.5H12C12.5523 14.5 13 14.0523 13 13.5ZM9.5 5V2.12132L12.3787 5H9.5ZM5.13 5.00062H4.505V6.25062H5.13H6H6.625V5.00062H6H5.13ZM4.505 8H5.13H11H11.625V9.25H11H5.13H4.505V8ZM5.13 11H4.505V12.25H5.13H11H11.625V11H11H5.13Z" fill="#666666"/>
</svg>

Before

Width:  |  Height:  |  Size: 645 B

View File

@@ -1,10 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_868_525)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.268 14.0934C11.9051 13.4838 13.2303 12.2333 13.9384 10.6469C13.1192 10.7941 12.2138 10.9111 11.2469 10.9925C11.0336 12.2005 10.695 13.2621 10.268 14.0934ZM8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16ZM8.48347 14.4823C8.32384 14.494 8.16262 14.5 8 14.5C7.83738 14.5 7.67616 14.494 7.51654 14.4823C7.5132 14.4791 7.50984 14.4759 7.50647 14.4726C7.2415 14.2165 6.94578 13.7854 6.67032 13.1558C6.41594 12.5744 6.19979 11.8714 6.04101 11.0778C6.67605 11.1088 7.33104 11.125 8 11.125C8.66896 11.125 9.32395 11.1088 9.95899 11.0778C9.80021 11.8714 9.58406 12.5744 9.32968 13.1558C9.05422 13.7854 8.7585 14.2165 8.49353 14.4726C8.49016 14.4759 8.4868 14.4791 8.48347 14.4823ZM11.4187 9.72246C12.5137 9.62096 13.5116 9.47245 14.3724 9.28806C14.4561 8.87172 14.5 8.44099 14.5 8C14.5 7.55901 14.4561 7.12828 14.3724 6.71194C13.5116 6.52755 12.5137 6.37904 11.4187 6.27753C11.4719 6.83232 11.5 7.40867 11.5 8C11.5 8.59133 11.4719 9.16768 11.4187 9.72246ZM10.1525 6.18401C10.2157 6.75982 10.25 7.36805 10.25 8C10.25 8.63195 10.2157 9.24018 10.1525 9.81598C9.46123 9.85455 8.7409 9.875 8 9.875C7.25909 9.875 6.53877 9.85455 5.84749 9.81598C5.7843 9.24018 5.75 8.63195 5.75 8C5.75 7.36805 5.7843 6.75982 5.84749 6.18401C6.53877 6.14545 7.25909 6.125 8 6.125C8.74091 6.125 9.46123 6.14545 10.1525 6.18401ZM11.2469 5.00748C12.2138 5.08891 13.1191 5.20593 13.9384 5.35306C13.2303 3.7667 11.9051 2.51622 10.268 1.90662C10.695 2.73788 11.0336 3.79953 11.2469 5.00748ZM8.48347 1.51771C8.4868 1.52089 8.49016 1.52411 8.49353 1.52737C8.7585 1.78353 9.05422 2.21456 9.32968 2.84417C9.58406 3.42562 9.80021 4.12856 9.95899 4.92219C9.32395 4.89118 8.66896 4.875 8 4.875C7.33104 4.875 6.67605 4.89118 6.04101 4.92219C6.19978 4.12856 6.41594 3.42562 6.67032 2.84417C6.94578 2.21456 7.2415 1.78353 7.50647 1.52737C7.50984 1.52411 7.51319 1.52089 7.51653 1.51771C7.67615 1.50597 7.83738 1.5 8 1.5C8.16262 1.5 8.32384 1.50597 8.48347 1.51771ZM5.73202 1.90663C4.0949 2.51622 2.76975 3.7667 2.06159 5.35306C2.88085 5.20593 3.78617 5.08891 4.75309 5.00748C4.96639 3.79953 5.30497 2.73788 5.73202 1.90663ZM4.58133 6.27753C3.48633 6.37904 2.48837 6.52755 1.62761 6.71194C1.54392 7.12828 1.5 7.55901 1.5 8C1.5 8.44099 1.54392 8.87172 1.62761 9.28806C2.48837 9.47245 3.48633 9.62096 4.58133 9.72246C4.52807 9.16768 4.5 8.59133 4.5 8C4.5 7.40867 4.52807 6.83232 4.58133 6.27753ZM4.75309 10.9925C3.78617 10.9111 2.88085 10.7941 2.06159 10.6469C2.76975 12.2333 4.0949 13.4838 5.73202 14.0934C5.30497 13.2621 4.96639 12.2005 4.75309 10.9925Z" fill="#666666"/>
</g>
<defs>
<clipPath id="clip0_868_525">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,19 +0,0 @@
<svg width="473" height="76" viewBox="0 0 473 76" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M130.998 30.6565V22.3773H91.0977V30.6565H106.16V58.1875H115.935V30.6565H130.998Z" fill="black"/>
<path d="M153.542 58.7362C165.811 58.7362 172.544 52.5018 172.544 42.2275V22.3773H162.768V41.2799C162.768 47.0155 159.776 50.2574 153.542 50.2574C147.307 50.2574 144.315 47.0155 144.315 41.2799V22.3773H134.539V42.2275C134.539 52.5018 141.272 58.7362 153.542 58.7362Z" fill="black"/>
<path d="M187.508 46.3173H197.234L204.914 58.1875H216.136L207.458 45.2699C212.346 43.5243 215.338 39.634 215.338 34.3473C215.338 26.6665 209.603 22.3773 200.874 22.3773H177.732V58.1875H187.508V46.3173ZM187.508 38.5867V30.5568H200.376C203.817 30.5568 205.712 32.053 205.712 34.5967C205.712 36.9907 203.817 38.5867 200.376 38.5867H187.508Z" fill="black"/>
<path d="M219.887 58.1875H245.472C253.452 58.1875 258.041 54.397 258.041 48.0629C258.041 43.8235 255.348 40.9308 252.156 39.634C254.35 38.5867 257.043 36.0929 257.043 32.1528C257.043 25.8187 252.555 22.3773 244.625 22.3773H219.887V58.1875ZM229.263 36.3922V30.3074H243.627C246.32 30.3074 247.817 31.3548 247.817 33.3498C247.817 35.3448 246.32 36.3922 243.627 36.3922H229.263ZM229.263 43.7238H244.525C247.168 43.7238 248.615 45.0205 248.615 46.9657C248.615 48.9108 247.168 50.2075 244.525 50.2075H229.263V43.7238Z" fill="black"/>
<path d="M281.942 21.7788C269.423 21.7788 260.396 29.6092 260.396 40.2824C260.396 50.9557 269.423 58.786 281.942 58.786C294.461 58.786 303.438 50.9557 303.438 40.2824C303.438 29.6092 294.461 21.7788 281.942 21.7788ZM281.942 30.2575C288.525 30.2575 293.463 34.1478 293.463 40.2824C293.463 46.417 288.525 50.3073 281.942 50.3073C275.359 50.3073 270.421 46.417 270.421 40.2824C270.421 34.1478 275.359 30.2575 281.942 30.2575Z" fill="black"/>
<path d="M317.526 46.3173H327.251L334.932 58.1875H346.154L337.476 45.2699C342.364 43.5243 345.356 39.634 345.356 34.3473C345.356 26.6665 339.62 22.3773 330.892 22.3773H307.75V58.1875H317.526V46.3173ZM317.526 38.5867V30.5568H330.394C333.835 30.5568 335.73 32.053 335.73 34.5967C335.73 36.9907 333.835 38.5867 330.394 38.5867H317.526Z" fill="black"/>
<path d="M349.904 22.3773V58.1875H384.717V49.9083H359.48V44.0729H381.874V35.9932H359.48V30.6565H384.717V22.3773H349.904Z" fill="black"/>
<path d="M399.204 46.7662H412.221C420.95 46.7662 426.685 42.5767 426.685 34.5967C426.685 26.5668 420.95 22.3773 412.221 22.3773H389.428V58.1875H399.204V46.7662ZM399.204 38.6365V30.5568H411.673C415.164 30.5568 417.059 32.053 417.059 34.5967C417.059 37.0904 415.164 38.6365 411.673 38.6365H399.204Z" fill="black"/>
<path d="M450.948 21.7788C438.43 21.7788 429.402 29.6092 429.402 40.2824C429.402 50.9557 438.43 58.786 450.948 58.786C463.467 58.786 472.444 50.9557 472.444 40.2824C472.444 29.6092 463.467 21.7788 450.948 21.7788ZM450.948 30.2575C457.532 30.2575 462.469 34.1478 462.469 40.2824C462.469 46.417 457.532 50.3073 450.948 50.3073C444.365 50.3073 439.427 46.417 439.427 40.2824C439.427 34.1478 444.365 30.2575 450.948 30.2575Z" fill="black"/>
<path d="M38.5017 18.0956C27.2499 18.0956 18.0957 27.2498 18.0957 38.5016C18.0957 49.7534 27.2499 58.9076 38.5017 58.9076C49.7535 58.9076 58.9077 49.7534 58.9077 38.5016C58.9077 27.2498 49.7535 18.0956 38.5017 18.0956ZM38.5017 49.0618C32.6687 49.0618 27.9415 44.3346 27.9415 38.5016C27.9415 32.6686 32.6687 27.9414 38.5017 27.9414C44.3347 27.9414 49.0619 32.6686 49.0619 38.5016C49.0619 44.3346 44.3347 49.0618 38.5017 49.0618Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.2115 14.744V7.125C56.7719 8.0104 69.9275 21.7208 69.9275 38.5016C69.9275 55.2824 56.7719 68.989 40.2115 69.8782V62.2592C52.5539 61.3776 62.3275 51.0644 62.3275 38.5016C62.3275 25.9388 52.5539 15.6256 40.2115 14.744ZM20.5048 54.0815C17.233 50.3043 15.124 45.4935 14.7478 40.2115H7.125C7.5202 47.6025 10.4766 54.3095 15.1088 59.4737L20.501 54.0815H20.5048ZM36.7916 69.8782V62.2592C31.5058 61.883 26.695 59.7778 22.9178 56.5022L17.5256 61.8944C22.6936 66.5304 29.4006 69.483 36.7878 69.8782H36.7916Z" fill="url(#paint0_linear_2028_278)"/>
<defs>
<linearGradient id="paint0_linear_2028_278" x1="41.443" y1="11.5372" x2="10.5567" y2="42.4236" gradientUnits="userSpaceOnUse">
<stop stop-color="#0096FF"/>
<stop offset="1" stop-color="#FF1E56"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,19 +0,0 @@
<svg width="473" height="76" viewBox="0 0 473 76" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M130.998 30.6566V22.3773H91.0977V30.6566H106.16V58.1876H115.935V30.6566H130.998Z" fill="white"/>
<path d="M153.542 58.7362C165.811 58.7362 172.544 52.5018 172.544 42.2276V22.3773H162.768V41.2799C162.768 47.0156 159.776 50.2574 153.542 50.2574C147.307 50.2574 144.315 47.0156 144.315 41.2799V22.3773H134.539V42.2276C134.539 52.5018 141.272 58.7362 153.542 58.7362Z" fill="white"/>
<path d="M187.508 46.3173H197.234L204.914 58.1876H216.136L207.458 45.2699C212.346 43.5243 215.338 39.6341 215.338 34.3473C215.338 26.6666 209.603 22.3773 200.874 22.3773H177.732V58.1876H187.508V46.3173ZM187.508 38.5867V30.5568H200.376C203.817 30.5568 205.712 32.0531 205.712 34.5967C205.712 36.9907 203.817 38.5867 200.376 38.5867H187.508Z" fill="white"/>
<path d="M219.887 58.1876H245.472C253.452 58.1876 258.041 54.3971 258.041 48.0629C258.041 43.8236 255.348 40.9308 252.156 39.6341C254.35 38.5867 257.043 36.0929 257.043 32.1528C257.043 25.8187 252.555 22.3773 244.625 22.3773H219.887V58.1876ZM229.263 36.3922V30.3074H243.627C246.32 30.3074 247.817 31.3548 247.817 33.3498C247.817 35.3448 246.32 36.3922 243.627 36.3922H229.263ZM229.263 43.7238H244.525C247.168 43.7238 248.615 45.0206 248.615 46.9657C248.615 48.9108 247.168 50.2076 244.525 50.2076H229.263V43.7238Z" fill="white"/>
<path d="M281.942 21.7788C269.423 21.7788 260.396 29.6092 260.396 40.2824C260.396 50.9557 269.423 58.7861 281.942 58.7861C294.461 58.7861 303.438 50.9557 303.438 40.2824C303.438 29.6092 294.461 21.7788 281.942 21.7788ZM281.942 30.2576C288.525 30.2576 293.463 34.1478 293.463 40.2824C293.463 46.4171 288.525 50.3073 281.942 50.3073C275.359 50.3073 270.421 46.4171 270.421 40.2824C270.421 34.1478 275.359 30.2576 281.942 30.2576Z" fill="white"/>
<path d="M317.526 46.3173H327.251L334.932 58.1876H346.154L337.476 45.2699C342.364 43.5243 345.356 39.6341 345.356 34.3473C345.356 26.6666 339.62 22.3773 330.892 22.3773H307.75V58.1876H317.526V46.3173ZM317.526 38.5867V30.5568H330.394C333.835 30.5568 335.73 32.0531 335.73 34.5967C335.73 36.9907 333.835 38.5867 330.394 38.5867H317.526Z" fill="white"/>
<path d="M349.904 22.3773V58.1876H384.717V49.9083H359.48V44.0729H381.874V35.9932H359.48V30.6566H384.717V22.3773H349.904Z" fill="white"/>
<path d="M399.204 46.7662H412.221C420.95 46.7662 426.685 42.5767 426.685 34.5967C426.685 26.5668 420.95 22.3773 412.221 22.3773H389.428V58.1876H399.204V46.7662ZM399.204 38.6366V30.5568H411.673C415.164 30.5568 417.059 32.0531 417.059 34.5967C417.059 37.0904 415.164 38.6366 411.673 38.6366H399.204Z" fill="white"/>
<path d="M450.948 21.7788C438.43 21.7788 429.402 29.6092 429.402 40.2824C429.402 50.9557 438.43 58.7861 450.948 58.7861C463.467 58.7861 472.444 50.9557 472.444 40.2824C472.444 29.6092 463.467 21.7788 450.948 21.7788ZM450.948 30.2576C457.532 30.2576 462.469 34.1478 462.469 40.2824C462.469 46.4171 457.532 50.3073 450.948 50.3073C444.365 50.3073 439.427 46.4171 439.427 40.2824C439.427 34.1478 444.365 30.2576 450.948 30.2576Z" fill="white"/>
<path d="M38.5017 18.0956C27.2499 18.0956 18.0957 27.2498 18.0957 38.5016C18.0957 49.7534 27.2499 58.9076 38.5017 58.9076C49.7535 58.9076 58.9077 49.7534 58.9077 38.5016C58.9077 27.2498 49.7535 18.0956 38.5017 18.0956ZM38.5017 49.0618C32.6687 49.0618 27.9415 44.3346 27.9415 38.5016C27.9415 32.6686 32.6687 27.9414 38.5017 27.9414C44.3347 27.9414 49.0619 32.6686 49.0619 38.5016C49.0619 44.3346 44.3347 49.0618 38.5017 49.0618Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.2115 14.744V7.125C56.7719 8.0104 69.9275 21.7208 69.9275 38.5016C69.9275 55.2824 56.7719 68.989 40.2115 69.8782V62.2592C52.5539 61.3776 62.3275 51.0644 62.3275 38.5016C62.3275 25.9388 52.5539 15.6256 40.2115 14.744ZM20.5048 54.0815C17.233 50.3043 15.124 45.4935 14.7478 40.2115H7.125C7.5202 47.6025 10.4766 54.3095 15.1088 59.4737L20.501 54.0815H20.5048ZM36.7916 69.8782V62.2592C31.5058 61.883 26.695 59.7778 22.9178 56.5022L17.5256 61.8944C22.6936 66.5304 29.4006 69.483 36.7878 69.8782H36.7916Z" fill="url(#paint0_linear_2028_477)"/>
<defs>
<linearGradient id="paint0_linear_2028_477" x1="41.443" y1="11.5372" x2="10.5567" y2="42.4236" gradientUnits="userSpaceOnUse">
<stop stop-color="#0096FF"/>
<stop offset="1" stop-color="#FF1E56"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,10 +0,0 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_977_547)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5 3L18.5 17H2.5L10.5 3Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_977_547">
<rect width="16" height="16" fill="white" transform="translate(2.5 2)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 367 B

View File

@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5H14.5V12.5C14.5 13.0523 14.0523 13.5 13.5 13.5H2.5C1.94772 13.5 1.5 13.0523 1.5 12.5V2.5ZM0 1H1.5H14.5H16V2.5V12.5C16 13.8807 14.8807 15 13.5 15H2.5C1.11929 15 0 13.8807 0 12.5V2.5V1ZM3.75 5.5C4.16421 5.5 4.5 5.16421 4.5 4.75C4.5 4.33579 4.16421 4 3.75 4C3.33579 4 3 4.33579 3 4.75C3 5.16421 3.33579 5.5 3.75 5.5ZM7 4.75C7 5.16421 6.66421 5.5 6.25 5.5C5.83579 5.5 5.5 5.16421 5.5 4.75C5.5 4.33579 5.83579 4 6.25 4C6.66421 4 7 4.33579 7 4.75ZM8.75 5.5C9.16421 5.5 9.5 5.16421 9.5 4.75C9.5 4.33579 9.16421 4 8.75 4C8.33579 4 8 4.33579 8 4.75C8 5.16421 8.33579 5.5 8.75 5.5Z" fill="#666666"/>
</svg>

Before

Width:  |  Height:  |  Size: 750 B

View File

@@ -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"
]
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,22 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,22 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,38 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,66 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,27 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,27 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,22 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -0,0 +1,29 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@@ -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 (
<html lang={locale || 'en'}>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@@ -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 (
<div>
<main>
<LanguageSwitcher />
<BookingInterface />
</main>
<footer className="bg-white text-black py-8 mt-12 border-t">
<div className="max-w-2xl mx-auto px-4 text-center">
<p className="text-gray-600 mb-2">{t('getInTouch')}</p>
<a
href="mailto:vitrify@asyavee.me"
className="text-blue-600 hover:text-blue-800 transition-colors font-medium"
>
vitrify@asyavee.me
</a>
</div>
</footer>
</div>
);
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 (
<html lang="en">
<html>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>

View File

@@ -1,22 +1,5 @@
import BookingInterface from "../components/BookingForm";
import {redirect} from 'next/navigation';
export default function Home() {
return (
<div>
<main>
<BookingInterface />
</main>
<footer className="bg-white text-black py-8 mt-12">
<div className="max-w-2xl mx-auto px-4 text-center">
<p className="text-gray-600 mb-2">Questions? Get in touch</p>
<a
href="mailto:vitrify@asyavee.me"
className="text-blue-600 hover:text-blue-800 transition-colors font-medium"
>
vitrify@asyavee.me
</a>
</div>
</footer>
</div>
);
export default function RootPage() {
redirect('/en');
}

View File

@@ -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<BookingType[]>([]);
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
const [availableDates, setAvailableDates] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<FormData>({
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
});
};
const generateCalendarDays = () => {
const today = new Date();
const days = [];
for (let i = 0; i < 14; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
days.push({
date: date.toISOString().split('T')[0],
day: date.getDate(),
month: date.toLocaleDateString('en', { month: 'short' }),
dayName: date.toLocaleDateString('en', { weekday: 'short' })
});
// 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);
}
return days;
};
const calendarDays = generateCalendarDays();
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);
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 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 (
<div className="min-h-screen bg-gray-50 p-4">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Book Your Session</h1>
<p className="text-gray-600">Choose your pottery experience</p>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('title')}</h1>
<p className="text-gray-600">{t('subtitle')}</p>
</div>
<div className="space-y-8">
{/* Loading State */}
{loading && (
<div className="bg-white rounded-xl p-6 shadow-sm text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="text-gray-600 mt-2">{t('loading')}</p>
</div>
)}
{/* Error State */}
{error && (
<div className="bg-white rounded-xl p-6 shadow-sm text-center">
<div className="text-red-500 mb-2"></div>
<p className="text-gray-800 font-medium mb-2">{t('noBookings')}</p>
<p className="text-gray-600 text-sm">{error}</p>
</div>
)}
{/* Booking Type Selection */}
<div className="bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Calendar className="w-5 h-5" />
Select Experience
</h2>
<div className="grid gap-3">
{bookingTypes.map((type) => (
<label key={type.id} className="cursor-pointer">
<input
type="radio"
name="bookingType"
value={type.id}
checked={selectedBookingType === type.id}
onChange={(e) => setSelectedBookingType(e.target.value)}
className="sr-only"
{!loading && !error && bookingTypes.length === 0 && (
<div className="bg-white rounded-xl p-6 shadow-sm text-center">
<p className="text-gray-800">{t('noBookings')}</p>
<p className="text-gray-600 text-sm mt-2">{t('checkBackLater')}</p>
</div>
)}
{!loading && !error && bookingTypes.length > 0 && (
<BookingTypeSelector
bookingTypes={bookingTypes}
selectedBookingType={selectedBookingType}
onBookingTypeChange={handleBookingTypeChange}
/>
<div className={`p-4 rounded-lg border-2 transition-all ${
selectedBookingType === type.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}>
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium text-gray-900">{type.display_name}</h3>
{type.requires_payment && (
<p className="text-sm text-gray-600 mt-1">
${type.price_per_person} per person
</p>
)}
</div>
{type.requires_payment && (
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
Payment Required
</span>
)}
</div>
</div>
</label>
))}
</div>
</div>
{/* Date Selection */}
{selectedBookingType && (
<div className="bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Calendar className="w-5 h-5" />
Choose Date
</h2>
<div className="grid grid-cols-7 gap-2 md:gap-3">
{calendarDays.map((day) => (
<button
key={day.date}
type="button"
onClick={() => setSelectedDate(day.date)}
className={`p-3 rounded-lg text-center transition-all ${
selectedDate === day.date
? 'bg-blue-500 text-white'
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'
}`}
>
<div className="text-xs font-medium">{day.dayName}</div>
<div className="text-sm font-semibold">{day.day}</div>
<div className="text-xs">{day.month}</div>
</button>
))}
</div>
</div>
<DateSelector
selectedDate={selectedDate}
availableDates={availableDates}
onDateChange={handleDateChange}
/>
)}
{/* Time Slot Selection */}
{selectedDate && (
<div className="bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5" />
Available Times
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{timeSlots.map((slot) => (
<button
key={slot.id}
type="button"
disabled={!slot.available}
onClick={() => setSelectedTimeSlot(slot.id)}
className={`p-3 rounded-lg text-center transition-all ${
!slot.available
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: selectedTimeSlot === slot.id
? 'bg-blue-500 text-white'
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'
}`}
>
<div className="font-medium">
{slot.start_time} - {slot.end_time}
</div>
</button>
))}
</div>
</div>
<TimeSlotSelector
timeSlots={timeSlots}
selectedTimeSlot={selectedTimeSlot}
onTimeSlotChange={setSelectedTimeSlot}
/>
)}
{/* Customer Details */}
{selectedTimeSlot && (
<div className="bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<User className="w-5 h-5" />
Your Details
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Full Name *
</label>
<input
type="text"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your full name"
{selectedTimeSlot && selectedBookingTypeData && (
<form onSubmit={handleSubmit(onSubmit)}>
<CustomerDetails
register={register}
errors={errors}
participantsCount={participantsCount}
onParticipantsCountChange={handleParticipantsCountChange}
bookingType={selectedBookingTypeData}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email Address *
</label>
<input
type="email"
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your email"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
<Users className="w-4 h-4" />
Number of Participants
</label>
<select
value={participantsCount}
onChange={(e) => setParticipantsCount(parseInt(e.target.value))}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{[1, 2, 3, 4, 5, 6, 7, 8].map(num => (
<option key={num} value={num}>{num} participant{num > 1 ? 's' : ''}</option>
))}
</select>
</div>
</div>
</div>
)}
{/* Summary & Submit */}
{selectedTimeSlot && customerName && customerEmail && (
<div className="bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Booking Summary</h2>
<div className="space-y-3 mb-6">
<div className="flex justify-between">
<span className="text-gray-600">Experience:</span>
<span className="font-medium">{selectedBookingTypeData?.display_name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Date:</span>
<span className="font-medium">{selectedDate}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Time:</span>
<span className="font-medium">
{timeSlots.find(s => s.id === selectedTimeSlot)?.start_time} -
{timeSlots.find(s => s.id === selectedTimeSlot)?.end_time}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Participants:</span>
<span className="font-medium">{participantsCount}</span>
</div>
{selectedBookingTypeData?.requires_payment && (
<div className="flex justify-between text-lg font-semibold border-t pt-3">
<span>Total:</span>
<span>${selectedBookingTypeData.price_per_person * participantsCount}</span>
{isValid && (
<div className="mt-8">
<BookingSummary
selectedBookingTypeData={selectedBookingTypeData}
selectedDate={selectedDate}
selectedTimeSlot={selectedTimeSlot}
timeSlots={timeSlots}
participantsCount={participantsCount}
onSubmit={handleSubmit(onSubmit)}
/>
</div>
)}
</div>
<button
onClick={handleSubmit}
className="w-full bg-blue-600 text-white py-4 px-6 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
{selectedBookingTypeData?.requires_payment ? 'Continue to Payment' : 'Confirm Booking'}
</button>
</div>
</form>
)}
</div>
</div>

View File

@@ -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<BookingSummaryProps> = ({
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 (
<div className="bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Booking Summary</h2>
<div className="space-y-3 mb-6">
<div className="flex justify-between">
<span className="text-gray-600">Experience:</span>
<span className="font-medium">{selectedBookingTypeData?.display_name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Date:</span>
<span className="font-medium">{selectedDate}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Time:</span>
<span className="font-medium">
{selectedTimeSlotData?.start_time && selectedTimeSlotData?.end_time
? `${formatTime(selectedTimeSlotData.start_time)}${formatTime(selectedTimeSlotData.end_time)}`
: 'Not selected'
}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Participants:</span>
<span className="font-medium">{participantsCount}</span>
</div>
{selectedBookingTypeData?.requires_payment && (
<div className="flex justify-between text-lg font-semibold border-t pt-3">
<span>Total:</span>
<span>{selectedBookingTypeData.price_per_person * participantsCount}</span>
</div>
)}
</div>
<button
onClick={onSubmit}
className="w-full bg-blue-600 text-white py-4 px-6 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
{selectedBookingTypeData?.requires_payment ? 'Continue to Payment' : 'Confirm Booking'}
</button>
</div>
);
};
export default BookingSummary;

View File

@@ -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<BookingTypeSelectorProps> = ({
bookingTypes,
selectedBookingType,
onBookingTypeChange,
}) => {
const t = useTranslations('bookingForm');
return (
<div className="bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Calendar className="w-5 h-5" />
{t('selectExperience')}
</h2>
<div className="grid gap-3">
{bookingTypes.map((type) => (
<label key={type.id} className="cursor-pointer">
<input
type="radio"
name="bookingType"
value={type.id}
checked={selectedBookingType === type.id}
onChange={(e) => onBookingTypeChange(e.target.value)}
className="sr-only"
/>
<div className={`p-4 rounded-lg border-2 transition-all ${
selectedBookingType === type.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}>
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium text-gray-900">{type.display_name}</h3>
{type.requires_payment && (
<p className="text-sm text-gray-600 mt-1">
{type.price_per_person} {t('perPerson')}
</p>
)}
</div>
{type.requires_payment && (
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
{t('paymentRequired')}
</span>
)}
</div>
</div>
</label>
))}
</div>
</div>
);
};
export default BookingTypeSelector;

View File

@@ -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<FormData>;
errors: FieldErrors<FormData>;
participantsCount: number;
onParticipantsCountChange: (count: number) => void;
bookingType: BookingType;
}
const CustomerDetails: React.FC<CustomerDetailsProps> = ({
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 (
<div className="bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<User className="w-5 h-5" />
Your Details
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Full Name *
</label>
<input
type="text"
{...register('customerName', {
required: 'Full name is required',
minLength: { value: 2, message: 'Name must be at least 2 characters' }
})}
className={`w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors.customerName ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="Enter your full name"
/>
{errors.customerName && (
<p className="text-red-500 text-sm mt-1">{errors.customerName.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email Address *
</label>
<input
type="email"
{...register('customerEmail', {
required: 'Email address is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Please enter a valid email address'
}
})}
className={`w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors.customerEmail ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="Enter your email"
/>
{errors.customerEmail && (
<p className="text-red-500 text-sm mt-1">{errors.customerEmail.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
<Users className="w-4 h-4" />
Number of Participants
</label>
<div className="flex items-center justify-center bg-gray-100 rounded-lg p-1 max-w-xs">
<button
type="button"
onClick={() => onParticipantsCountChange(Math.max(minParticipants, participantsCount - 1))}
className="w-12 h-12 flex items-center justify-center rounded-lg bg-white text-gray-600 hover:bg-gray-50 hover:text-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={participantsCount <= minParticipants}
>
<span className="text-xl font-medium"></span>
</button>
<div className="flex-1 text-center px-4">
<span className="text-xl font-semibold text-gray-900">{participantsCount}</span>
<div className="text-xs text-gray-500">participant{participantsCount > 1 ? 's' : ''}</div>
</div>
<button
type="button"
onClick={() => onParticipantsCountChange(Math.min(maxParticipants, participantsCount + 1))}
className="w-12 h-12 flex items-center justify-center rounded-lg bg-white text-gray-600 hover:bg-gray-50 hover:text-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={participantsCount >= maxParticipants}
>
<span className="text-xl font-medium">+</span>
</button>
</div>
<div className="text-xs text-gray-500 mt-2 text-center">
Min: {minParticipants} Max: {maxParticipants}
</div>
<input
type="hidden"
{...register('participantsCount', {
min: { value: minParticipants, message: `At least ${minParticipants} participants required` },
max: { value: maxParticipants, message: `Maximum ${maxParticipants} participants allowed` }
})}
value={participantsCount}
/>
</div>
</div>
</div>
);
};
export default CustomerDetails;

View File

@@ -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<DateSelectorProps> = ({
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 (
<div className="bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Calendar className="w-5 h-5" />
{t('chooseDate')}
</h2>
{loading ? (
<div className="flex justify-center items-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="ml-2 text-gray-600">Loading available dates...</span>
</div>
) : (
<div className="grid grid-cols-7 gap-2 md:gap-3">
{calendarDays.map((day) => (
<button
key={day.date}
type="button"
onClick={() => day.available && onDateChange(day.date)}
disabled={!day.available}
className={`p-3 rounded-lg text-center transition-all ${
!day.available
? 'bg-gray-50 text-gray-300 cursor-not-allowed'
: selectedDate === day.date
? 'bg-blue-500 text-white'
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'
}`}
>
<div className="text-xs font-medium">{day.dayName}</div>
<div className="text-sm font-semibold">{day.day}</div>
<div className="text-xs">{day.month}</div>
</button>
))}
</div>
)}
</div>
);
};
export default DateSelector;

View File

@@ -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 (
<div className="fixed top-4 right-4 z-50">
<button
onClick={switchLocale}
className="flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-3 py-2 shadow-sm hover:shadow-md transition-shadow text-sm"
>
<Globe className="w-4 h-4" />
<span>{t('current')}</span>
</button>
</div>
);
}

View File

@@ -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<TimeSlotSelectorProps> = ({
timeSlots,
selectedTimeSlot,
onTimeSlotChange,
loading = false,
error = null,
}) => {
const t = useTranslations('bookingForm');
return (
<div className="bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5" />
{t('availableTimes')}
</h2>
{loading ? (
<div className="flex justify-center items-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="ml-2 text-gray-600">Loading available times...</span>
</div>
) : error ? (
<div className="text-center py-8">
<p className="text-gray-600">{error}</p>
</div>
) : timeSlots.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-600">No available time slots for this date</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{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 (
<button
key={slot.id}
type="button"
disabled={!isAvailable}
onClick={() => isAvailable && onTimeSlotChange(slot.id)}
className={`p-3 rounded-lg text-center transition-all ${
!isAvailable
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: selectedTimeSlot === slot.id
? 'bg-blue-500 text-white'
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'
}`}
>
<div className="font-medium">
{formattedTime}
</div>
{slot.availableCapacity !== undefined && slot.availableCapacity > 0 && (
<div className="text-xs mt-1 text-green-600">
{slot.availableCapacity} spots left
</div>
)}
{!isAvailable && (
<div className="text-xs mt-1">Unavailable</div>
)}
</button>
);
})}
</div>
)}
</div>
);
};
export default TimeSlotSelector;

View File

@@ -0,0 +1,15 @@
import { bookingApi } from './pocketbase';
import { BookingType } from '@/types/bookings';
export class BookingService {
static async getBookingTypes(): Promise<BookingType[]> {
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.');
}
}
}

16
apps/web/lib/i18n.ts Normal file
View File

@@ -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
};
});

View File

@@ -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<BookingType[]> {
@@ -24,121 +23,125 @@ export const bookingApi = {
}
},
// Get available resources
async getResources(): Promise<Resource[]> {
// Get time slots for a specific booking type
async getTimeSlotsForBookingType(bookingTypeId: string): Promise<TimeSlot[]> {
try {
const records = await pb.collection('resources').getFullList<Resource>({
filter: 'is_active = true',
sort: 'type',
});
return records;
} catch (error) {
console.error('Error fetching resources:', error);
throw error;
}
},
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<TimeSlot[]> {
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<TimeSlot>({
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<Booking>({
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);
// Step 1: Generate massive array of individual time slots with specific dates
const allTimeSlots: { start_time: string; end_time: string }[] = [];
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;
}
// 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;
// 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);
}
});
// Get the time slot to check max capacity
const timeSlot = await pb.collection('timeSlots').getOne<TimeSlot>(timeSlotId);
// Step 2: Group time slots by date (without time)
const availableSlotsByDate: { [date: string]: { start_time: string; end_time: string }[] } = {};
const currentBookings = existingBookings.reduce((sum, booking) =>
sum + (booking.participants_count || 0), 0
);
allTimeSlots.forEach(timeSlot => {
const dateStr = timeSlot.start_time.split('T')[0];
const availableSpots = timeSlot.max_capacity - currentBookings;
const available = availableSpots >= participantsCount;
return {
available,
currentBookings,
};
} catch (error) {
console.error('Error checking availability:', error);
throw error;
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<Booking> {
// Get all bookings for a specific date filtered by booking type IDs
async getBookingsForDate(
date: string,
bookingTypeIds: string[]
): Promise<Booking[]> {
try {
const record = await pb.collection('bookings').create<Booking>({
...bookingData,
status: 'confirmed',
payment_status: 'not_required', // We'll handle payment logic later
payment_required: false,
});
return record;
} catch (error) {
console.error('Error creating booking:', error);
throw error;
}
},
// Create filter for booking type IDs
const bookingTypeFilter = bookingTypeIds.map(id => `booking_type = "${id}"`).join(' || ');
// Get booking by cancellation token (for cancellation flow)
async getBookingByToken(token: string): Promise<Booking | null> {
try {
const records = await pb.collection('bookings').getFullList<Booking>({
filter: `cancellation_token = "${token}"`,
const bookings = await pb.collection('bookings').getFullList<Booking>({
filter: `status = "confirmed" && start_time ~ "${date}" && (${bookingTypeFilter})`,
sort: 'start_time'
});
return records[0] || null;
} catch (error) {
console.error('Error fetching booking by token:', error);
return null;
}
},
// Cancel booking
async cancelBooking(bookingId: string): Promise<boolean> {
try {
await pb.collection('bookings').update(bookingId, {
status: 'cancelled',
});
return true;
console.log(`Bookings for ${date}:`, bookings);
return bookings;
} catch (error) {
console.error('Error cancelling booking:', error);
return false;
console.error('Error fetching bookings for date:', error);
return [];
}
},
};

67
apps/web/messages/en.json Normal file
View File

@@ -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"
}
}
}

67
apps/web/messages/ka.json Normal file
View File

@@ -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": "დეკ"
}
}
}

22
apps/web/middleware.ts Normal file
View File

@@ -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'
};

View File

@@ -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);

View File

@@ -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": {

View File

@@ -1,6 +1,10 @@
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
"plugins": [
{
"name": "next"

View File

@@ -1,46 +1,57 @@
// types/booking.ts
export interface BookingType {
// Base types for PocketBase records
export interface BaseRecord {
id: string;
created: string;
updated: string;
}
// Resource entity
export interface Resource extends BaseRecord {
name: string;
display_name: string;
requires_payment: boolean;
default_duration: number; // in minutes
description?: string;
resources: string; // relation to resources collection
created: string;
updated: string;
}
export interface Resource {
id: string;
type: 'wheel' | 'workstation';
capacity: number;
usage_capacity: number;
is_active: boolean;
created: string;
updated: string;
}
export interface TimeSlot {
id: string;
booking_types: string[]; // relation to booking types
// 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;
}
// Time Slot entity
export interface TimeSlot extends BaseRecord {
booking_types: string[]; // relation to BookingType (many-to-many)
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;
is_reccuring?: boolean;
recurrence_pattern?: RecurrencePattern;
}
export interface Booking {
id: string;
booking_type: string; // relation to booking type
// 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;
@@ -51,29 +62,126 @@ export interface Booking {
payment_status: 'not_required' | 'paid' | 'refunded' | 'partially_refunded' | 'pending';
payment_required: boolean;
cancellation_token: string;
created: string;
updated: string;
use_subscription?: string; // relation to subscription (future feature)
}
// Frontend form types
export interface BookingFormData {
bookingTypeId: string;
customerName: string;
customerEmail: string;
startTime: string;
endTime: string;
participantsCount: number;
startTime: Date;
endTime: Date;
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;
// 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<Booking, 'booking_type'> {
expand?: {
booking_type?: BookingType;
};
}
export interface BookingTypeWithRelations extends Omit<BookingType, 'resources'> {
expand?: {
resources?: Resource;
};
}
export interface TimeSlotWithRelations extends Omit<TimeSlot, 'booking_types'> {
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;

124
pnpm-lock.yaml generated
View File

@@ -1,3 +1,5 @@
# pnpm-lock.yaml
lockfileVersion: '9.0'
settings:
@@ -71,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
@@ -83,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
@@ -227,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'}
@@ -455,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==}
@@ -765,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==}
@@ -1044,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'}
@@ -1312,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}
@@ -1437,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==}
@@ -1682,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'}
@@ -1767,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':
@@ -1931,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
@@ -2293,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:
@@ -2685,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
@@ -2932,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
@@ -3063,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: {}
@@ -3394,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

View File

@@ -1,3 +1,5 @@
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"