Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd69dbceaf | |||
| 5a890ac253 | |||
| b94e205787 | |||
| 7ff599aae0 | |||
| 9d8ef03fea | |||
| a4a95e8649 | |||
| c7f6ad43c4 | |||
| a4ad1d8645 | |||
|
|
ff6de00988 | ||
|
|
459289df2d | ||
|
|
c0647fe512 | ||
|
|
47baf8dfe2 |
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.env.local
|
||||||
64
.gitea/workflows/docker-build.yaml
Normal 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
@@ -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
|
||||||
70
Dockerfile
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Stage 1: Dependencies
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm install -g pnpm@9.0.0
|
||||||
|
|
||||||
|
# Copy all package.json files for proper dependency resolution
|
||||||
|
COPY package.json pnpm-lock.yaml turbo.json pnpm-workspace.yaml ./
|
||||||
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
COPY apps/pocketbase/package.json ./apps/pocketbase/
|
||||||
|
COPY packages/*/package.json ./packages/
|
||||||
|
|
||||||
|
# Create the directory structure that might be missing
|
||||||
|
RUN mkdir -p packages/ui packages/eslint-config packages/typescript-config
|
||||||
|
|
||||||
|
# Copy package.json files to their correct locations
|
||||||
|
COPY packages/ui/package.json ./packages/ui/
|
||||||
|
COPY packages/eslint-config/package.json ./packages/eslint-config/
|
||||||
|
COPY packages/typescript-config/package.json ./packages/typescript-config/
|
||||||
|
|
||||||
|
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 and source
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=deps /app/pnpm-lock.yaml ./
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build only the web app
|
||||||
|
RUN pnpm turbo build --filter=web
|
||||||
|
|
||||||
|
# Stage 3: Runtime
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN npm install -g pnpm@9.0.0
|
||||||
|
|
||||||
|
# Copy package files for production install
|
||||||
|
COPY package.json pnpm-lock.yaml turbo.json ./
|
||||||
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
COPY packages/*/package.json ./packages/
|
||||||
|
|
||||||
|
# Install only production dependencies
|
||||||
|
RUN pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy built Next.js application
|
||||||
|
COPY --from=builder /app/apps/web/.next ./apps/web/.next
|
||||||
|
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||||
|
COPY --from=builder /app/apps/web/next.config.js ./apps/web/
|
||||||
|
COPY --from=builder /app/apps/web/package.json ./apps/web/
|
||||||
|
|
||||||
|
# Copy other necessary files
|
||||||
|
COPY --from=builder /app/apps/web/lib ./apps/web/lib
|
||||||
|
COPY --from=builder /app/apps/web/messages ./apps/web/messages
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nextjs -u 1001
|
||||||
|
|
||||||
|
# Change ownership of the app directory
|
||||||
|
RUN chown -R nextjs:nodejs /app
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Use Next.js start command
|
||||||
|
CMD ["pnpm", "--filter=web", "start"]
|
||||||
36
apps/admin/.gitignore
vendored
@@ -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
|
|
||||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
.page {
|
|
||||||
--gray-rgb: 0, 0, 0;
|
|
||||||
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
|
|
||||||
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
|
|
||||||
|
|
||||||
--button-primary-hover: #383838;
|
|
||||||
--button-secondary-hover: #f2f2f2;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 20px 1fr 20px;
|
|
||||||
align-items: center;
|
|
||||||
justify-items: center;
|
|
||||||
min-height: 100svh;
|
|
||||||
padding: 80px;
|
|
||||||
gap: 64px;
|
|
||||||
font-synthesis: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.page {
|
|
||||||
--gray-rgb: 255, 255, 255;
|
|
||||||
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
|
|
||||||
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
|
|
||||||
|
|
||||||
--button-primary-hover: #ccc;
|
|
||||||
--button-secondary-hover: #1a1a1a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 32px;
|
|
||||||
grid-row-start: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main ol {
|
|
||||||
font-family: var(--font-geist-mono);
|
|
||||||
padding-left: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 24px;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
list-style-position: inside;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main li:not(:last-of-type) {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main code {
|
|
||||||
font-family: inherit;
|
|
||||||
background: var(--gray-alpha-100);
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctas {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctas a {
|
|
||||||
appearance: none;
|
|
||||||
border-radius: 128px;
|
|
||||||
height: 48px;
|
|
||||||
padding: 0 20px;
|
|
||||||
border: none;
|
|
||||||
font-family: var(--font-geist-sans);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 20px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.primary {
|
|
||||||
background: var(--foreground);
|
|
||||||
color: var(--background);
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.secondary {
|
|
||||||
border-color: var(--gray-alpha-200);
|
|
||||||
min-width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.secondary {
|
|
||||||
appearance: none;
|
|
||||||
border-radius: 128px;
|
|
||||||
height: 48px;
|
|
||||||
padding: 0 20px;
|
|
||||||
border: none;
|
|
||||||
font-family: var(--font-geist-sans);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 20px;
|
|
||||||
font-weight: 500;
|
|
||||||
background: transparent;
|
|
||||||
border-color: var(--gray-alpha-200);
|
|
||||||
min-width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
font-family: var(--font-geist-sans);
|
|
||||||
grid-row-start: 3;
|
|
||||||
display: flex;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer img {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enable hover only on non-touch devices */
|
|
||||||
@media (hover: hover) and (pointer: fine) {
|
|
||||||
a.primary:hover {
|
|
||||||
background: var(--button-primary-hover);
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.secondary:hover {
|
|
||||||
background: var(--button-secondary-hover);
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.page {
|
|
||||||
padding: 32px;
|
|
||||||
padding-bottom: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main ol {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctas {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctas a {
|
|
||||||
font-size: 14px;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.secondary {
|
|
||||||
min-width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.logo {
|
|
||||||
filter: invert();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.Config} */
|
|
||||||
export default nextJsConfig;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
22
apps/pocketbase/pb_migrations/1756243147_updated_bookings.js
Normal 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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
40
apps/web/app/[locale]/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
apps/web/app/[locale]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
apps/web/app/api/booking-types/[id]/available-dates/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
apps/web/app/api/booking-types/[id]/time-slots/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,17 +12,17 @@ const geistMono = localFont({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Vitrify - Book Your Pottery Session",
|
||||||
description: "Generated by create next app",
|
description: "Book pottery sessions and workshops",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html>
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,22 +1,5 @@
|
|||||||
import BookingInterface from "../components/BookingForm";
|
import {redirect} from 'next/navigation';
|
||||||
|
|
||||||
export default function Home() {
|
export default function RootPage() {
|
||||||
return (
|
redirect('/en');
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,271 +1,246 @@
|
|||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Calendar, Clock, User, Mail, Users } from 'lucide-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 BookingInterface = () => {
|
||||||
|
const t = useTranslations('bookingForm');
|
||||||
const [selectedDate, setSelectedDate] = useState('');
|
const [selectedDate, setSelectedDate] = useState('');
|
||||||
const [selectedTimeSlot, setSelectedTimeSlot] = useState('');
|
const [selectedTimeSlot, setSelectedTimeSlot] = useState('');
|
||||||
const [selectedBookingType, setSelectedBookingType] = useState('');
|
const [selectedBookingType, setSelectedBookingType] = useState('');
|
||||||
const [customerName, setCustomerName] = useState('');
|
|
||||||
const [customerEmail, setCustomerEmail] = useState('');
|
|
||||||
const [participantsCount, setParticipantsCount] = useState(1);
|
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 { register, handleSubmit, formState: { errors, isValid }, setValue, trigger } = useForm<FormData>({
|
||||||
const bookingTypes = [
|
mode: 'onChange',
|
||||||
{ id: '1', name: 'wheel_rental', display_name: 'Wheel Rental', requires_payment: false, price_per_person: 0 },
|
defaultValues: {
|
||||||
{ id: '2', name: 'hand_building', display_name: 'Hand Building Coworking', requires_payment: false, price_per_person: 0 },
|
customerName: '',
|
||||||
{ id: '3', name: 'personal_workshop', display_name: 'Personal Workshop', requires_payment: true, price_per_person: 75 },
|
customerEmail: '',
|
||||||
{ id: '4', name: 'group_workshop', display_name: 'Group Workshop', requires_payment: true, price_per_person: 50 }
|
participantsCount: 1
|
||||||
];
|
}
|
||||||
|
});
|
||||||
// Mock available time slots
|
|
||||||
const timeSlots = [
|
|
||||||
{ id: '1', start_time: '09:00', end_time: '11:00', available: true },
|
|
||||||
{ id: '2', start_time: '11:30', end_time: '13:30', available: true },
|
|
||||||
{ id: '3', start_time: '14:00', end_time: '16:00', available: false },
|
|
||||||
{ id: '4', start_time: '16:30', end_time: '18:30', available: true },
|
|
||||||
{ id: '5', start_time: '19:00', end_time: '21:00', available: true }
|
|
||||||
];
|
|
||||||
|
|
||||||
const selectedBookingTypeData = bookingTypes.find(bt => bt.id === selectedBookingType);
|
const selectedBookingTypeData = bookingTypes.find(bt => bt.id === selectedBookingType);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
// Fetch booking types on mount
|
||||||
console.log({
|
useEffect(() => {
|
||||||
selectedBookingType,
|
const fetchBookingTypes = async () => {
|
||||||
selectedDate,
|
try {
|
||||||
selectedTimeSlot,
|
setLoading(true);
|
||||||
customerName,
|
setError(null);
|
||||||
customerEmail,
|
const types = await BookingService.getBookingTypes();
|
||||||
participantsCount
|
setBookingTypes(types);
|
||||||
});
|
} catch (err) {
|
||||||
};
|
setError(err instanceof Error ? err.message : 'Failed to load booking types');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const generateCalendarDays = () => {
|
fetchBookingTypes();
|
||||||
const today = new Date();
|
}, []);
|
||||||
const days = [];
|
|
||||||
|
// Available dates are now handled in handleBookingTypeChange
|
||||||
|
|
||||||
|
// Time slots are now handled in handleDateChange
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue('participantsCount', participantsCount);
|
||||||
|
trigger('participantsCount');
|
||||||
|
}, [participantsCount, setValue, trigger]);
|
||||||
|
|
||||||
|
const onSubmit = (data: FormData) => {
|
||||||
|
const selectedTimeSlotData = timeSlots.find(slot => slot.id === selectedTimeSlot);
|
||||||
|
|
||||||
for (let i = 0; i < 14; i++) {
|
const formData = {
|
||||||
const date = new Date(today);
|
bookingTypeId: selectedBookingType,
|
||||||
date.setDate(today.getDate() + i);
|
customerName: data.customerName,
|
||||||
days.push({
|
customerEmail: data.customerEmail,
|
||||||
date: date.toISOString().split('T')[0],
|
startTime: selectedDate && selectedTimeSlotData
|
||||||
day: date.getDate(),
|
? `${selectedDate}T${selectedTimeSlotData.start_time}`
|
||||||
month: date.toLocaleDateString('en', { month: 'short' }),
|
: '',
|
||||||
dayName: date.toLocaleDateString('en', { weekday: 'short' })
|
endTime: selectedDate && selectedTimeSlotData
|
||||||
});
|
? `${selectedDate}T${selectedTimeSlotData.end_time}`
|
||||||
}
|
: '',
|
||||||
return days;
|
participantsCount: data.participantsCount,
|
||||||
|
};
|
||||||
|
console.log(formData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calendarDays = generateCalendarDays();
|
const handleParticipantsCountChange = (count: number) => {
|
||||||
|
setParticipantsCount(count);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBookingTypeChange = async (typeId: string) => {
|
||||||
|
setSelectedBookingType(typeId);
|
||||||
|
setSelectedDate('');
|
||||||
|
setSelectedTimeSlot('');
|
||||||
|
setTimeSlots([]);
|
||||||
|
|
||||||
|
// Set participants count to booking type's minimum capacity
|
||||||
|
const bookingType = bookingTypes.find(bt => bt.id === typeId);
|
||||||
|
if (bookingType) {
|
||||||
|
setParticipantsCount(bookingType.min_participants_capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch available dates from server API (with capacity pre-calculated)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/booking-types/${typeId}/available-dates`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setAvailableDates(data.availableDates);
|
||||||
|
} else {
|
||||||
|
console.error('Error fetching available dates:', data.error);
|
||||||
|
setAvailableDates([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching available dates:', error);
|
||||||
|
setAvailableDates([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateChange = async (date: string) => {
|
||||||
|
setSelectedDate(date);
|
||||||
|
setSelectedTimeSlot('');
|
||||||
|
|
||||||
|
// Fetch time slots with capacity from server API
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/booking-types/${selectedBookingType}/time-slots?date=${date}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Convert server response to TimeSlot format
|
||||||
|
const formattedTimeSlots: TimeSlot[] = data.timeSlots.map((slot: any) => ({
|
||||||
|
id: slot.id,
|
||||||
|
start_time: slot.start_time,
|
||||||
|
end_time: slot.end_time,
|
||||||
|
is_active: slot.is_active,
|
||||||
|
booking_capacity: slot.maxCapacity,
|
||||||
|
booking_types: [selectedBookingType],
|
||||||
|
is_reccuring: false,
|
||||||
|
recurrence_pattern: undefined,
|
||||||
|
resources: [],
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
availableCapacity: slot.availableCapacity // Add capacity info for UI
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTimeSlots(formattedTimeSlots);
|
||||||
|
} else {
|
||||||
|
console.error('Error fetching time slots:', data.error);
|
||||||
|
setTimeSlots([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching time slots:', error);
|
||||||
|
setTimeSlots([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-4">
|
<div className="min-h-screen bg-gray-50 p-4">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Book Your Session</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('title')}</h1>
|
||||||
<p className="text-gray-600">Choose your pottery experience</p>
|
<p className="text-gray-600">{t('subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Booking Type Selection */}
|
{/* Loading State */}
|
||||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
{loading && (
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
<div className="bg-white rounded-xl p-6 shadow-sm text-center">
|
||||||
<Calendar className="w-5 h-5" />
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||||
Select Experience
|
<p className="text-gray-600 mt-2">{t('loading')}</p>
|
||||||
</h2>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{bookingTypes.map((type) => (
|
|
||||||
<label key={type.id} className="cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="bookingType"
|
|
||||||
value={type.id}
|
|
||||||
checked={selectedBookingType === type.id}
|
|
||||||
onChange={(e) => setSelectedBookingType(e.target.value)}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
<div className={`p-4 rounded-lg border-2 transition-all ${
|
|
||||||
selectedBookingType === type.id
|
|
||||||
? 'border-blue-500 bg-blue-50'
|
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
|
||||||
}`}>
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-gray-900">{type.display_name}</h3>
|
|
||||||
{type.requires_payment && (
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
${type.price_per_person} per person
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{type.requires_payment && (
|
|
||||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
|
||||||
Payment Required
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
||||||
|
{!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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Date Selection */}
|
{/* Date Selection */}
|
||||||
{selectedBookingType && (
|
{selectedBookingType && (
|
||||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
<DateSelector
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
selectedDate={selectedDate}
|
||||||
<Calendar className="w-5 h-5" />
|
availableDates={availableDates}
|
||||||
Choose Date
|
onDateChange={handleDateChange}
|
||||||
</h2>
|
/>
|
||||||
<div className="grid grid-cols-7 gap-2 md:gap-3">
|
|
||||||
{calendarDays.map((day) => (
|
|
||||||
<button
|
|
||||||
key={day.date}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedDate(day.date)}
|
|
||||||
className={`p-3 rounded-lg text-center transition-all ${
|
|
||||||
selectedDate === day.date
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="text-xs font-medium">{day.dayName}</div>
|
|
||||||
<div className="text-sm font-semibold">{day.day}</div>
|
|
||||||
<div className="text-xs">{day.month}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Time Slot Selection */}
|
{/* Time Slot Selection */}
|
||||||
{selectedDate && (
|
{selectedDate && (
|
||||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
<TimeSlotSelector
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
timeSlots={timeSlots}
|
||||||
<Clock className="w-5 h-5" />
|
selectedTimeSlot={selectedTimeSlot}
|
||||||
Available Times
|
onTimeSlotChange={setSelectedTimeSlot}
|
||||||
</h2>
|
/>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
||||||
{timeSlots.map((slot) => (
|
|
||||||
<button
|
|
||||||
key={slot.id}
|
|
||||||
type="button"
|
|
||||||
disabled={!slot.available}
|
|
||||||
onClick={() => setSelectedTimeSlot(slot.id)}
|
|
||||||
className={`p-3 rounded-lg text-center transition-all ${
|
|
||||||
!slot.available
|
|
||||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
||||||
: selectedTimeSlot === slot.id
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="font-medium">
|
|
||||||
{slot.start_time} - {slot.end_time}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Customer Details */}
|
{/* Customer Details */}
|
||||||
{selectedTimeSlot && (
|
{selectedTimeSlot && selectedBookingTypeData && (
|
||||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
<CustomerDetails
|
||||||
<User className="w-5 h-5" />
|
register={register}
|
||||||
Your Details
|
errors={errors}
|
||||||
</h2>
|
participantsCount={participantsCount}
|
||||||
|
onParticipantsCountChange={handleParticipantsCountChange}
|
||||||
|
bookingType={selectedBookingTypeData}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Summary & Submit */}
|
||||||
<div>
|
{isValid && (
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<div className="mt-8">
|
||||||
Full Name *
|
<BookingSummary
|
||||||
</label>
|
selectedBookingTypeData={selectedBookingTypeData}
|
||||||
<input
|
selectedDate={selectedDate}
|
||||||
type="text"
|
selectedTimeSlot={selectedTimeSlot}
|
||||||
value={customerName}
|
timeSlots={timeSlots}
|
||||||
onChange={(e) => setCustomerName(e.target.value)}
|
participantsCount={participantsCount}
|
||||||
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
placeholder="Enter your full name"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
</form>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Email Address *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={customerEmail}
|
|
||||||
onChange={(e) => setCustomerEmail(e.target.value)}
|
|
||||||
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
placeholder="Enter your email"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
|
|
||||||
<Users className="w-4 h-4" />
|
|
||||||
Number of Participants
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={participantsCount}
|
|
||||||
onChange={(e) => setParticipantsCount(parseInt(e.target.value))}
|
|
||||||
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8].map(num => (
|
|
||||||
<option key={num} value={num}>{num} participant{num > 1 ? 's' : ''}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Summary & Submit */}
|
|
||||||
{selectedTimeSlot && customerName && customerEmail && (
|
|
||||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Booking Summary</h2>
|
|
||||||
|
|
||||||
<div className="space-y-3 mb-6">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Experience:</span>
|
|
||||||
<span className="font-medium">{selectedBookingTypeData?.display_name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Date:</span>
|
|
||||||
<span className="font-medium">{selectedDate}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Time:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{timeSlots.find(s => s.id === selectedTimeSlot)?.start_time} -
|
|
||||||
{timeSlots.find(s => s.id === selectedTimeSlot)?.end_time}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Participants:</span>
|
|
||||||
<span className="font-medium">{participantsCount}</span>
|
|
||||||
</div>
|
|
||||||
{selectedBookingTypeData?.requires_payment && (
|
|
||||||
<div className="flex justify-between text-lg font-semibold border-t pt-3">
|
|
||||||
<span>Total:</span>
|
|
||||||
<span>${selectedBookingTypeData.price_per_person * participantsCount}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="w-full bg-blue-600 text-white py-4 px-6 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
{selectedBookingTypeData?.requires_payment ? 'Continue to Payment' : 'Confirm Booking'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
84
apps/web/components/BookingSummary.tsx
Normal 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;
|
||||||
64
apps/web/components/BookingTypeSelector.tsx
Normal 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;
|
||||||
130
apps/web/components/CustomerDetails.tsx
Normal 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;
|
||||||
93
apps/web/components/DateSelector.tsx
Normal 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;
|
||||||
30
apps/web/components/LanguageSwitcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
apps/web/components/TimeSlotSelector.tsx
Normal 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;
|
||||||
15
apps/web/lib/bookingService.ts
Normal 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
@@ -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
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import PocketBase from 'pocketbase';
|
import PocketBase from 'pocketbase';
|
||||||
import { BookingType, Resource, TimeSlot, Booking } from '@/types/booking';
|
import { BookingType, TimeSlot, Booking } from '@/types/bookings';
|
||||||
|
|
||||||
// Initialize PocketBase client
|
// Initialize PocketBase client
|
||||||
export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || 'http://127.0.0.1:8090');
|
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);
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
// API functions for booking system
|
// API functions for booking system
|
||||||
|
|
||||||
export const bookingApi = {
|
export const bookingApi = {
|
||||||
// Get all active booking types
|
// Get all active booking types
|
||||||
async getBookingTypes(): Promise<BookingType[]> {
|
async getBookingTypes(): Promise<BookingType[]> {
|
||||||
@@ -24,121 +23,125 @@ export const bookingApi = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get available resources
|
// Get time slots for a specific booking type
|
||||||
async getResources(): Promise<Resource[]> {
|
async getTimeSlotsForBookingType(bookingTypeId: string): Promise<TimeSlot[]> {
|
||||||
try {
|
try {
|
||||||
const records = await pb.collection('resources').getFullList<Resource>({
|
const today = new Date();
|
||||||
filter: 'is_active = true',
|
const oneMonthFromToday = new Date();
|
||||||
sort: 'type',
|
oneMonthFromToday.setMonth(today.getMonth() + 1);
|
||||||
});
|
|
||||||
return records;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching resources:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get available time slots for a specific booking type and date range
|
const todayStr = today.toISOString().split('T')[0];
|
||||||
async getAvailableTimeSlots(
|
const oneMonthStr = oneMonthFromToday.toISOString().split('T')[0];
|
||||||
bookingTypeId: string,
|
|
||||||
startDate: Date,
|
|
||||||
endDate: Date
|
|
||||||
): Promise<TimeSlot[]> {
|
|
||||||
try {
|
|
||||||
const startDateStr = startDate.toISOString().split('T')[0];
|
|
||||||
const endDateStr = endDate.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
const records = await pb.collection('timeSlots').getFullList<TimeSlot>({
|
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',
|
sort: 'start_time',
|
||||||
});
|
});
|
||||||
return records;
|
return records;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching time slots:', error);
|
console.error('Error fetching time slots for booking type:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Check if a time slot has availability
|
// Generate available time slots grouped by date
|
||||||
async checkTimeSlotAvailability(
|
generateAvailableTimeSlots(timeSlots: TimeSlot[]): { [date: string]: { start_time: string; end_time: string }[] } {
|
||||||
timeSlotId: string,
|
const today = new Date();
|
||||||
startTime: string,
|
const oneMonthFromToday = new Date();
|
||||||
endTime: string,
|
oneMonthFromToday.setMonth(today.getMonth() + 1);
|
||||||
participantsCount: number
|
|
||||||
): Promise<{ available: boolean; currentBookings: number }> {
|
|
||||||
try {
|
|
||||||
// Get existing confirmed bookings for this time slot
|
|
||||||
const existingBookings = await pb.collection('bookings').getFullList<Booking>({
|
|
||||||
filter: `status = "confirmed" && start_time >= "${startTime}" && end_time <= "${endTime}"`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the time slot to check max capacity
|
// Step 1: Generate massive array of individual time slots with specific dates
|
||||||
const timeSlot = await pb.collection('timeSlots').getOne<TimeSlot>(timeSlotId);
|
const allTimeSlots: { start_time: string; end_time: string }[] = [];
|
||||||
|
|
||||||
const currentBookings = existingBookings.reduce((sum, booking) =>
|
timeSlots.forEach(slot => {
|
||||||
sum + (booking.participants_count || 0), 0
|
if (!slot.is_reccuring || !slot.recurrence_pattern) {
|
||||||
);
|
// Handle non-recurring slots - use as is
|
||||||
|
allTimeSlots.push({
|
||||||
|
start_time: slot.start_time,
|
||||||
|
end_time: slot.end_time
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const availableSpots = timeSlot.max_capacity - currentBookings;
|
// Handle recurring slots - generate dates based on recurrence pattern
|
||||||
const available = availableSpots >= participantsCount;
|
const pattern = slot.recurrence_pattern;
|
||||||
|
const endDate = pattern.end_date ? new Date(pattern.end_date) : oneMonthFromToday;
|
||||||
|
const finalEndDate = endDate < oneMonthFromToday ? endDate : oneMonthFromToday;
|
||||||
|
|
||||||
return {
|
// Extract time from original start_time and end_time
|
||||||
available,
|
const originalStartTime = new Date(slot.start_time);
|
||||||
currentBookings,
|
const originalEndTime = new Date(slot.end_time);
|
||||||
};
|
const startHours = originalStartTime.getHours();
|
||||||
} catch (error) {
|
const startMinutes = originalStartTime.getMinutes();
|
||||||
console.error('Error checking availability:', error);
|
const endHours = originalEndTime.getHours();
|
||||||
throw error;
|
const endMinutes = originalEndTime.getMinutes();
|
||||||
}
|
|
||||||
|
let currentDate = new Date(today);
|
||||||
|
|
||||||
|
while (currentDate <= finalEndDate) {
|
||||||
|
const dayOfWeek = currentDate.getDay();
|
||||||
|
// Convert Sunday (0) to 6, Monday (1) to 0, etc. to match pattern format
|
||||||
|
const patternDay = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||||
|
|
||||||
|
if (pattern.days && pattern.days.includes(patternDay)) {
|
||||||
|
// Create specific datetime for this occurrence
|
||||||
|
const specificStartTime = new Date(currentDate);
|
||||||
|
specificStartTime.setHours(startHours, startMinutes, 0, 0);
|
||||||
|
|
||||||
|
const specificEndTime = new Date(currentDate);
|
||||||
|
specificEndTime.setHours(endHours, endMinutes, 0, 0);
|
||||||
|
|
||||||
|
allTimeSlots.push({
|
||||||
|
start_time: specificStartTime.toISOString(),
|
||||||
|
end_time: specificEndTime.toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Group time slots by date (without time)
|
||||||
|
const availableSlotsByDate: { [date: string]: { start_time: string; end_time: string }[] } = {};
|
||||||
|
|
||||||
|
allTimeSlots.forEach(timeSlot => {
|
||||||
|
const dateStr = timeSlot.start_time.split('T')[0];
|
||||||
|
|
||||||
|
if (dateStr) {
|
||||||
|
if (!availableSlotsByDate[dateStr]) {
|
||||||
|
availableSlotsByDate[dateStr] = [];
|
||||||
|
}
|
||||||
|
availableSlotsByDate[dateStr].push(timeSlot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Sort time slots within each date
|
||||||
|
Object.keys(availableSlotsByDate).forEach(date => {
|
||||||
|
availableSlotsByDate[date]?.sort((a, b) => a.start_time.localeCompare(b.start_time));
|
||||||
|
});
|
||||||
|
|
||||||
|
return availableSlotsByDate;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create a new booking
|
// Get all bookings for a specific date filtered by booking type IDs
|
||||||
async createBooking(bookingData: {
|
async getBookingsForDate(
|
||||||
booking_type: string;
|
date: string,
|
||||||
customer_name: string;
|
bookingTypeIds: string[]
|
||||||
customer_email: string;
|
): Promise<Booking[]> {
|
||||||
start_time: string;
|
|
||||||
end_time: string;
|
|
||||||
participants_count: number;
|
|
||||||
internal_notes?: string;
|
|
||||||
}): Promise<Booking> {
|
|
||||||
try {
|
try {
|
||||||
const record = await pb.collection('bookings').create<Booking>({
|
// Create filter for booking type IDs
|
||||||
...bookingData,
|
const bookingTypeFilter = bookingTypeIds.map(id => `booking_type = "${id}"`).join(' || ');
|
||||||
status: 'confirmed',
|
|
||||||
payment_status: 'not_required', // We'll handle payment logic later
|
const bookings = await pb.collection('bookings').getFullList<Booking>({
|
||||||
payment_required: false,
|
filter: `status = "confirmed" && start_time ~ "${date}" && (${bookingTypeFilter})`,
|
||||||
|
sort: 'start_time'
|
||||||
});
|
});
|
||||||
return record;
|
|
||||||
|
console.log(`Bookings for ${date}:`, bookings);
|
||||||
|
return bookings;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating booking:', error);
|
console.error('Error fetching bookings for date:', error);
|
||||||
throw error;
|
return [];
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get booking by cancellation token (for cancellation flow)
|
|
||||||
async getBookingByToken(token: string): Promise<Booking | null> {
|
|
||||||
try {
|
|
||||||
const records = await pb.collection('bookings').getFullList<Booking>({
|
|
||||||
filter: `cancellation_token = "${token}"`,
|
|
||||||
});
|
|
||||||
return records[0] || null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching booking by token:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Cancel booking
|
|
||||||
async cancelBooking(bookingId: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await pb.collection('bookings').update(bookingId, {
|
|
||||||
status: 'cancelled',
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error cancelling booking:', error);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
67
apps/web/messages/en.json
Normal 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
@@ -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
@@ -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'
|
||||||
|
};
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin('./lib/i18n.ts');
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {};
|
const nextConfig = {};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
@@ -15,10 +15,12 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.12",
|
"@tailwindcss/postcss": "^4.1.12",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"next": "^15.5.0",
|
"next": "^15.5.0",
|
||||||
|
"next-intl": "^4.3.5",
|
||||||
"pocketbase": "^0.26.2",
|
"pocketbase": "^0.26.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-hook-form": "^7.62.0",
|
||||||
"tailwindcss": "^4.1.12"
|
"tailwindcss": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"extends": "@repo/typescript-config/nextjs.json",
|
"extends": "@repo/typescript-config/nextjs.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
|
|||||||
@@ -1,79 +1,187 @@
|
|||||||
// types/booking.ts
|
// Base types for PocketBase records
|
||||||
|
export interface BaseRecord {
|
||||||
export interface BookingType {
|
id: string;
|
||||||
id: string;
|
created: string;
|
||||||
name: string;
|
updated: 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 {
|
// Resource entity
|
||||||
id: string;
|
export interface Resource extends BaseRecord {
|
||||||
type: 'wheel' | 'workstation';
|
name: string;
|
||||||
capacity: number;
|
type: 'wheel' | 'workstation';
|
||||||
is_active: boolean;
|
usage_capacity: number;
|
||||||
created: string;
|
is_active: boolean;
|
||||||
updated: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeSlot {
|
// Booking Type entity
|
||||||
id: string;
|
export interface BookingType extends BaseRecord {
|
||||||
booking_types: string[]; // relation to booking types
|
name: string;
|
||||||
start_time: string;
|
type: 'workshop' | 'rental';
|
||||||
end_time: string;
|
display_name: string;
|
||||||
is_reccuring: boolean;
|
description?: string;
|
||||||
max_capacity: number;
|
requires_payment?: boolean;
|
||||||
is_active: boolean;
|
base_duration: number; // minutes, min 30
|
||||||
recurrence_pattern?: {
|
min_duration: number;
|
||||||
type: string;
|
price_per_person: number;
|
||||||
days: number[];
|
resources: string; // relation to Resource
|
||||||
end_date: string;
|
min_participants_capacity: number;
|
||||||
};
|
max_participants_capacity: number;
|
||||||
created: string;
|
is_active: boolean;
|
||||||
updated: string;
|
booking_capacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Booking {
|
// Time Slot entity
|
||||||
id: string;
|
export interface TimeSlot extends BaseRecord {
|
||||||
booking_type: string; // relation to booking type
|
booking_types: string[]; // relation to BookingType (many-to-many)
|
||||||
customer_name: string;
|
start_time: string;
|
||||||
customer_email: string;
|
end_time: string;
|
||||||
internal_notes?: string;
|
is_active: boolean;
|
||||||
start_time: string;
|
is_reccuring?: boolean;
|
||||||
end_time: string;
|
recurrence_pattern?: RecurrencePattern;
|
||||||
participants_count: number;
|
|
||||||
status: 'confirmed' | 'cancelled' | 'completed';
|
|
||||||
payment_status: 'not_required' | 'paid' | 'refunded' | 'partially_refunded' | 'pending';
|
|
||||||
payment_required: boolean;
|
|
||||||
cancellation_token: string;
|
|
||||||
created: string;
|
|
||||||
updated: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recurrence pattern structure
|
||||||
|
export interface RecurrencePattern {
|
||||||
|
// type: 'daily' | 'weekly' | 'monthly';
|
||||||
|
// interval: number;
|
||||||
|
days?: number[]; // 0-6, Sunday = 0
|
||||||
|
end_date?: string;
|
||||||
|
// occurrences?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Booking entity
|
||||||
|
export interface Booking extends BaseRecord {
|
||||||
|
booking_type: string; // relation to BookingType
|
||||||
|
customer_name: string;
|
||||||
|
customer_email: string;
|
||||||
|
internal_notes?: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
participants_count: number;
|
||||||
|
status: 'confirmed' | 'cancelled' | 'completed';
|
||||||
|
payment_status: 'not_required' | 'paid' | 'refunded' | 'partially_refunded' | 'pending';
|
||||||
|
payment_required: boolean;
|
||||||
|
cancellation_token: string;
|
||||||
|
use_subscription?: string; // relation to subscription (future feature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontend form types
|
||||||
export interface BookingFormData {
|
export interface BookingFormData {
|
||||||
bookingTypeId: string;
|
bookingTypeId: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerEmail: string;
|
customerEmail: string;
|
||||||
participantsCount: number;
|
startTime: string;
|
||||||
startTime: Date;
|
endTime: string;
|
||||||
endTime: Date;
|
participantsCount: number;
|
||||||
|
internalNotes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For the booking flow state
|
// API response types
|
||||||
export interface BookingFlowState {
|
export interface BookingResponse {
|
||||||
step: 'booking-type' | 'date-time' | 'customer-details' | 'confirmation';
|
success: boolean;
|
||||||
selectedBookingType?: BookingType;
|
booking?: Booking;
|
||||||
selectedDate?: Date;
|
error?: string;
|
||||||
selectedTimeSlot?: string; // time slot ID or time string
|
cancellationUrl?: string;
|
||||||
customerDetails?: {
|
}
|
||||||
name: string;
|
|
||||||
email: string;
|
export interface AvailabilityResponse {
|
||||||
participantsCount: number;
|
success: boolean;
|
||||||
notes?: string;
|
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
@@ -1,3 +1,5 @@
|
|||||||
|
# pnpm-lock.yaml
|
||||||
|
|
||||||
lockfileVersion: '9.0'
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
@@ -71,6 +73,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: ^15.5.0
|
specifier: ^15.5.0
|
||||||
version: 15.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.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:
|
pocketbase:
|
||||||
specifier: ^0.26.2
|
specifier: ^0.26.2
|
||||||
version: 0.26.2
|
version: 0.26.2
|
||||||
@@ -83,6 +88,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.1.0(react@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:
|
tailwindcss:
|
||||||
specifier: ^4.1.12
|
specifier: ^4.1.12
|
||||||
version: 4.1.12
|
version: 4.1.12
|
||||||
@@ -227,6 +235,24 @@ packages:
|
|||||||
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
|
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
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':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -455,6 +481,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
'@schummar/icu-type-parser@1.21.5':
|
||||||
|
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||||
|
|
||||||
@@ -765,6 +794,9 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decimal.js@10.6.0:
|
||||||
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
@@ -1044,6 +1076,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
intl-messageformat@10.7.16:
|
||||||
|
resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==}
|
||||||
|
|
||||||
is-array-buffer@3.0.5:
|
is-array-buffer@3.0.5:
|
||||||
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1312,6 +1347,20 @@ packages:
|
|||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
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:
|
next@15.5.0:
|
||||||
resolution: {integrity: sha512-N1lp9Hatw3a9XLt0307lGB4uTKsXDhyOKQo7uYMzX4i0nF/c27grcGXkLdb7VcT8QPYLBa8ouIyEoUQJ2OyeNQ==}
|
resolution: {integrity: sha512-N1lp9Hatw3a9XLt0307lGB4uTKsXDhyOKQo7uYMzX4i0nF/c27grcGXkLdb7VcT8QPYLBa8ouIyEoUQJ2OyeNQ==}
|
||||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||||
@@ -1437,6 +1486,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.1.0
|
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:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@@ -1682,6 +1737,11 @@ packages:
|
|||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
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:
|
which-boxed-primitive@1.1.1:
|
||||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1767,6 +1827,36 @@ snapshots:
|
|||||||
'@eslint/core': 0.15.2
|
'@eslint/core': 0.15.2
|
||||||
levn: 0.4.1
|
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/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.6':
|
'@humanfs/node@0.16.6':
|
||||||
@@ -1931,6 +2021,8 @@ snapshots:
|
|||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
fastq: 1.19.1
|
fastq: 1.19.1
|
||||||
|
|
||||||
|
'@schummar/icu-type-parser@1.21.5': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -2293,6 +2385,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
define-data-property@1.1.4:
|
define-data-property@1.1.4:
|
||||||
@@ -2685,6 +2779,13 @@ snapshots:
|
|||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
side-channel: 1.1.0
|
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:
|
is-array-buffer@3.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.8
|
call-bind: 1.0.8
|
||||||
@@ -2932,6 +3033,18 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
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):
|
next@15.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 15.5.0
|
'@next/env': 15.5.0
|
||||||
@@ -3063,6 +3176,10 @@ snapshots:
|
|||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
scheduler: 0.26.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-is@16.13.1: {}
|
||||||
|
|
||||||
react@19.1.0: {}
|
react@19.1.0: {}
|
||||||
@@ -3394,6 +3511,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
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:
|
which-boxed-primitive@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-bigint: 1.1.0
|
is-bigint: 1.1.0
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# pnpm-workspace.yaml
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
- "apps/*"
|
- "apps/*"
|
||||||
- "packages/*"
|
- "packages/*"
|
||||||
|
|||||||