diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..cd7d9c8fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +.output +.vinxi +.tanstack/** +.nitro +.turbo +*.local + +# Wrangler / Cloudflare +.wrangler/ +.dev.vars + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + + +#env +.env +.env.docker diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..be49b2655 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +node_modules +dist +.output +.vinxi +pnpm-lock.yaml +package-lock.json +bun.lock +routeTree.gen.ts diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..90abee239 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "semi": true, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/README.md b/README.md index 68d8b36b8..64a2c6e31 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,61 @@ -# BookMyVenue (by WeCode) +# BookMyVenue -BookMyVenue is a 100% open-source initiative built by the **WeCode community**, designed to simplify the process of finding and booking venues in local areas. This is a platform built for the community, by the community. Whether you're looking for a cozy cafe for a quick meetup, a spacious auditorium for a community event, or a scenic outdoor space for a personal celebration, BookMyVenue aims to connect people with the perfect spaceβ€”without the commercial overhead. +A modern venue booking application built with TanStack Start, Cloudflare Workers, D1 Database, R2 Storage, and KV Cache. -## 🚧 The Problem +## Local Development Setup -Organizing an eventβ€”big or smallβ€”often comes with the hassle of finding a suitable location. Traditionally, people struggle with: +To run this application locally, you only need to use Cloudflare Wrangler. -- **Fragmented Information:** Venue details, availability, and pricing are scattered across various websites or require time-consuming phone calls. -- **Lack of Transparency:** Hidden costs, unclear amenities, and outdated photos make it hard to trust what you are booking. -- **Time Inefficiency:** Manually comparing options, negotiating, and finalizing bookings is a tedious and frustrating process for organizers. -- **Underutilized Spaces:** Owners of small or unique spaces (like local cafes, art studios, or boutique halls) often lack the marketing reach to showcase their venues to a wider audience. +### Prerequisites -## πŸ’‘ The Solution +- **Node.js**: v18.0.0 or higher +- **npm**: v10.0.0 or higher -BookMyVenue acts as a completely free and open bridge between space owners and the community, offering a seamless booking experience for everyone: +### Step-by-Step Setup -- **Centralized Marketplace:** A single platform to discover a wide variety of venues, ranging from conventional banquet halls and auditoriums to unconventional spaces like cafes, studios, and mall pop-up spaces. -- **Real-Time Availability & Pricing:** Transparent access to schedules and upfront pricing so you can make informed decisions quickly without the back-and-forth. -- **Detailed Listings:** High-quality photos, comprehensive amenity lists (e.g., Wi-Fi, parking, AV equipment), and verified user reviews. -- **Simplified Booking Process:** Easy, hassle-free online reservations in just a few clicks. -- **Empowering Local Communities:** Providing small venue owners, community centers, and local spaces an open platform to manage their underutilized spaces and share them with the public. +1. **Install Dependencies** -## πŸ—ΊοΈ Project Roadmap + ```bash + npm install + ``` -We are building BookMyVenue iteratively through **4 Phases**: -- **Phase 1: MVP (Current):** Anyone can contribute in *any stack*. If it's good, we merge it! -- **Phase 2 & 3: Feature & Modularization:** Implementing features module by module and refining the architecture. -- **Phase 4: Scalability:** Building a fully scalable, cloud-native solution for high traffic. +2. **Configure Environment Variables** + Copy the example environment file: -## 🀝 How to Contribute + ```bash + cp .env.example .env + ``` -This is a collective effort, and everyone in the WeCode community shares the benefits of what we build! Whether you're a beginner or a pro, we would love your help. -Please check out our full [Contribution Guidelines](CONTRIBUTING.md) to learn how to: -- Fork and clone the repository. -- Create your feature branch (`feat/`). -- Submit a Pull Request. + Generate a random string for `BETTER_AUTH_SECRET` (e.g., using `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"`) and update it in your `.env` file. -### πŸ“ Pull Request Format -To maintain a high standard of code, all Pull Requests must use our [standard template](.github/PULL_REQUEST_TEMPLATE.md). When you open a PR, you will be prompted to: -1. **Select the Phase Category** (e.g., Phase 1 MVP). -2. **Document your Tech Stack** (Frontend, Backend, Database). -3. **Complete the Review Checklist** (Self-review, community standards). -4. **Sign the AI Disclosure** (Confirming you have reviewed any AI-generated code). -5. **Attach Screenshots** (If your PR includes UI changes). +3. **Set Up the Local Database** + Apply migrations to your local D1 database: -**BookMyVenue belongs to all of us. Join WeCode today and let's build something amazing together!** + ```bash + npx wrangler d1 migrations apply book-my-venue-db --local + ``` + + Seed the local database with initial venue data: + + ```bash + npm run db:seed + ``` + +4. **Start the Development Server** + Start the local development server using Wrangler: + ```bash + npx wrangler dev + ``` + The app will be running at [http://127.0.0.1:8787](http://127.0.0.1:8787). + +--- + +## Project Structure + +- **`apps/`**: Sub-applications (e.g. mobile, MCP integrations). +- **`packages/`**: Monorepo packages for domain models, core contracts, and UI tokens. +- **`src/`**: The main TanStack Start web application. + - `routes/`: Frontend routing structure. + - `components/`: UI components. + - `infrastructure/`: Providers and dependency injection. + - `lib/`: Helper libraries, middleware, and core utilities. diff --git a/apps/mcp/README.md b/apps/mcp/README.md new file mode 100644 index 000000000..bd598e28e --- /dev/null +++ b/apps/mcp/README.md @@ -0,0 +1,46 @@ +# Venues MCP Server + +A small Python [MCP](https://modelcontextprotocol.io) server that exposes the +app's venues as a `get_venues` tool, plus an HTML widget that ChatGPT renders +next to the tool result (OpenAI Apps SDK convention). + +## What's inside + +- `server.py` β€” FastMCP server with one tool (`get_venues`) and one UI resource. +- `widget.html` β€” the card-grid widget rendered inside ChatGPT. +- `requirements.txt` β€” Python deps. + +The tool calls the app's public REST endpoint `GET /api/public/venues` (see +`src/routes/api/public/venues.ts`), so no Supabase credentials are needed in +the Python process. + +## Run it + +```bash +cd apps/mcp +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt + +# Point at your deployed Lovable app (or http://localhost:3000 for local dev) +export VENUES_API_BASE="https://project--bf1a39b6-3c54-479a-8f7d-173e73cb3c8f.lovable.app" + +python server.py +# -> Streamable HTTP MCP endpoint at http://localhost:8000/mcp +``` + +## Connect to ChatGPT + +1. In ChatGPT: **Settings β†’ Connectors β†’ Add custom connector** (Developer Mode + must be enabled). +2. URL: `http://localhost:8000/mcp` (or your tunnelled https URL, e.g. ngrok). +3. Start a new chat, enable the connector, and ask "show me available venues". + ChatGPT will call `get_venues` and render the widget above the JSON. + +## Tool + +| Name | Args | Returns | +| ------------ | ------------------------------------------------------------------------------------------------ | ---------------------------------- | +| `get_venues` | `search?`, `venue_type?` (`wedding`/`conference`/`party`/`celebration`/`other`), `min_capacity?` | `{ venues: [...], count: number }` | + +The widget reads `window.openai.toolOutput.venues` and renders a responsive +card grid (cover image, name, type, location, capacity, price). diff --git a/apps/mcp/__pycache__/server.cpython-313.pyc b/apps/mcp/__pycache__/server.cpython-313.pyc new file mode 100644 index 000000000..2d5b2f320 Binary files /dev/null and b/apps/mcp/__pycache__/server.cpython-313.pyc differ diff --git a/apps/mcp/package.json b/apps/mcp/package.json new file mode 100644 index 000000000..2d6ce4478 --- /dev/null +++ b/apps/mcp/package.json @@ -0,0 +1,10 @@ +{ + "name": "@apps/mcp", + "version": "0.0.0", + "private": true, + "description": "Python MCP server exposing get_venues to ChatGPT. Managed by Turborepo only for scripts orchestration.", + "scripts": { + "dev": "python server.py", + "install:py": "pip install -r requirements.txt" + } +} diff --git a/apps/mcp/requirements.txt b/apps/mcp/requirements.txt new file mode 100644 index 000000000..e64c13c4c --- /dev/null +++ b/apps/mcp/requirements.txt @@ -0,0 +1,2 @@ +mcp[cli]>=1.2.0 +httpx>=0.27.0 diff --git a/apps/mcp/server.py b/apps/mcp/server.py new file mode 100644 index 000000000..f3d95f33d --- /dev/null +++ b/apps/mcp/server.py @@ -0,0 +1,94 @@ +""" +Venues MCP server (Python). + +Exposes a single `get_venues` tool backed by the Lovable app's public +`/api/public/venues` endpoint, and ships an HTML UI widget that ChatGPT +(via the OpenAI Apps SDK) renders alongside the tool result. + +Run locally (streamable HTTP transport, the one ChatGPT expects): + + pip install -r requirements.txt + python server.py + +Then register `http://localhost:8000/mcp` as a connector in ChatGPT +(Settings -> Connectors -> Add custom MCP server). +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import httpx +from mcp.server.fastmcp import FastMCP +from mcp.server.transport_security import TransportSecuritySettings +from mcp.types import TextResourceContents + +API_BASE = os.environ.get( + "VENUES_API_BASE", + "", +).rstrip("/") + +WIDGET_URI = "ui://widget/venues-list.html" +WIDGET_HTML = (Path(__file__).parent / "widget.html").read_text(encoding="utf-8") + +mcp = FastMCP( + "venues-mcp", + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=False, + ), +) + + +# ---------- UI widget resource (OpenAI Apps SDK convention) ---------- +@mcp.resource( + WIDGET_URI, + name="Venues list widget", + mime_type="text/html+skybridge", +) +def venues_widget() -> str: + """HTML widget rendered by ChatGPT next to the tool output.""" + return WIDGET_HTML + + +# ---------- Tool ---------- +@mcp.tool( + name="get_venues", + description=( + "List bookable venues. Optionally filter by free-text search, " + "venue type (wedding | conference | party | celebration | other), " + "or minimum capacity." + ), + meta={ + "ui": { + "resourceUri": WIDGET_URI, + }, + "ui/resourceUri": WIDGET_URI, + }, +) +async def get_venues( + search: str | None = None, + venue_type: str | None = None, + min_capacity: int | None = None, +) -> dict[str, Any]: + params: dict[str, str] = {} + if search: + params["search"] = search + if venue_type: + params["venue_type"] = venue_type + if min_capacity is not None: + params["min_capacity"] = str(min_capacity) + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(f"{API_BASE}/api/public/venues", params=params) + resp.raise_for_status() + payload = resp.json() + + venues = payload.get("venues", []) if isinstance(payload, dict) else [] + return {"venues": venues, "count": len(venues)} + + +if __name__ == "__main__": + # Streamable HTTP transport, served at /mcp on port 8000. + mcp.run(transport="streamable-http") diff --git a/apps/mcp/widget.html b/apps/mcp/widget.html new file mode 100644 index 000000000..36a942edb --- /dev/null +++ b/apps/mcp/widget.html @@ -0,0 +1,114 @@ + +
+ + diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example new file mode 100644 index 000000000..ea48bb98a --- /dev/null +++ b/apps/mobile/.env.example @@ -0,0 +1,2 @@ +EXPO_PUBLIC_SUPABASE_URL= +EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY= diff --git a/apps/mobile/.expo/README.md b/apps/mobile/.expo/README.md new file mode 100644 index 000000000..228864a75 --- /dev/null +++ b/apps/mobile/.expo/README.md @@ -0,0 +1,9 @@ +> Why do I have a folder named ".expo" in my project? +> The ".expo" folder is created when an Expo project is started using "expo start" command. +> What do the files contain? + +- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds. +- "settings.json": contains the server configuration that is used to serve the application manifest. + > Should I commit the ".expo" folder? + > No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine. + > Upon project creation, the ".expo" folder is already added to your ".gitignore" file. diff --git a/apps/mobile/.expo/devices.json b/apps/mobile/.expo/devices.json new file mode 100644 index 000000000..5efff6c8c --- /dev/null +++ b/apps/mobile/.expo/devices.json @@ -0,0 +1,3 @@ +{ + "devices": [] +} diff --git a/apps/mobile/.expo/types/router.d.ts b/apps/mobile/.expo/types/router.d.ts new file mode 100644 index 000000000..b3533fecb --- /dev/null +++ b/apps/mobile/.expo/types/router.d.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +import * as Router from "expo-router"; + +export * from "expo-router"; + +declare module "expo-router" { + export namespace ExpoRouter { + export interface __routes extends Record { + StaticRoutes: + | `/` + | `/(auth)` + | `/(auth)/account/bookings` + | `/_sitemap` + | `/account/bookings` + | `/login` + | `/signup` + | `/venues`; + DynamicRoutes: `/venues/${Router.SingleRoutePart}`; + DynamicRouteTemplate: `/venues/[id]`; + } + } +} diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 000000000..5873d9abc --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,6 @@ + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 000000000..2ed54ac2e --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,68 @@ +# Book My Venue β€” Mobile (Expo) + +React Native app that reuses the monorepo's business logic +(`@repo/domain`, `@repo/application`, `@repo/contracts`, +`@repo/infrastructure`) and design tokens (`@repo/ui-tokens`). + +## Setup + +1. Install workspace dependencies from the repo root: + + ```bash + npm install --legacy-peer-deps + ``` + +2. Add the public Supabase env vars (these mirror the web app's + `VITE_SUPABASE_*` values): + + ```bash + # apps/mobile/.env + EXPO_PUBLIC_SUPABASE_URL=... + EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=... + ``` + +3. Start the dev server: + + ```bash + cd apps/mobile + npm start # then press i for iOS, a for Android, or scan with Expo Go + ``` + +## What's shared with the web app + +| Package | Purpose | +| ---------------------- | -------------------------------------------------- | +| `@repo/domain` | Entities, value objects, pricing rules | +| `@repo/application` | Use-cases (quoteBooking, listVenues, …) | +| `@repo/contracts` | Ports: `AuthProvider`, `StorageProvider`, repos, … | +| `@repo/infrastructure` | Supabase adapters (auth, storage, db, repos) | +| `@repo/ui-tokens` | Color, radius, spacing, font tokens (web + mobile) | + +## What's mobile-only + +| Path | Why | +| ---------------------------------------- | ----------------------------------- | +| `src/integrations/supabase.ts` | RN client (AsyncStorage session) | +| `src/infrastructure/providers.native.ts` | Native provider singletons | +| `app/**` | Expo Router screens | +| `components/ui.tsx` | RN equivalents of shadcn primitives | + +## Swapping providers + +Same story as the web app β€” change the factories in +`src/infrastructure/providers.native.ts` and nothing else changes. +See `packages/infrastructure/README.md` for adapter options +(Cognito, R2, S3, D1, …). + +## Screen ↔ web route mapping + +| Web route | Mobile screen | +| ---------------------------------- | --------------------------------------------- | +| `/` | `app/index.tsx` | +| `/login`, `/signup` | `app/login.tsx`, `app/signup.tsx` | +| `/venues`, `/venues/:venueId` | `app/venues/index.tsx`, `app/venues/[id].tsx` | +| `/_authenticated/account/bookings` | `app/(auth)/account/bookings.tsx` | + +Host flows (`/_authenticated/host/*`) can follow the same pattern when +needed β€” copy from the web routes and replace HTML/shadcn with the RN +primitives in `components/ui.tsx`. diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 000000000..f69e762a5 --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,15 @@ +{ + "expo": { + "name": "Book My Venue", + "slug": "book-my-venue", + "scheme": "bookmyvenue", + "version": "0.1.0", + "orientation": "portrait", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "ios": { "supportsTablet": true, "bundleIdentifier": "com.bookmyvenue.app" }, + "android": { "package": "com.bookmyvenue.app" }, + "plugins": ["expo-router"], + "experiments": { "typedRoutes": true } + } +} diff --git a/apps/mobile/app/(auth)/_layout.tsx b/apps/mobile/app/(auth)/_layout.tsx new file mode 100644 index 000000000..08f5a0b6e --- /dev/null +++ b/apps/mobile/app/(auth)/_layout.tsx @@ -0,0 +1,14 @@ +import { Stack, useRouter } from "expo-router"; +import { useEffect } from "react"; +import { useAuth } from "@/src/hooks/use-auth"; + +export default function AuthLayout() { + const { session, loading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!loading && !session) router.replace("/login"); + }, [session, loading, router]); + + return ; +} diff --git a/apps/mobile/app/(auth)/account/bookings.tsx b/apps/mobile/app/(auth)/account/bookings.tsx new file mode 100644 index 000000000..e0a650a5e --- /dev/null +++ b/apps/mobile/app/(auth)/account/bookings.tsx @@ -0,0 +1,42 @@ +import { ActivityIndicator, FlatList, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { Card, H1, H2, P } from "@/components/ui"; +import { useMyBookings } from "@/src/hooks/use-data"; +import { useAuth } from "@/src/hooks/use-auth"; + +export default function AccountBookings() { + const { user } = useAuth(); + const { data, isLoading } = useMyBookings(user?.id ?? null); + + return ( + + +

My bookings

+

Upcoming and past reservations.

+
+ {isLoading ? ( + + ) : ( + b.id} + contentContainerClassName="p-5 gap-3" + renderItem={({ item }) => ( + +

{(item as { venue_name?: string }).venue_name ?? "Venue"}

+

+ {new Date(item.start_time).toLocaleString()} β†’{" "} + {new Date(item.end_time).toLocaleString()} +

+

Status: {item.status}

+

+ Total: {item.currency} {(item.total_cents / 100).toLocaleString()} +

+
+ )} + ListEmptyComponent={

No bookings yet.

} + /> + )} +
+ ); +} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx new file mode 100644 index 000000000..81d8be925 --- /dev/null +++ b/apps/mobile/app/_layout.tsx @@ -0,0 +1,50 @@ +import { Stack, useRouter, useSegments } from "expo-router"; +import { useEffect } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { StatusBar } from "expo-status-bar"; +import { useAuth } from "@/src/hooks/use-auth"; +import "../global.css"; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 30_000, retry: 1 } }, +}); + +function AuthGate({ children }: { children: React.ReactNode }) { + const { session, loading } = useAuth(); + const segments = useSegments(); + const router = useRouter(); + + useEffect(() => { + if (loading) return; + const inAuthGroup = segments[0] === "(auth)"; + if (!session && inAuthGroup) router.replace("/login"); + }, [session, segments, loading, router]); + + return <>{children}; +} + +export default function RootLayout() { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx new file mode 100644 index 000000000..0dbdbee02 --- /dev/null +++ b/apps/mobile/app/index.tsx @@ -0,0 +1,31 @@ +import { ScrollView, View, Image } from "react-native"; +import { Link } from "expo-router"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { Button, H1, P } from "@/components/ui"; + +export default function Home() { + return ( + + + +

Find a venue worth remembering.

+

+ Hand-picked spaces for weddings, conferences, and celebrations. +

+
+ + + + + + + + + + + + +
+
+ ); +} diff --git a/apps/mobile/app/login.tsx b/apps/mobile/app/login.tsx new file mode 100644 index 000000000..3807c0451 --- /dev/null +++ b/apps/mobile/app/login.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { Alert, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useRouter } from "expo-router"; +import { z } from "zod"; +import { authProvider } from "@/src/infrastructure/providers.native"; +import { Button, H1, Input, Label, P } from "@/components/ui"; + +const schema = z.object({ email: z.string().email(), password: z.string().min(6) }); + +export default function LoginScreen() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + async function submit() { + const parsed = schema.safeParse({ email, password }); + if (!parsed.success) return Alert.alert("Invalid input", parsed.error.issues[0]?.message ?? ""); + setLoading(true); + try { + await authProvider.signInWithPassword(parsed.data); + router.replace("/"); + } catch (err) { + Alert.alert("Sign-in failed", err instanceof Error ? err.message : "Unknown error"); + } finally { + setLoading(false); + } + } + + return ( + + +

Welcome back

+

Sign in to manage your bookings and venues.

+ + + + + + + + + + + + +
+
+ ); +} diff --git a/apps/mobile/app/signup.tsx b/apps/mobile/app/signup.tsx new file mode 100644 index 000000000..849653dce --- /dev/null +++ b/apps/mobile/app/signup.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { Alert, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useRouter } from "expo-router"; +import { z } from "zod"; +import { authProvider } from "@/src/infrastructure/providers.native"; +import { Button, H1, Input, Label, P } from "@/components/ui"; + +const schema = z.object({ + first_name: z.string().min(1), + last_name: z.string().min(1), + email: z.string().email(), + password: z.string().min(8), +}); + +export default function SignupScreen() { + const router = useRouter(); + const [form, setForm] = useState({ first_name: "", last_name: "", email: "", password: "" }); + const [loading, setLoading] = useState(false); + + async function submit() { + const parsed = schema.safeParse(form); + if (!parsed.success) return Alert.alert("Invalid input", parsed.error.issues[0]?.message ?? ""); + setLoading(true); + try { + await authProvider.signUp({ + email: parsed.data.email, + password: parsed.data.password, + metadata: { + first_name: parsed.data.first_name, + last_name: parsed.data.last_name, + role: "customer", + }, + }); + Alert.alert("Check your email", "Confirm your account, then sign in."); + router.replace("/login"); + } catch (err) { + Alert.alert("Signup failed", err instanceof Error ? err.message : "Unknown error"); + } finally { + setLoading(false); + } + } + + return ( + + +

Create your account

+

Book venues, host spaces, or both.

+ + + + + setForm({ ...form, first_name: v })} + /> + + + + setForm({ ...form, last_name: v })} + /> + + + + + setForm({ ...form, email: v })} + autoCapitalize="none" + keyboardType="email-address" + /> + + + + setForm({ ...form, password: v })} + secureTextEntry + /> + + + +
+
+ ); +} diff --git a/apps/mobile/app/venues/[id].tsx b/apps/mobile/app/venues/[id].tsx new file mode 100644 index 000000000..4f31cda08 --- /dev/null +++ b/apps/mobile/app/venues/[id].tsx @@ -0,0 +1,56 @@ +import { ActivityIndicator, Image, ScrollView, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useLocalSearchParams } from "expo-router"; +import { Button, Card, H1, H2, P } from "@/components/ui"; +import { useVenue } from "@/src/hooks/use-data"; + +export default function VenueDetail() { + const { id } = useLocalSearchParams<{ id: string }>(); + const { data: venue, isLoading } = useVenue(id); + + if (isLoading || !venue) { + return ( + + + + ); + } + + return ( + + + {venue.cover_image_url && ( + + )} + + +

{venue.venue_type}

+

{venue.name}

+

Up to {venue.capacity} guests

+
+ + + +

+ {venue.currency} {(venue.base_price_cents / 100).toLocaleString()} +

+

per hour

+
+ +
+ + {venue.description ? ( + +

About this space

+

{venue.description}

+
+ ) : null} +
+
+
+ ); +} diff --git a/apps/mobile/app/venues/index.tsx b/apps/mobile/app/venues/index.tsx new file mode 100644 index 000000000..5649d2def --- /dev/null +++ b/apps/mobile/app/venues/index.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { ActivityIndicator, FlatList, Image, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { Link } from "expo-router"; +import { Card, H2, Input, P } from "@/components/ui"; +import { useVenues } from "@/src/hooks/use-data"; + +export default function VenuesList() { + const [search, setSearch] = useState(""); + const { data, isLoading } = useVenues(search); + + return ( + + + + + {isLoading ? ( + + ) : ( + v.id} + contentContainerClassName="p-5 gap-4" + renderItem={({ item }) => ( + + + {item.cover_image_url && ( + + )} + +

{item.name}

+

{item.address_data?.city ?? ""}

+

+ {item.currency} {(item.base_price_cents / 100).toLocaleString()} / hr +

+
+
+ + )} + ListEmptyComponent={

No venues found.

} + /> + )} +
+ ); +} diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js new file mode 100644 index 000000000..845cd1fb3 --- /dev/null +++ b/apps/mobile/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: [["babel-preset-expo", { jsxImportSource: "nativewind" }], "nativewind/babel"], + plugins: ["react-native-reanimated/plugin"], + }; +}; diff --git a/apps/mobile/components/ui.tsx b/apps/mobile/components/ui.tsx new file mode 100644 index 000000000..69b50a96d --- /dev/null +++ b/apps/mobile/components/ui.tsx @@ -0,0 +1,74 @@ +// Shared RN primitives that mirror the shadcn components used on web. +// Keep the props API close so screen code looks familiar across platforms. +import { + Pressable, + Text, + View, + TextInput, + type PressableProps, + type TextInputProps, + type ViewProps, +} from "react-native"; +import { forwardRef } from "react"; + +type Variant = "primary" | "outline" | "ghost"; +export interface ButtonProps extends PressableProps { + variant?: Variant; + children: React.ReactNode; +} + +export function Button({ + variant = "primary", + children, + className, + ...rest +}: ButtonProps & { className?: string }) { + const base = "rounded-full px-5 py-3 items-center justify-center"; + const styles: Record = { + primary: "bg-brand", + outline: "border border-border bg-transparent", + ghost: "bg-transparent", + }; + const text: Record = { + primary: "text-brand-foreground font-semibold", + outline: "text-foreground font-medium", + ghost: "text-foreground font-medium", + }; + return ( + + {typeof children === "string" ? {children} : children} + + ); +} + +export const Input = forwardRef( + ({ className, ...rest }, ref) => ( + + ), +); +Input.displayName = "Input"; + +export function Card({ className, ...rest }: ViewProps & { className?: string }) { + return ; +} + +export function Label({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} + +export function H1({ children }: { children: React.ReactNode }) { + return {children}; +} +export function H2({ children }: { children: React.ReactNode }) { + return {children}; +} +export function P({ children, className }: { children: React.ReactNode; className?: string }) { + return {children}; +} diff --git a/apps/mobile/global.css b/apps/mobile/global.css new file mode 100644 index 000000000..b5c61c956 --- /dev/null +++ b/apps/mobile/global.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js new file mode 100644 index 000000000..8b4f9ee38 --- /dev/null +++ b/apps/mobile/metro.config.js @@ -0,0 +1,18 @@ +// Metro config β€” enables monorepo (watch sibling packages) + NativeWind. +const { getDefaultConfig } = require("expo/metro-config"); +const { withNativeWind } = require("nativewind/metro"); +const path = require("path"); + +const projectRoot = __dirname; +const workspaceRoot = path.resolve(projectRoot, "../.."); + +const config = getDefaultConfig(projectRoot); +config.watchFolders = [workspaceRoot]; +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, "node_modules"), + path.resolve(workspaceRoot, "node_modules"), +]; +config.resolver.disableHierarchicalLookup = true; +config.resolver.unstable_enablePackageExports = true; + +module.exports = withNativeWind(config, { input: "./global.css" }); diff --git a/apps/mobile/nativewind-env.d.ts b/apps/mobile/nativewind-env.d.ts new file mode 100644 index 000000000..a13e3136b --- /dev/null +++ b/apps/mobile/nativewind-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 000000000..270f784c7 --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,51 @@ +{ + "name": "@app/mobile", + "version": "0.0.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "ios": "expo start --ios", + "android": "expo start --android", + "web": "expo start --web", + "typecheck": "tsc --noEmit", + "build": "expo export" + }, + "dependencies": { + "@expo/metro-runtime": "~3.2.1", + "@react-native-async-storage/async-storage": "1.23.1", + "@repo/application": "*", + "@repo/contracts": "*", + "@repo/domain": "*", + "@repo/infrastructure": "*", + "@repo/ui-tokens": "*", + "@tanstack/react-query": "^5.83.0", + "expo": "~51.0.0", + "expo-auth-session": "~5.5.2", + "expo-constants": "~16.0.2", + "expo-image-picker": "~15.0.7", + "expo-linking": "~6.3.1", + "expo-router": "~3.5.23", + "expo-status-bar": "~1.12.1", + "expo-web-browser": "~13.0.3", + "nativewind": "^4.0.36", + "react": "18.2.0", + "react-dom": "^18.2.0", + "react-native": "0.74.5", + "react-native-gesture-handler": "~2.16.1", + "react-native-reanimated": "~3.10.1", + "react-native-safe-area-context": "4.10.5", + "react-native-screens": "3.31.1", + "react-native-svg": "15.2.0", + "react-native-url-polyfill": "^2.0.0", + "react-native-web": "~0.19.10", + "react-native-worklets": "^0.9.2", + "zod": "^3.24.2" + }, + "devDependencies": { + "@babel/core": "^7.24.0", + "@types/react": "~18.2.79", + "tailwindcss": "3.4.10", + "typescript": "^5.8.3" + } +} diff --git a/apps/mobile/src/hooks/use-auth.ts b/apps/mobile/src/hooks/use-auth.ts new file mode 100644 index 000000000..831c54a31 --- /dev/null +++ b/apps/mobile/src/hooks/use-auth.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; +import type { AuthSession, AuthUser } from "@repo/contracts"; +import { authProvider } from "@/src/infrastructure/providers.native"; + +export function useAuth() { + const [session, setSession] = useState(null); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const unsub = authProvider.onAuthStateChange((s) => { + setSession(s); + setUser(s?.user ?? null); + }); + authProvider.getSession().then((s) => { + setSession(s); + setUser(s?.user ?? null); + setLoading(false); + }); + return () => unsub(); + }, []); + + return { session, user, loading }; +} diff --git a/apps/mobile/src/hooks/use-data.ts b/apps/mobile/src/hooks/use-data.ts new file mode 100644 index 000000000..735ce146a --- /dev/null +++ b/apps/mobile/src/hooks/use-data.ts @@ -0,0 +1,40 @@ +// Mobile data hooks β€” call the shared @repo/application use cases through +// repos built from the mobile Supabase client. Same business rules as web. +import { useQuery } from "@tanstack/react-query"; +import { makeHttpVenuesRepo, makeHttpBookingsRepo } from "@repo/infrastructure"; +import { listVenuesUseCase, getVenueUseCase, listMyBookingsUseCase } from "@repo/application"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import Constants from "expo-constants"; + +const extra = (Constants.expoConfig?.extra ?? {}) as Record; +const API_URL = extra.API_URL ?? process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3000"; + +const venuesRepo = makeHttpVenuesRepo({ apiUrl: API_URL }); +const bookingsRepo = makeHttpBookingsRepo({ + apiUrl: API_URL, + getToken: () => AsyncStorage.getItem("better-auth.session_token"), +}); + +const listVenues = listVenuesUseCase(venuesRepo); +const getVenue = getVenueUseCase(venuesRepo); +const listMyBookings = listMyBookingsUseCase(bookingsRepo); + +export const useVenues = (search?: string) => + useQuery({ + queryKey: ["venues", search ?? ""], + queryFn: () => listVenues({ search }), + }); + +export const useVenue = (id: string) => + useQuery({ + queryKey: ["venue", id], + queryFn: () => getVenue(id), + enabled: !!id, + }); + +export const useMyBookings = (userId: string | null) => + useQuery({ + queryKey: ["my-bookings", userId], + queryFn: () => listMyBookings(userId!), + enabled: !!userId, + }); diff --git a/apps/mobile/src/infrastructure/providers.native.ts b/apps/mobile/src/infrastructure/providers.native.ts new file mode 100644 index 000000000..70f0a0b23 --- /dev/null +++ b/apps/mobile/src/infrastructure/providers.native.ts @@ -0,0 +1,22 @@ +// Native provider singletons β€” the mobile equivalent of +// `src/infrastructure/providers.ts` on web. SAME ports, different adapters. +// To swap auth/storage providers (Cognito, R2, …) replace the factories +// here and nothing else changes. + +import { makeHttpAuthProvider, makeHttpStorageProvider } from "@repo/infrastructure"; +import type { AuthProvider, StorageProvider } from "@repo/contracts"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import Constants from "expo-constants"; + +const extra = (Constants.expoConfig?.extra ?? {}) as Record; +const API_URL = extra.API_URL ?? process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3000"; + +export const authProvider: AuthProvider = makeHttpAuthProvider({ + apiUrl: API_URL, + storage: AsyncStorage, +}); + +export const storageProvider: StorageProvider = makeHttpStorageProvider({ + apiUrl: API_URL, + getToken: () => AsyncStorage.getItem("better-auth.session_token"), +}); diff --git a/apps/mobile/tailwind.config.js b/apps/mobile/tailwind.config.js new file mode 100644 index 000000000..2a9183e5a --- /dev/null +++ b/apps/mobile/tailwind.config.js @@ -0,0 +1,35 @@ +const { palette, radius } = require("@repo/ui-tokens"); + +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"], + presets: [require("nativewind/preset")], + theme: { + extend: { + colors: { + surface: palette.surface, + background: palette.background, + lead: palette.lead, + foreground: palette.foreground, + brand: palette.brand, + "brand-foreground": palette.brandForeground, + card: palette.card, + muted: palette.muted, + "muted-foreground": palette.mutedForeground, + border: palette.border, + destructive: palette.destructive, + }, + borderRadius: { + sm: radius.sm, + md: radius.md, + lg: radius.lg, + xl: radius.xl, + "2xl": radius["2xl"], + }, + fontFamily: { + serif: ["Instrument Serif", "Georgia", "serif"], + sans: ["Instrument Sans", "System"], + }, + }, + }, +}; diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 000000000..97ad336db --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "jsx": "react-jsx", + "moduleResolution": "bundler", + "baseUrl": ".", + "typeRoots": ["./node_modules/@types", "../../node_modules/@types"], + "paths": { + "@/*": ["./*"], + "react": ["./node_modules/@types/react"], + "react/*": ["./node_modules/@types/react/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx", "nativewind-env.d.ts", ".expo/types/**/*.ts", "expo-env.d.ts"] +} diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 000000000..3ea2625e7 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,14 @@ +# Better Auth configuration +# Generate a real secret with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +BETTER_AUTH_SECRET="generate_a_random_32_character_string_here" +BETTER_AUTH_URL="http://127.0.0.1:8787" + +# OAuth Providers (Optional for local testing if not using Google login) +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" + +# Resend Email API (Optional) +RESEND_API_KEY="" + +# Telegram Bot API (Optional) +TELEGRAM_API_KEY="" diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 000000000..f0817a84e --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "css": "src/styles.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts new file mode 100644 index 000000000..3d8f7fcf9 --- /dev/null +++ b/apps/web/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "../../packages/infrastructure/src/drizzle/schema.ts", + out: "./drizzle/migrations", + dialect: "sqlite", + dbCredentials: { + url: ".wrangler/state/v3/d1/miniflare-D1DatabaseObject/85cf62fecdc7a7e6b6dd60686687edcabe6850290c4279cee81984ceb96526e6.sqlite", + }, +}); diff --git a/apps/web/drizzle/migrations/0000_graceful_patriot.sql b/apps/web/drizzle/migrations/0000_graceful_patriot.sql new file mode 100644 index 000000000..94730edef --- /dev/null +++ b/apps/web/drizzle/migrations/0000_graceful_patriot.sql @@ -0,0 +1,186 @@ +CREATE TABLE `accounts` ( + `id` text PRIMARY KEY NOT NULL, + `account_id` text NOT NULL, + `provider_id` text NOT NULL, + `user_id` text NOT NULL, + `access_token` text, + `refresh_token` text, + `id_token` text, + `access_token_expires_at` integer, + `refresh_token_expires_at` integer, + `scope` text, + `password` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `profiles`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `api_cache` ( + `key` text PRIMARY KEY NOT NULL, + `payload` text NOT NULL, + `expires_at` text NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `bookings` ( + `id` text PRIMARY KEY NOT NULL, + `venue_id` text NOT NULL, + `customer_id` text, + `coupon_id` text, + `start_time` text NOT NULL, + `end_time` text NOT NULL, + `guest_count` integer, + `subtotal_cents` integer NOT NULL, + `discount_amount_cents` integer DEFAULT 0 NOT NULL, + `total_cents` integer NOT NULL, + `currency` text DEFAULT 'USD' NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `expires_at` text, + `version` integer DEFAULT 1 NOT NULL, + `notes` text, + `stripe_session_id` text, + `source` text DEFAULT 'online' NOT NULL, + `guest_name` text, + `guest_email` text, + `guest_phone` text, + `payment_method` text, + `amount_paid_cents` integer DEFAULT 0 NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`venue_id`) REFERENCES `venues`(`id`) ON UPDATE no action ON DELETE restrict, + FOREIGN KEY (`customer_id`) REFERENCES `profiles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE TABLE `coupons` ( + `id` text PRIMARY KEY NOT NULL, + `code` text NOT NULL, + `venue_id` text, + `discount_type` text NOT NULL, + `discount_value` real NOT NULL, + `valid_from` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `valid_until` text, + `usage_limit` integer, + `times_used` integer DEFAULT 0 NOT NULL, + `is_active` integer DEFAULT true NOT NULL, + `created_by` text, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`venue_id`) REFERENCES `venues`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`created_by`) REFERENCES `profiles`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `coupons_code_unique` ON `coupons` (`code`);--> statement-breakpoint +CREATE TABLE `invoices` ( + `id` text PRIMARY KEY NOT NULL, + `booking_id` text NOT NULL, + `invoice_number` text NOT NULL, + `pdf_path` text NOT NULL, + `amount_cents` integer NOT NULL, + `currency` text NOT NULL, + `issued_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `sent_at` text, + `recipient_email` text, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `invoices_booking_id_unique` ON `invoices` (`booking_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `invoices_invoice_number_unique` ON `invoices` (`invoice_number`);--> statement-breakpoint +CREATE TABLE `payments` ( + `id` text PRIMARY KEY NOT NULL, + `booking_id` text NOT NULL, + `gateway` text DEFAULT 'stripe' NOT NULL, + `transaction_id` text, + `order_id` text, + `amount_cents` integer NOT NULL, + `currency` text DEFAULT 'USD' NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `gateway_response` text, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `payments_transaction_id_unique` ON `payments` (`transaction_id`);--> statement-breakpoint +CREATE TABLE `profiles` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `email` text NOT NULL, + `email_verified` integer DEFAULT false NOT NULL, + `image` text, + `created_at_auth` integer NOT NULL, + `updated_at_auth` integer NOT NULL, + `first_name` text, + `last_name` text, + `role` text, + `is_suspended` integer DEFAULT false NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `profiles_email_unique` ON `profiles` (`email`);--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` text PRIMARY KEY NOT NULL, + `expires_at` integer NOT NULL, + `token` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `ip_address` text, + `user_agent` text, + `user_id` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `profiles`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `sessions_token_unique` ON `sessions` (`token`);--> statement-breakpoint +CREATE TABLE `user_roles` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `role` text NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `profiles`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_role_unique` ON `user_roles` (`user_id`,`role`);--> statement-breakpoint +CREATE TABLE `venue_reviews` ( + `id` text PRIMARY KEY NOT NULL, + `venue_id` text NOT NULL, + `user_id` text NOT NULL, + `rating` integer NOT NULL, + `feedback` text, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`venue_id`) REFERENCES `venues`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `profiles`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `venue_review_unique` ON `venue_reviews` (`venue_id`,`user_id`);--> statement-breakpoint +CREATE TABLE `venues` ( + `id` text PRIMARY KEY NOT NULL, + `host_id` text NOT NULL, + `name` text NOT NULL, + `description` text, + `venue_type` text NOT NULL, + `capacity` integer NOT NULL, + `base_price_cents` integer NOT NULL, + `currency` text DEFAULT 'USD' NOT NULL, + `address_data` text DEFAULT '{}' NOT NULL, + `amenities` text DEFAULT '[]' NOT NULL, + `cover_image_url` text, + `gallery_urls` text DEFAULT '[]' NOT NULL, + `pricing_mode` text DEFAULT 'per_hour' NOT NULL, + `is_active` integer DEFAULT true NOT NULL, + `is_suspended` integer DEFAULT false NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`host_id`) REFERENCES `profiles`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `verifications` ( + `id` text PRIMARY KEY NOT NULL, + `identifier` text NOT NULL, + `value` text NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer, + `updated_at` integer +); diff --git a/apps/web/drizzle/migrations/0001_booking_conflict_index.sql b/apps/web/drizzle/migrations/0001_booking_conflict_index.sql new file mode 100644 index 000000000..830402dca --- /dev/null +++ b/apps/web/drizzle/migrations/0001_booking_conflict_index.sql @@ -0,0 +1,4 @@ +-- Conflict-lookup index for the atomic conditional INSERT. +-- Lets the NOT-EXISTS subquery use an index scan instead of full-scanning bookings. +CREATE INDEX `bookings_conflict_lookup_idx` + ON `bookings` (`venue_id`, `status`, `start_time`, `end_time`, `expires_at`); diff --git a/apps/web/drizzle/migrations/meta/0000_snapshot.json b/apps/web/drizzle/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..1424985d1 --- /dev/null +++ b/apps/web/drizzle/migrations/meta/0000_snapshot.json @@ -0,0 +1,1258 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0c8a7a78-f12d-4f54-91ef-4f890282c0d8", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_profiles_id_fk": { + "name": "accounts_user_id_profiles_id_fk", + "tableFrom": "accounts", + "tableTo": "profiles", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_cache": { + "name": "api_cache", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookings": { + "name": "bookings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "venue_id": { + "name": "venue_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "coupon_id": { + "name": "coupon_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guest_count": { + "name": "guest_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subtotal_cents": { + "name": "subtotal_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discount_amount_cents": { + "name": "discount_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_session_id": { + "name": "stripe_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'online'" + }, + "guest_name": { + "name": "guest_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guest_email": { + "name": "guest_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guest_phone": { + "name": "guest_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_paid_cents": { + "name": "amount_paid_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "bookings_venue_id_venues_id_fk": { + "name": "bookings_venue_id_venues_id_fk", + "tableFrom": "bookings", + "tableTo": "venues", + "columnsFrom": ["venue_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "bookings_customer_id_profiles_id_fk": { + "name": "bookings_customer_id_profiles_id_fk", + "tableFrom": "bookings", + "tableTo": "profiles", + "columnsFrom": ["customer_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookings_coupon_id_coupons_id_fk": { + "name": "bookings_coupon_id_coupons_id_fk", + "tableFrom": "bookings", + "tableTo": "coupons", + "columnsFrom": ["coupon_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "coupons": { + "name": "coupons", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "venue_id": { + "name": "venue_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discount_value": { + "name": "discount_value", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_from": { + "name": "valid_from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "valid_until": { + "name": "valid_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_limit": { + "name": "usage_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "times_used": { + "name": "times_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "coupons_code_unique": { + "name": "coupons_code_unique", + "columns": ["code"], + "isUnique": true + } + }, + "foreignKeys": { + "coupons_venue_id_venues_id_fk": { + "name": "coupons_venue_id_venues_id_fk", + "tableFrom": "coupons", + "tableTo": "venues", + "columnsFrom": ["venue_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "coupons_created_by_profiles_id_fk": { + "name": "coupons_created_by_profiles_id_fk", + "tableFrom": "coupons", + "tableTo": "profiles", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "booking_id": { + "name": "booking_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pdf_path": { + "name": "pdf_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issued_at": { + "name": "issued_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "sent_at": { + "name": "sent_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "invoices_booking_id_unique": { + "name": "invoices_booking_id_unique", + "columns": ["booking_id"], + "isUnique": true + }, + "invoices_invoice_number_unique": { + "name": "invoices_invoice_number_unique", + "columns": ["invoice_number"], + "isUnique": true + } + }, + "foreignKeys": { + "invoices_booking_id_bookings_id_fk": { + "name": "invoices_booking_id_bookings_id_fk", + "tableFrom": "invoices", + "tableTo": "bookings", + "columnsFrom": ["booking_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "payments": { + "name": "payments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "booking_id": { + "name": "booking_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gateway": { + "name": "gateway", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stripe'" + }, + "transaction_id": { + "name": "transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "gateway_response": { + "name": "gateway_response", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "payments_transaction_id_unique": { + "name": "payments_transaction_id_unique", + "columns": ["transaction_id"], + "isUnique": true + } + }, + "foreignKeys": { + "payments_booking_id_bookings_id_fk": { + "name": "payments_booking_id_bookings_id_fk", + "tableFrom": "payments", + "tableTo": "bookings", + "columnsFrom": ["booking_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "profiles": { + "name": "profiles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_auth": { + "name": "created_at_auth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at_auth": { + "name": "updated_at_auth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "profiles_email_unique": { + "name": "profiles_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": { + "sessions_user_id_profiles_id_fk": { + "name": "sessions_user_id_profiles_id_fk", + "tableFrom": "sessions", + "tableTo": "profiles", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "user_role_unique": { + "name": "user_role_unique", + "columns": ["user_id", "role"], + "isUnique": true + } + }, + "foreignKeys": { + "user_roles_user_id_profiles_id_fk": { + "name": "user_roles_user_id_profiles_id_fk", + "tableFrom": "user_roles", + "tableTo": "profiles", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "venue_reviews": { + "name": "venue_reviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "venue_id": { + "name": "venue_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "venue_review_unique": { + "name": "venue_review_unique", + "columns": ["venue_id", "user_id"], + "isUnique": true + } + }, + "foreignKeys": { + "venue_reviews_venue_id_venues_id_fk": { + "name": "venue_reviews_venue_id_venues_id_fk", + "tableFrom": "venue_reviews", + "tableTo": "venues", + "columnsFrom": ["venue_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "venue_reviews_user_id_profiles_id_fk": { + "name": "venue_reviews_user_id_profiles_id_fk", + "tableFrom": "venue_reviews", + "tableTo": "profiles", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "venues": { + "name": "venues", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "venue_type": { + "name": "venue_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_price_cents": { + "name": "base_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "address_data": { + "name": "address_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "amenities": { + "name": "amenities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "cover_image_url": { + "name": "cover_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gallery_urls": { + "name": "gallery_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "pricing_mode": { + "name": "pricing_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'per_hour'" + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_suspended": { + "name": "is_suspended", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "venues_host_id_profiles_id_fk": { + "name": "venues_host_id_profiles_id_fk", + "tableFrom": "venues", + "tableTo": "profiles", + "columnsFrom": ["host_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verifications": { + "name": "verifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/web/drizzle/migrations/meta/_journal.json b/apps/web/drizzle/migrations/meta/_journal.json new file mode 100644 index 000000000..a08b60e94 --- /dev/null +++ b/apps/web/drizzle/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1781335345000, + "tag": "0000_graceful_patriot", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1781923958000, + "tag": "0001_booking_conflict_index", + "breakpoints": true + } + ] +} diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 000000000..be4075bd5 --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,40 @@ +import js from "@eslint/js"; +import eslintPluginPrettier from "eslint-plugin-prettier/recommended"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist", ".output", ".vinxi"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "server-only", + message: + "TanStack Start does not use the Next.js `server-only` package. Rename the module to `*.server.ts` or mark it with `@tanstack/react-start/server-only`.", + }, + ], + }, + ], + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "@typescript-eslint/no-unused-vars": "off", + }, + }, + eslintPluginPrettier, +); diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 000000000..460c23a81 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,102 @@ +{ + "name": "@app/web", + "version": "0.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "build:dev": "vite build --mode development", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "cf-typegen": "wrangler types", + "deploy": "wrangler deploy" + }, + "dependencies": { + "@cloudflare/vite-plugin": "^1.25.5", + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-aspect-ratio": "^1.1.8", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@repo/application": "*", + "@repo/contracts": "*", + "@repo/domain": "*", + "@repo/infrastructure": "*", + "@repo/ui-tokens": "*", + "@tailwindcss/vite": "^4.2.1", + "@tanstack/query-core": "^5.100.14", + "@tanstack/react-query": "^5.83.0", + "@tanstack/react-router": "^1.168.25", + "@tanstack/react-start": "^1.167.50", + "@tanstack/router-plugin": "^1.167.28", + "better-auth": "^1.2.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "drizzle-orm": "^0.44.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.575.0", + "nitro": "3.0.260603-beta", + "pdf-lib": "^1.17.1", + "react": "^19.2.0", + "react-day-picker": "^9.14.0", + "react-dom": "^19.2.0", + "react-hook-form": "^7.71.2", + "react-resizable-panels": "^4.6.5", + "recharts": "^2.15.4", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.1", + "tw-animate-css": "^1.3.4", + "vaul": "^1.1.2", + "vite-tsconfig-paths": "^6.0.2", + "zod": "^3.24.2" + }, + "devDependencies": { + "ajv": "^8.20.0", + "@eslint/js": "^9.32.0", + "@types/node": "^22.16.5", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.4", + "drizzle-kit": "^0.31.0", + "eslint": "^9.32.0", + "eslint-config-prettier": "^10.1.1", + "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^15.15.0", + "prettier": "^3.7.3", + "typescript": "^5.8.3", + "typescript-eslint": "^8.56.1", + "vite": "^7.3.1", + "wrangler": "^4.0.0" + } +} diff --git a/apps/web/seed_kerala_venues.sql b/apps/web/seed_kerala_venues.sql new file mode 100644 index 000000000..3f59c7d43 --- /dev/null +++ b/apps/web/seed_kerala_venues.sql @@ -0,0 +1,277 @@ +-- Seed script for Book My Venue (Kerala Venues) + +-- 1. Ensure the presence of the Host Profile +INSERT INTO profiles ( + id, + name, + email, + email_verified, + created_at_auth, + updated_at_auth, + role +) VALUES ( + 'rY2XeurYfx151wxgozkerT0DibGUFTUc', + 'MUHAMMED A', + 'muhammed@example.com', + 1, + 1718360000000, + 1718360000000, + 'host' +) ON CONFLICT(id) DO UPDATE SET role = 'host'; + +-- 2. Add the host role mapping +INSERT INTO user_roles (id, user_id, role) +VALUES ('seed-user-role-host-1', 'rY2XeurYfx151wxgozkerT0DibGUFTUc', 'host') +ON CONFLICT(user_id, role) DO NOTHING; + +-- 3. Ensure the presence of a Customer Profile +INSERT INTO profiles ( + id, + name, + email, + email_verified, + created_at_auth, + updated_at_auth, + role +) VALUES ( + 'customer-seed-1', + 'Adarsh Nair', + 'adarsh@example.com', + 1, + 1718360000000, + 1718360000000, + 'customer' +) ON CONFLICT(id) DO UPDATE SET role = 'customer'; + +-- 4. Add the customer role mapping +INSERT INTO user_roles (id, user_id, role) +VALUES ('seed-user-role-customer-1', 'customer-seed-1', 'customer') +ON CONFLICT(user_id, role) DO NOTHING; + +-- 5. Delete existing seeded venues, bookings, and reviews to avoid duplication/FK constraint errors +DELETE FROM bookings WHERE venue_id IN ( + '8b50e7a2-1d54-47f6-953e-bfa5c73c2421', + 'f4a8dc22-83b6-4554-949e-1dc63f707f5a', + 'a9b23b1b-dcde-41c3-8818-b711e74f8c6e', + 'c5cf420d-773a-44e2-b062-1132de62c5eb', + '5e2b0de4-91bb-4f38-89c0-54e7d97b91fc', + '7f8f94cb-59b0-4c74-8b01-de5d2b1f81d1', + '0be21f1d-cbbf-49f3-a1df-b463777d1302', + '2b7b848c-7f55-4a57-ab1c-3df46399b380', + 'b1e2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6', + '12345678-abcd-1234-abcd-1234567890ab' +); +DELETE FROM venues WHERE id IN ( + '8b50e7a2-1d54-47f6-953e-bfa5c73c2421', + 'f4a8dc22-83b6-4554-949e-1dc63f707f5a', + 'a9b23b1b-dcde-41c3-8818-b711e74f8c6e', + 'c5cf420d-773a-44e2-b062-1132de62c5eb', + '5e2b0de4-91bb-4f38-89c0-54e7d97b91fc', + '7f8f94cb-59b0-4c74-8b01-de5d2b1f81d1', + '0be21f1d-cbbf-49f3-a1df-b463777d1302', + '2b7b848c-7f55-4a57-ab1c-3df46399b380', + 'b1e2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6', + '12345678-abcd-1234-abcd-1234567890ab' +); +DELETE FROM venues WHERE id LIKE 'venue-kerala-%'; -- Clean up the old string-based venue IDs + +-- 6. Insert Venues +INSERT INTO venues ( + id, + host_id, + name, + description, + venue_type, + capacity, + base_price_cents, + currency, + address_data, + amenities, + cover_image_url, + gallery_urls, + pricing_mode, + is_active, + is_suspended +) VALUES +( + '8b50e7a2-1d54-47f6-953e-bfa5c73c2421', + 'rY2XeurYfx151wxgozkerT0DibGUFTUc', + 'Kumarakom Lake Symphony Lawn', + 'Located on the banks of the pristine Vembanad Lake in Kumarakom, this venue offers a majestic outdoor lawn with traditional Kerala architectural touches. Perfect for dreamy heritage destination weddings, evening receptions, and high-end celebrations under the stars.', + 'wedding', + 600, + 15000000, -- INR 1,50,000 + 'INR', + '{"address_line1":"Vembanad Lake Shore","address_line2":"Kumarakom North","landmark":"Near Kumarakom Bird Sanctuary","city":"Kumarakom","state":"Kerala","pincode":"686563","country":"India","gstin":"32AAACK1234F1Z1","contact_phone":"+91 9447123456","contact_email":"kumarakom.symphony@example.com","rules":"No firecrackers allowed near the lake. DJ allowed till 10 PM. No outside catering.","cancellation_policy":"50% refund for cancellations made 30 days prior to the event.","min_booking_hours":8}', + '["Lake View","Spacious Lawn","Traditional Stage","Valet Parking","Catering Service","AC Changing Rooms","Wi-Fi"]', + '/api/storage/public/venue-images/seed/kumarakom_cover.jpg', + '["/api/storage/public/venue-images/seed/kumarakom_gallery_1.jpg","/api/storage/public/venue-images/seed/kumarakom_gallery_2.jpg"]', + 'per_day', + 1, + 0 +), +( + 'f4a8dc22-83b6-4554-949e-1dc63f707f5a', + 'rY2XeurYfx151wxgozkerT0DibGUFTUc', + 'Munnar Tea Valley Heritage Hall', + 'Nestled amidst the lush green rolling tea gardens of Munnar, this heritage conference hall features high ceilings, timber cladding, and breathtaking views of the misty valleys. It is fully equipped with modern audio-visual technology, making it the perfect choice for corporate retreats, seminars, and strategic workshops.', + 'conference', + 150, + 2500000, -- INR 25,000 + 'INR', + '{"address_line1":"Tea Estate Road","address_line2":"Chithirapuram","landmark":"Near Pallivasal Powerhouse","city":"Munnar","state":"Kerala","pincode":"685565","country":"India","gstin":"32AAACK5678F1Z2","contact_phone":"+91 9447234567","contact_email":"munnar.valley@example.com","rules":"Eco-friendly zone. Littering strictly prohibited. Smoking is allowed only in designated areas.","cancellation_policy":"Full refund up to 14 days before the event.","min_booking_hours":4}', + '["Valley View","High-speed Wi-Fi","AV Projector","Microphones","Generator Backup","Coffee Station","Catering Space"]', + '/api/storage/public/venue-images/seed/munnar_cover.jpg', + '["/api/storage/public/venue-images/seed/munnar_gallery_1.jpg","/api/storage/public/venue-images/seed/munnar_gallery_2.jpg"]', + 'per_hour', + 1, + 0 +), +( + 'a9b23b1b-dcde-41c3-8818-b711e74f8c6e', + 'rY2XeurYfx151wxgozkerT0DibGUFTUc', + 'Varkala Cliff-Side Ocean Vista', + 'Perched high on the famous red cliffs of Varkala, this stunning venue offers uninterrupted panoramic views of the Arabian Sea. With an open-air deck, chic lounge seating, and tropical beach vibes, it is the ultimate spot for sunset cocktail parties, birthdays, and private social gatherings.', + 'party', + 120, + 1500000, -- INR 15,000 + 'INR', + '{"address_line1":"North Cliff","address_line2":"Kurakkanni","landmark":"Near Helipad","city":"Varkala","state":"Kerala","pincode":"695141","country":"India","gstin":"","contact_phone":"+91 9447345678","contact_email":"varkala.vista@example.com","rules":"Outside alcohol allowed with permit. Music level must be reduced after 11 PM.","cancellation_policy":"Non-refundable within 7 days of event.","min_booking_hours":3}', + '["Ocean View","Sunset Deck","Sound System","Cocktail Bar Setup","Wi-Fi","Catering Allowed","Parking"]', + '/api/storage/public/venue-images/seed/varkala_cover.jpg', + '["/api/storage/public/venue-images/seed/varkala_gallery_1.jpg","/api/storage/public/venue-images/seed/varkala_gallery_2.jpg"]', + 'per_hour', + 1, + 0 +), +( + 'c5cf420d-773a-44e2-b062-1132de62c5eb', + 'rY2XeurYfx151wxgozkerT0DibGUFTUc', + 'Eranad Heritage Naalukettu Palace', + 'Step back in time at this authentic, painstakingly restored traditional Kerala Naalukettu mansion in Thrissur. Featuring a central open courtyard (Nadumuttam), exquisite teak pillars, and hand-painted tiles, it provides a deeply cultural and majestic backdrop for traditional wedding ceremonies, pre-wedding rituals, and classical events.', + 'wedding', + 300, + 9500000, -- INR 95,000 + 'INR', + '{"address_line1":"Heritage Village Road","address_line2":"Cheruthuruthy","landmark":"Near Kerala Kalamandalam","city":"Thrissur","state":"Kerala","pincode":"679531","country":"India","gstin":"32AAACK9012F1Z3","contact_phone":"+91 9447456789","contact_email":"eranad.naalukettu@example.com","rules":"Only vegetarian food allowed inside the main heritage house. Footwear is restricted in the inner courtyard.","cancellation_policy":"80% refund up to 30 days in advance.","min_booking_hours":12}', + '["Central Courtyard","Heritage Ambience","AC Groom & Bride Rooms","Traditional Dining Hall","In-house Temple Structure","Ample Parking"]', + '/api/storage/public/venue-images/seed/eranad_cover.jpg', + '["/api/storage/public/venue-images/seed/eranad_gallery_1.jpg","/api/storage/public/venue-images/seed/eranad_gallery_2.jpg"]', + 'per_day', + 1, + 0 +), +( + '5e2b0de4-91bb-4f38-89c0-54e7d97b91fc', + 'rY2XeurYfx151wxgozkerT0DibGUFTUc', + 'Kochi Waterfront Grand Ballroom', + 'Located on the Bolgatty Island waterfront, this state-of-the-art ballroom offers a vast column-free indoor space with a sophisticated pre-function lounge and a waterfront deck. It is the premier choice for large-scale international conferences, corporate galas, trade exhibitions, and premium award ceremonies.', + 'conference', + 1200, + 35000000, -- INR 3,50,000 + 'INR', + '{"address_line1":"Bolgatty Marina Road","address_line2":"Mulavukad","landmark":"Opposite Bolgatty Palace","city":"Kochi","state":"Kerala","pincode":"682050","country":"India","gstin":"32AAACK3456F1Z4","contact_phone":"+91 9447567890","contact_email":"kochi.ballroom@example.com","rules":"Compliance with all fire safety regulations. Sound restrictions apply after midnight.","cancellation_policy":"Strict: 25% refund for cancellations up to 45 days prior.","min_booking_hours":8}', + '["Central AC","Marina & River Views","High-speed Wi-Fi","Advanced Sound & Light System","Professional Catering Stage","Valet Parking for 200 cars","Dedicated VIP Lounges"]', + '/api/storage/public/venue-images/seed/kochi_cover.jpg', + '["/api/storage/public/venue-images/seed/kochi_gallery_1.jpg","/api/storage/public/venue-images/seed/kochi_gallery_2.jpg"]', + 'per_day', + 1, + 0 +), +( + '7f8f94cb-59b0-4c74-8b01-de5d2b1f81d1', + 'rY2XeurYfx151wxgozkerT0DibGUFTUc', + 'Alleppey Backwater Royal Houseboat', + 'Cruise in style along the serene backwaters of Alleppey on our triple-deck luxury houseboat. Featuring fully air-conditioned bedroom suites, a glass-walled conference lounge, and an open sun deck, this unique moving venue is perfect for intimate family celebrations, high-level board meetings, and sunset cocktail cruises.', + 'other', + 50, + 8000000, -- INR 80,000 + 'INR', + '{"address_line1":"Punnamada Finishing Point","address_line2":"Starting Point Jetty","landmark":"Near Punnamada Lake","city":"Alappuzha","state":"Kerala","pincode":"688006","country":"India","gstin":"","contact_phone":"+91 9447678901","contact_email":"alleppey.houseboat@example.com","rules":"Life jackets are mandatory during cruising. Cruise timings are 11 AM to 5:30 PM. Over-night anchoring at designated spots.","cancellation_policy":"Full refund up to 15 days before departure.","min_booking_hours":24}', + '["Fully Air Conditioned","Onboard Chef & Traditional Kerala Kitchen","Sunset Deck","Wi-Fi","Sound System","Bedrooms for Overnight Stay","River Cruise Route"]', + '/api/storage/public/venue-images/seed/alleppey_cover.jpg', + '["/api/storage/public/venue-images/seed/alleppey_gallery_1.jpg","/api/storage/public/venue-images/seed/alleppey_gallery_2.jpg"]', + 'per_day', + 1, + 0 +), +( + '0be21f1d-cbbf-49f3-a1df-b463777d1302', + 'rY2XeurYfx151wxgozkerT0DibGUFTUc', + 'Wayanad Rainforest Sanctuary Canopy Lounge', + 'Surrounded by a dense tropical rainforest canopy and whispering streams, this unique open-air treehouse-style deck in Wayanad offers a serene, close-to-nature celebration space. It is designed for luxury camping events, bonfire nights, acoustic musical gatherings, and wellness retreats.', + 'celebration', + 60, + 4500000, -- INR 45,000 + 'INR', + '{"address_line1":"Vythiri Forest Road","address_line2":"Lakkidi Ghats","landmark":"Near Lakkidi View Point","city":"Wayanad","state":"Kerala","pincode":"673576","country":"India","gstin":"32AAACK7890F1Z5","contact_phone":"+91 9447789012","contact_email":"wayanad.canopy@example.com","rules":"No plastic bottles or wrappers allowed in the forest area. Loud music is restricted after 9:30 PM due to forest regulations.","cancellation_policy":"7 days advance cancellation receives 90% refund.","min_booking_hours":6}', + '["Rainforest Canopy View","Natural Brook Access","Bonfire Pit","Eco-friendly Dining","Solar Power Backup","Guided Nature Walks","Wi-Fi"]', + '/api/storage/public/venue-images/seed/wayanad_cover.jpg', + '["/api/storage/public/venue-images/seed/wayanad_gallery_1.jpg","/api/storage/public/venue-images/seed/wayanad_gallery_2.jpg"]', + 'per_day', + 1, + 0 +), +( + '2b7b848c-7f55-4a57-ab1c-3df46399b380', + 'rY2XeurYfx151wxgozkerT0DibGUFTUc', + 'Fort Kochi Colonial Courtyard', + 'A delightful colonial-era courtyard featuring Portuguese and Dutch architectural accents, located in the historic heart of Fort Kochi. Surrounded by heritage trees, exposed brick walls, and twinkling fairy lights, this venue is ideal for boutique engagements, art exhibitions, unplugged music shows, and vintage-themed parties.', + 'celebration', + 100, + 800000, -- INR 8,000 + 'INR', + '{"address_line1":"Princess Street","address_line2":"Fort Kochi","landmark":"Near Santa Cruz Basilica","city":"Kochi","state":"Kerala","pincode":"682001","country":"India","gstin":"","contact_phone":"+91 9447890123","contact_email":"fortkochi.courtyard@example.com","rules":"Respect local historical guidelines. No loud amplified music after 10 PM. Outside decorators are welcome.","cancellation_policy":"Full refund up to 7 days before event.","min_booking_hours":4}', + '["Colonial Charm","Ambient Lighting","Catering Prep Area","Restrooms","Fairy Lights Decor","Wi-Fi","Close to Beach"]', + '/api/storage/public/venue-images/seed/fortkochi_cover.jpg', + '["/api/storage/public/venue-images/seed/fortkochi_gallery_1.jpg","/api/storage/public/venue-images/seed/fortkochi_gallery_2.jpg"]', + 'per_hour', + 1, + 0 +), +( + 'b1e2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6', + 'rY2XeurYfx151wxgozkerT0DibGUFTUc', + 'Ashtamudi Lake Vista Island Resort', + 'Located on a private island in the middle of Ashtamudi Lake in Kollam, this premium venue is reachable only by a scenic 5-minute boat ride. Featuring a luxury pool deck, palm trees, and an open bar lounge, it is the ultimate destination for high-end pool parties, elite family celebrations, and VIP gatherings.', + 'party', + 250, + 20000000, -- INR 2,00,000 + 'INR', + '{"address_line1":"Ashtamudi Private Isle","address_line2":"Mathilil","landmark":"Boat Jetty near Ashtamudi Bridge","city":"Kollam","state":"Kerala","pincode":"691601","country":"India","gstin":"32AAACK1234E1Z6","contact_phone":"+91 9447901234","contact_email":"ashtamudi.island@example.com","rules":"Includes free boat shuttle service for guests. Swimwear required for pool usage. Outside catering not allowed.","cancellation_policy":"No refund within 14 days. 50% refund up to 30 days.","min_booking_hours":12}', + '["Private Island Access","Swimming Pool & Pool Deck","Open-air Bar Counter","Scenic Speedboat Transfers","In-house DJ & sound","Water Sports Available","Premium AC Lounge"]', + '/api/storage/public/venue-images/seed/ashtamudi_cover.jpg', + '["/api/storage/public/venue-images/seed/ashtamudi_gallery_1.jpg","/api/storage/public/venue-images/seed/ashtamudi_gallery_2.jpg"]', + 'per_day', + 1, + 0 +), +( + '12345678-abcd-1234-abcd-1234567890ab', + 'rY2XeurYfx151wxgozkerT0DibGUFTUc', + 'Kovalam Shoreline Wedding Pavilion', + 'A magnificent open-air beachfront pavilion overlooking the famous lighthouse beach in Kovalam. Exchange vows with the soothing sound of the waves and a spectacular golden sunset background. Designed with elegant white draping, traditional brass lamps, and customizable oceanfront stage setups.', + 'wedding', + 400, + 18000000, -- INR 1,80,000 + 'INR', + '{"address_line1":"Lighthouse Beach Road","address_line2":"Kovalam Beach","landmark":"Behind Kovalam Lighthouse","city":"Kovalam","state":"Kerala","pincode":"695527","country":"India","gstin":"32AAACK5678E1Z7","contact_phone":"+91 9447012345","contact_email":"kovalam.shoreline@example.com","rules":"Beach permissions included in booking fee. Environmental protection guidelines must be followed. Safe swimming zones are marked.","cancellation_policy":"Full refund if cancelled 60 days before event; 50% refund if cancelled between 30 to 60 days.","min_booking_hours":8}', + '["Direct Beach Front","Panoramic Ocean Sunset View","Stage Draping & Lighting","VIP Dining Area","Valet Parking","Air-conditioned VIP Suites","Sound & Microphone Set"]', + '/api/storage/public/venue-images/seed/kovalam_cover.jpg', + '["/api/storage/public/venue-images/seed/kovalam_gallery_1.jpg","/api/storage/public/venue-images/seed/kovalam_gallery_2.jpg"]', + 'per_day', + 1, + 0 +); + +-- 7. Ensure reviews don't overlap +DELETE FROM venue_reviews WHERE id LIKE 'rev-kerala-%'; + +-- 8. Seed Reviews +INSERT INTO venue_reviews (id, venue_id, user_id, rating, feedback) +VALUES +('rev-kerala-1', '8b50e7a2-1d54-47f6-953e-bfa5c73c2421', 'customer-seed-1', 5, 'An absolute paradise! The lakeside view during sunset was breathtaking. Highly recommend for weddings.'), +('rev-kerala-2', 'f4a8dc22-83b6-4554-949e-1dc63f707f5a', 'customer-seed-1', 4, 'Very professional setup and beautiful views of the tea gardens. The audio system was top notch.'), +('rev-kerala-3', 'a9b23b1b-dcde-41c3-8818-b711e74f8c6e', 'customer-seed-1', 5, 'Best sunset cocktail party ever! The cliffside view is unbeatable.'), +('rev-kerala-4', '7f8f94cb-59b0-4c74-8b01-de5d2b1f81d1', 'customer-seed-1', 5, 'Cruising through Alappuzha waters while enjoying freshly caught Karimeen was amazing. Top experience!'), +('rev-kerala-5', '0be21f1d-cbbf-49f3-a1df-b463777d1302', 'customer-seed-1', 4, 'Waking up to the sounds of nature and the misty forest was dreamlike. Excellent service.'); diff --git a/apps/web/src/assets/venue-conservatory.jpg b/apps/web/src/assets/venue-conservatory.jpg new file mode 100644 index 000000000..d3db05ba6 Binary files /dev/null and b/apps/web/src/assets/venue-conservatory.jpg differ diff --git a/apps/web/src/assets/venue-loft.jpg b/apps/web/src/assets/venue-loft.jpg new file mode 100644 index 000000000..f55d0260b Binary files /dev/null and b/apps/web/src/assets/venue-loft.jpg differ diff --git a/apps/web/src/assets/venue-manor.jpg b/apps/web/src/assets/venue-manor.jpg new file mode 100644 index 000000000..ac2c88610 Binary files /dev/null and b/apps/web/src/assets/venue-manor.jpg differ diff --git a/apps/web/src/components/site-nav.tsx b/apps/web/src/components/site-nav.tsx new file mode 100644 index 000000000..1c92bc7a2 --- /dev/null +++ b/apps/web/src/components/site-nav.tsx @@ -0,0 +1,115 @@ +import { Link, useNavigate } from "@tanstack/react-router"; +import { useAuth, useUserRoles } from "@/hooks/use-auth"; +import { authProvider } from "@/infrastructure/providers"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { User as UserIcon } from "lucide-react"; + +export function SiteNav() { + const { user, loading } = useAuth(); + const { isHost, isAdmin } = useUserRoles(); + const navigate = useNavigate(); + + async function signOut() { + await authProvider.signOut(); + navigate({ to: "/" }); + } + + return ( + + ); +} + +export function SiteFooter() { + return ( + + ); +} diff --git a/apps/web/src/components/star-rating.tsx b/apps/web/src/components/star-rating.tsx new file mode 100644 index 000000000..b7b9865bc --- /dev/null +++ b/apps/web/src/components/star-rating.tsx @@ -0,0 +1,44 @@ +import { Star } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface StarRatingProps { + value: number; + onChange?: (value: number) => void; + size?: number; + className?: string; + readOnly?: boolean; +} + +export function StarRating({ value, onChange, size = 20, className, readOnly }: StarRatingProps) { + const interactive = !!onChange && !readOnly; + return ( +
+ {[1, 2, 3, 4, 5].map((n) => { + const filled = n <= Math.round(value); + const StarEl = ( + + ); + return interactive ? ( + + ) : ( + {StarEl} + ); + })} +
+ ); +} diff --git a/apps/web/src/components/ui/accordion.tsx b/apps/web/src/components/ui/accordion.tsx new file mode 100644 index 000000000..16ee90045 --- /dev/null +++ b/apps/web/src/components/ui/accordion.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx new file mode 100644 index 000000000..072a66562 --- /dev/null +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx new file mode 100644 index 000000000..cd0a06277 --- /dev/null +++ b/apps/web/src/components/ui/alert.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/apps/web/src/components/ui/aspect-ratio.tsx b/apps/web/src/components/ui/aspect-ratio.tsx new file mode 100644 index 000000000..c9e6f4bf9 --- /dev/null +++ b/apps/web/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx new file mode 100644 index 000000000..7904926b9 --- /dev/null +++ b/apps/web/src/components/ui/avatar.tsx @@ -0,0 +1,47 @@ +"use client"; + +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx new file mode 100644 index 000000000..3aabd17e7 --- /dev/null +++ b/apps/web/src/components/ui/badge.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx new file mode 100644 index 000000000..94eb6291a --- /dev/null +++ b/apps/web/src/components/ui/breadcrumb.tsx @@ -0,0 +1,101 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>