diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..655775346 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.env +*.db +*.sqlite3 + +# Node +node_modules/ +dist/ +.vite/ +npm-debug.log* +*.tsbuildinfo + +# IDE / OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/README.md b/README.md index 68d8b36b8..02327b265 100644 --- a/README.md +++ b/README.md @@ -44,4 +44,119 @@ To maintain a high standard of code, all Pull Requests must use our [standard te 4. **Sign the AI Disclosure** (Confirming you have reviewed any AI-generated code). 5. **Attach Screenshots** (If your PR includes UI changes). +--- + +# MVP Implementation (FastAPI + React) + +A location-based venue discovery and booking platform. Find and book the perfect +nearby spaces for birthdays, professional meetups, and local events. + +This is the **MVP** of a vertical slice covering three personas: **User**, +**Owner**, and **Admin**. + +## Stack + +- **Backend**: FastAPI · SQLAlchemy 2.0 · Alembic · Pydantic v2 · JWT auth · + Google OAuth · SQLite (MVP; Postgres-ready via Alembic). +- **Frontend**: React 18 + Vite + TypeScript · React Router · TanStack Query · + Zustand · Tailwind CSS (minimal **red** BookMyShow-style theme). +- **Layout**: monorepo — `frontend/`, `backend/`, `docs/`, `docker-compose.yml`. + +## Repository structure + +``` +BMV/ + frontend/ React + Vite + TS app + src/ + api/ axios client + endpoint modules + features/ auth, venues, bookings, owner, admin + components/ shared UI (Button, Input, Card, layout) + stores/ zustand stores (auth, filters) + lib/ utils, types, constants + backend/ FastAPI app + app/ + core/ config, security (JWT), google, deps, exceptions + db/ base, session, init_db, seed + modules/ auth, users, venues, bookings, payments, admin + / router.py, service.py, schemas.py, (repository/model) + models/ re-exports for Alembic + main.py app factory + alembic/ migrations + tests/ + docs/ ERD + API contract +``` + +## Quick start (local) + +### Backend + +```bash +cd backend +python -m venv .venv +# Windows: .venv\Scripts\activate | macOS/Linux: source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env + +# create tables + seed demo data +python -m app.db.init_db + +# run server +uvicorn app.main:app --reload --port 8000 +# API docs: http://localhost:8000/docs +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +# open http://localhost:5173 +``` + +The Vite dev server proxies `/api` to `http://localhost:8000`. + +## Demo accounts (after seeding) + +| Role | Email | Password | +|-------|---------------------|------------| +| Admin | admin@example.com | admin123 | +| Owner | owner@example.com | owner123 | +| User | user@example.com | user123 | + +## Docker + +```bash +docker compose up --build +# backend -> http://localhost:8000 frontend -> http://localhost:5173 +``` + +## Database migrations (Alembic) + +```bash +cd backend +alembic revision --autogenerate -m "describe change" +alembic upgrade head +``` + +## MVP feature scope + +- **User**: email/Google signup, search & filter venues, view details, instant + booking request, booking history, cancel (mock refund). +- **Owner**: list venue (await approval), manage listings, accept/decline + incoming bookings. +- **Admin**: approve/reject venues, platform overview stats. + +Payments are **mocked** (a `payments` record is created on confirmation and +marked `mock_refunded` on cancellation). Real payment gateway, analytics +dashboards, owner payouts, disputes, and notifications are deferred. + +## Key flows + +1. Owner submits venue → status `pending`. +2. Admin approves → venue appears in public search. +3. User searches/filters → books → status `pending`. +4. Owner accepts → `confirmed`, mock payment recorded. +5. User cancels → `cancelled`, payment `mock_refunded`. + **BookMyVenue belongs to all of us. Join WeCode today and let's build something amazing together!** diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 000000000..56d67b655 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,15 @@ +# App +APP_NAME=BookMyVenue +ENVIRONMENT=development +BACKEND_CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] + +# Database (SQLite for MVP) +DATABASE_URL=sqlite:///./bmv.db + +# Security +JWT_SECRET=change-me-in-production-please-use-a-long-random-string +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=10080 + +# Google OAuth +GOOGLE_CLIENT_ID= diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 000000000..fcdc0e977 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1 +FROM python:3.13-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 000000000..344604e81 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = sqlite:///./bmv.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 000000000..3d68ceda1 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,41 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import settings +from app.db.base import Base +import app.models # noqa: F401 (register models) + +config = context.config +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 000000000..590f5b3a6 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/1d35a50953f8_add_indexes_on_venue_status_and_type.py b/backend/alembic/versions/1d35a50953f8_add_indexes_on_venue_status_and_type.py new file mode 100644 index 000000000..949dcfff9 --- /dev/null +++ b/backend/alembic/versions/1d35a50953f8_add_indexes_on_venue_status_and_type.py @@ -0,0 +1,30 @@ +"""add indexes on venue status and type + +Revision ID: 1d35a50953f8 +Revises: ace4f6eabba8 +Create Date: 2026-06-28 22:54:49.213742 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '1d35a50953f8' +down_revision: Union[str, None] = 'ace4f6eabba8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_venues_status'), 'venues', ['status'], unique=False) + op.create_index(op.f('ix_venues_type'), 'venues', ['type'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_venues_type'), table_name='venues') + op.drop_index(op.f('ix_venues_status'), table_name='venues') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/ace4f6eabba8_initial_schema.py b/backend/alembic/versions/ace4f6eabba8_initial_schema.py new file mode 100644 index 000000000..291cd3375 --- /dev/null +++ b/backend/alembic/versions/ace4f6eabba8_initial_schema.py @@ -0,0 +1,108 @@ +"""initial schema + +Revision ID: ace4f6eabba8 +Revises: +Create Date: 2026-06-28 22:14:34.434845 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'ace4f6eabba8' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=True), + sa.Column('role', sa.Enum('user', 'owner', 'admin', name='userrole'), nullable=False), + sa.Column('google_sub', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('google_sub') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('owner_profiles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('business_name', sa.String(length=255), nullable=False), + sa.Column('contact_phone', sa.String(length=50), nullable=True), + sa.Column('payout_info', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id') + ) + op.create_table('venues', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('owner_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('type', sa.Enum('birthday_hall', 'cafe', 'hotel', 'resort', 'auditorium', 'meetup', 'mall', 'other', name='venuetype'), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('address', sa.String(length=500), nullable=False), + sa.Column('lat', sa.Float(), nullable=True), + sa.Column('lng', sa.Float(), nullable=True), + sa.Column('price_per_hour', sa.Float(), nullable=False), + sa.Column('capacity', sa.Integer(), nullable=False), + sa.Column('photos', sa.JSON(), nullable=False), + sa.Column('amenities', sa.JSON(), nullable=False), + sa.Column('status', sa.Enum('pending', 'approved', 'rejected', name='venuestatus'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_venues_owner_id'), 'venues', ['owner_id'], unique=False) + op.create_table('bookings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('venue_id', sa.Integer(), nullable=False), + sa.Column('start_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('end_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('status', sa.Enum('pending', 'confirmed', 'declined', 'cancelled', 'completed', name='bookingstatus'), nullable=False), + sa.Column('total_price', sa.Float(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_bookings_user_id'), 'bookings', ['user_id'], unique=False) + op.create_index(op.f('ix_bookings_venue_id'), 'bookings', ['venue_id'], unique=False) + op.create_table('payments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('booking_id', sa.Integer(), nullable=False), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('status', sa.Enum('mock_success', 'mock_refunded', 'failed', name='paymentstatus'), nullable=False), + sa.Column('provider_txn_id', sa.String(length=64), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('booking_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('payments') + op.drop_index(op.f('ix_bookings_venue_id'), table_name='bookings') + op.drop_index(op.f('ix_bookings_user_id'), table_name='bookings') + op.drop_table('bookings') + op.drop_index(op.f('ix_venues_owner_id'), table_name='venues') + op.drop_table('venues') + op.drop_table('owner_profiles') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 000000000..2c4c19834 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,59 @@ +from functools import lru_cache +from typing import List + +from pydantic import field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +_PLACEHOLDER_SECRET = "change-me-in-production" + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", case_sensitive=True, extra="ignore" + ) + + APP_NAME: str = "BookMyVenue" + ENVIRONMENT: str = "development" + BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"] + + DATABASE_URL: str = "sqlite:///./bmv.db" + + JWT_SECRET: str = _PLACEHOLDER_SECRET + JWT_ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days + + GOOGLE_CLIENT_ID: str = "" + + @field_validator("BACKEND_CORS_ORIGINS", mode="before") + @classmethod + def _split_cors(cls, v): + if isinstance(v, str): + # Accept JSON list or comma-separated + v = v.strip() + if v.startswith("["): + import json + return json.loads(v) + return [origin.strip() for origin in v.split(",") if origin.strip()] + return v + + @model_validator(mode="after") + def _enforce_secrets_in_production(self): + if self.ENVIRONMENT != "development": + if not self.JWT_SECRET or self.JWT_SECRET == _PLACEHOLDER_SECRET: + raise ValueError( + "JWT_SECRET must be set to a strong random value in non-development " + "environments (ENVIRONMENT != 'development')." + ) + if self.DATABASE_URL.startswith("sqlite"): + raise ValueError( + "SQLite is not permitted outside development; set a non-sqlite DATABASE_URL." + ) + return self + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 000000000..001708296 --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,56 @@ +from typing import Annotated, Callable + +from fastapi import Depends, Request +from sqlalchemy.orm import Session + +from app.core.exceptions import ForbiddenError, UnauthorizedError +from app.core.security import decode_token +from app.db.session import get_db +from app.modules.users.model import User, UserRole + +COOKIE_NAME = "bmv_token" + + +def _extract_token(request: Request) -> str | None: + auth = request.headers.get("Authorization") + if auth and auth.lower().startswith("bearer "): + return auth.split(" ", 1)[1].strip() + return request.cookies.get(COOKIE_NAME) + + +def get_current_user( + request: Request, + db: Session = Depends(get_db), +) -> User: + token = _extract_token(request) + if not token: + raise UnauthorizedError("Not authenticated") + try: + payload = decode_token(token) + except ValueError as exc: + raise UnauthorizedError("Invalid or expired token") from exc + + user_id = payload.get("sub") + if not user_id: + raise UnauthorizedError("Invalid token payload") + user = db.get(User, int(user_id)) + if not user: + raise UnauthorizedError("User not found") + return user + + +def require_role(*allowed: UserRole) -> Callable[..., User]: + allowed_set = set(allowed) + + def _checker(current_user: User = Depends(get_current_user)) -> User: + if current_user.role not in allowed_set: + raise ForbiddenError("Insufficient permissions") + return current_user + + return _checker + + +# Pre-built role dependencies +require_user = require_role(UserRole.user, UserRole.owner, UserRole.admin) +require_owner = require_role(UserRole.owner, UserRole.admin) +require_admin = require_role(UserRole.admin) diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 000000000..26c4bdc93 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,46 @@ +"""Domain-specific exceptions and FastAPI exception handlers.""" + + +class AppException(Exception): + status_code: int = 400 + detail: str = "Application error" + + def __init__(self, detail: str | None = None, status_code: int | None = None): + if detail is not None: + self.detail = detail + if status_code is not None: + self.status_code = status_code + super().__init__(self.detail) + + +class NotFoundError(AppException): + status_code = 404 + detail = "Resource not found" + + +class ConflictError(AppException): + status_code = 409 + detail = "Conflict" + + +class ForbiddenError(AppException): + status_code = 403 + detail = "Forbidden" + + +class UnauthorizedError(AppException): + status_code = 401 + detail = "Unauthorized" + + +class ValidationError(AppException): + status_code = 422 + detail = "Validation error" + + +def register_exception_handlers(app): # noqa: ANN001 + from fastapi.responses import JSONResponse + + @app.exception_handler(AppException) + async def _handle_app_exception(request, exc: AppException): # noqa: ANN001, ARG001 + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 000000000..227e7ce49 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,40 @@ +from datetime import datetime, timedelta, timezone +from typing import Any + +import bcrypt +from jose import JWTError, jwt + +from app.core.config import settings + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + +def verify_password(plain: str, hashed: str) -> bool: + try: + return bcrypt.checkpw(plain.encode(), hashed.encode()) + except ValueError: + return False + + +def create_access_token(subject: str | int, extra: dict[str, Any] | None = None) -> str: + now = datetime.now(timezone.utc) + expire = now + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + payload: dict[str, Any] = { + "sub": str(subject), + "exp": expire, + "iat": now, + } + if extra: + payload.update(extra) + return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) + + +def decode_token(token: str) -> dict[str, Any]: + try: + return jwt.decode( + token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM] + ) + except JWTError as exc: + raise ValueError("Invalid token") from exc diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 000000000..460f8eaca --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,20 @@ +from datetime import datetime + +from sqlalchemy import DateTime, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py new file mode 100644 index 000000000..6b307f01e --- /dev/null +++ b/backend/app/db/init_db.py @@ -0,0 +1,17 @@ +"""Development DB initializer + seeder. Run via: python -m app.db.init_db""" +from app.db.base import Base +from app.db.session import SessionLocal, engine +from app.db.seed import seed_demo_data + + +def init_db() -> None: + import app.models # noqa: F401 + + Base.metadata.create_all(bind=engine) + with SessionLocal() as db: + seed_demo_data(db) + + +if __name__ == "__main__": + init_db() + print("Database initialized and seeded.") diff --git a/backend/app/db/seed.py b/backend/app/db/seed.py new file mode 100644 index 000000000..816a3bf40 --- /dev/null +++ b/backend/app/db/seed.py @@ -0,0 +1,95 @@ +"""Seed demo data for local development only.""" +from datetime import timedelta, timezone +from datetime import datetime + +from sqlalchemy import select + +from app.core.security import hash_password +from app.modules.bookings.model import Booking, BookingStatus +from app.modules.users.model import User, UserRole +from app.modules.venues.model import Venue, VenueStatus, VenueType + + +def seed_demo_data(db) -> None: # noqa: ANN001 + if db.scalar(select(User).where(User.email == "admin@example.com")): + return # already seeded + + admin = User( + email="admin@example.com", + name="Admin", + password_hash=hash_password("admin123"), + role=UserRole.admin, + ) + owner = User( + email="owner@example.com", + name="Venue Owner", + password_hash=hash_password("owner123"), + role=UserRole.owner, + ) + user = User( + email="user@example.com", + name="Demo User", + password_hash=hash_password("user123"), + role=UserRole.user, + ) + db.add_all([admin, owner, user]) + db.flush() + + sample_venues = [ + Venue( + owner_id=owner.id, + name="Grand Birthday Hall", + type=VenueType.birthday_hall, + description="Spacious hall perfect for birthday parties.", + address="123 Party St, Downtown", + lat=12.9716, + lng=77.5946, + price_per_hour=50.0, + capacity=200, + photos=[], + amenities=["parking", "catering", "ac"], + status=VenueStatus.approved, + ), + Venue( + owner_id=owner.id, + name="Cozy Corner Cafe", + type=VenueType.cafe, + description="Intimate cafe for small meetups.", + address="45 Coffee Ln, Midtown", + lat=12.9650, + lng=77.6000, + price_per_hour=20.0, + capacity=40, + photos=[], + amenities=["wifi", "coffee"], + status=VenueStatus.approved, + ), + Venue( + owner_id=owner.id, + name="Skyline Auditorium", + type=VenueType.auditorium, + description="Large auditorium for events and conferences.", + address="88 Skyline Ave, Uptown", + lat=12.9800, + lng=77.5800, + price_per_hour=120.0, + capacity=800, + photos=[], + amenities=["ac", "projector", "parking"], + status=VenueStatus.pending, + ), + ] + db.add_all(sample_venues) + db.flush() + + start = datetime.now(timezone.utc) + timedelta(days=7) + booking = Booking( + user_id=user.id, + venue_id=sample_venues[0].id, + start_at=start, + end_at=start + timedelta(hours=3), + status=BookingStatus.confirmed, + total_price=150.0, + ) + db.add(booking) + db.commit() diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 000000000..68d127553 --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,22 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import settings + +connect_args = {"check_same_thread": False} if settings.DATABASE_URL.startswith( + "sqlite" +) else {} + +engine = create_engine(settings.DATABASE_URL, connect_args=connect_args, future=True) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, future=True) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 000000000..7525f3c26 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,68 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import settings +from app.core.exceptions import register_exception_handlers +from app.db.base import Base +from app.db.session import engine + +API_PREFIX = "/api/v1" + + +@asynccontextmanager +async def lifespan(app: FastAPI): # noqa: ARG001 + # Auto-create tables only for local SQLite development convenience. + # Non-development deployments must apply schema via Alembic migrations + # (`alembic upgrade head`) so alembic_version stays in sync. + if settings.ENVIRONMENT == "development" and settings.DATABASE_URL.startswith( + "sqlite" + ): + import app.models # noqa: F401 (ensure models imported) + + Base.metadata.create_all(bind=engine) + yield + + +def create_app() -> FastAPI: + app = FastAPI(title=settings.APP_NAME, version="0.1.0", lifespan=lifespan) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.BACKEND_CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + register_exception_handlers(app) + + # Register routers + from app.modules.admin.router import router as admin_router + from app.modules.admin.service import router as admin_overview_router + from app.modules.auth.router import router as auth_router + from app.modules.bookings.router import ( + owner_bookings_router, + router as bookings_router, + ) + from app.modules.users.router import router as users_router + from app.modules.venues.router import owner_router, router as venues_router + + app.include_router(auth_router, prefix=API_PREFIX) + app.include_router(users_router, prefix=API_PREFIX) + app.include_router(venues_router, prefix=API_PREFIX) + app.include_router(owner_router, prefix=API_PREFIX) + app.include_router(bookings_router, prefix=API_PREFIX) + app.include_router(owner_bookings_router, prefix=API_PREFIX) + app.include_router(admin_router, prefix=API_PREFIX) + app.include_router(admin_overview_router, prefix=API_PREFIX) + + @app.get("/health") + def health(): + return {"status": "ok", "app": settings.APP_NAME} + + return app + + +app = create_app() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 000000000..72e1d4a0f --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,19 @@ +"""Re-export all ORM models so Alembic autogenerate and create_all discover them.""" +from app.modules.bookings.model import Booking, BookingStatus # noqa: F401 +from app.modules.payments.model import Payment, PaymentStatus # noqa: F401 +from app.modules.users.model import User, UserRole # noqa: F401 +from app.modules.users.owner_model import OwnerProfile # noqa: F401 +from app.modules.venues.model import Venue, VenueStatus, VenueType # noqa: F401 + +__all__ = [ + "Booking", + "BookingStatus", + "Payment", + "PaymentStatus", + "User", + "UserRole", + "OwnerProfile", + "Venue", + "VenueStatus", + "VenueType", +] diff --git a/backend/app/modules/__init__.py b/backend/app/modules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/modules/admin/__init__.py b/backend/app/modules/admin/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/modules/admin/router.py b/backend/app/modules/admin/router.py new file mode 100644 index 000000000..d22c8beb3 --- /dev/null +++ b/backend/app/modules/admin/router.py @@ -0,0 +1,78 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.deps import require_admin +from app.core.exceptions import ConflictError +from app.db.session import get_db +from app.modules.bookings.model import Booking +from app.modules.bookings.schemas import BookingRead +from app.modules.users.model import User +from app.modules.venues import repository as venues_repo +from app.modules.venues import service as venues_service +from app.modules.venues.model import VenueStatus +from app.modules.venues.schemas import VenueRead, VenueStatusUpdate + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.get("/venues/pending", response_model=list[VenueRead]) +def pending_venues( + db: Session = Depends(get_db), + _user: User = Depends(require_admin), +): + return venues_repo.list_pending_venues(db) + + +@router.get("/venues", response_model=list[VenueRead]) +def all_venues( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), + _user: User = Depends(require_admin), +): + return venues_repo.list_all_venues(db, skip=skip, limit=limit) + + +@router.patch("/venues/{venue_id}", response_model=VenueRead) +def update_venue_status( + venue_id: int, + data: VenueStatusUpdate, + db: Session = Depends(get_db), + _user: User = Depends(require_admin), +): + if data.status not in (VenueStatus.approved, VenueStatus.rejected): + raise ConflictError("Admin can only set a venue to 'approved' or 'rejected'") + venue = venues_repo.get_venue(db, venue_id) + return venues_repo.set_venue_status(db, venue, data.status) + + +@router.get("/bookings", response_model=list[BookingRead]) +def all_bookings( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), + _user: User = Depends(require_admin), +): + stmt = select(Booking).order_by(Booking.created_at.desc()).offset(skip).limit(limit) + return list(db.scalars(stmt)) + + +@router.get("/venues/{venue_id}/approve", response_model=VenueRead) +def approve_venue( + venue_id: int, + db: Session = Depends(get_db), + _user: User = Depends(require_admin), +): + venue = venues_repo.get_venue(db, venue_id) + return venues_service.approve(db, venue) + + +@router.get("/venues/{venue_id}/reject", response_model=VenueRead) +def reject_venue( + venue_id: int, + db: Session = Depends(get_db), + _user: User = Depends(require_admin), +): + venue = venues_repo.get_venue(db, venue_id) + return venues_service.reject(db, venue) diff --git a/backend/app/modules/admin/service.py b/backend/app/modules/admin/service.py new file mode 100644 index 000000000..6ff87e4a4 --- /dev/null +++ b/backend/app/modules/admin/service.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.deps import require_admin +from app.db.session import get_db +from app.modules.bookings.model import Booking +from app.modules.payments.model import Payment, PaymentStatus +from app.modules.payments.schemas import OverviewStats +from app.modules.users.model import User +from app.modules.venues import repository as venues_repo +from app.modules.venues.model import VenueStatus + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.get("/overview", response_model=OverviewStats) +def overview( + db: Session = Depends(get_db), + _user: User = Depends(require_admin), +): + total_users = db.scalar(select(func.count(User.id))) or 0 + return OverviewStats( + total_users=total_users, + total_venues=venues_repo.count_by_status(db, None), + pending_venues=venues_repo.count_by_status(db, VenueStatus.pending), + total_bookings=db.scalar(select(func.count(Booking.id))) or 0, + total_revenue=db.scalar( + select(func.coalesce(func.sum(Payment.amount), 0.0)).where( + Payment.status == PaymentStatus.mock_success + ) + ) or 0.0, + ) diff --git a/backend/app/modules/auth/__init__.py b/backend/app/modules/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/modules/auth/google.py b/backend/app/modules/auth/google.py new file mode 100644 index 000000000..5e5b4b191 --- /dev/null +++ b/backend/app/modules/auth/google.py @@ -0,0 +1,45 @@ +"""Google OAuth: verify a Google ID token and extract user info. + +Uses Authlib's ID token verifier. Falls back gracefully in development when +GOOGLE_CLIENT_ID is not configured (returns a stub) so the flow is testable. +""" +from __future__ import annotations + +from authlib.jose import JoseError +from authlib.oidc.core import CodeIDToken + +from app.core.config import settings +from app.core.exceptions import UnauthorizedError +from app.modules.auth.schemas import GoogleUserInfo + +_AUDIENCE_FALLBACK = "stub-google-client-id" + + +def verify_google_token(credential: str) -> GoogleUserInfo: + if not settings.GOOGLE_CLIENT_ID: + if settings.ENVIRONMENT != "development": + raise UnauthorizedError( + "Google OAuth is not configured (GOOGLE_CLIENT_ID missing)." + ) + # Development stub mode: accept any non-empty credential and fabricate + # deterministic user info so the OAuth flow is exercisable end-to-end + # without a real Google project configured. NEVER available in non-dev. + import hashlib + + digest = hashlib.sha256(credential.encode()).hexdigest() + return GoogleUserInfo( + sub=f"stub-{digest[:16]}", + email=f"user_{digest[:8]}@stub-google.example", + name="Google User", + ) + + try: + claims = CodeIDToken().validate(credential, settings.GOOGLE_CLIENT_ID) + except JoseError as exc: + raise UnauthorizedError(f"Invalid Google token: {exc}") from exc + + return GoogleUserInfo( + sub=str(claims.get("sub")), + email=str(claims.get("email")), + name=str(claims.get("name") or claims.get("email", "Google User")), + ) diff --git a/backend/app/modules/auth/router.py b/backend/app/modules/auth/router.py new file mode 100644 index 000000000..b0334afba --- /dev/null +++ b/backend/app/modules/auth/router.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, Depends, Response +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.exceptions import UnauthorizedError +from app.core.security import create_access_token, verify_password +from app.db.session import get_db +from app.modules.auth.google import verify_google_token +from app.modules.auth.schemas import ( + GoogleLoginRequest, + GoogleUserInfo, + LoginRequest, + Token, +) +from app.modules.users import service as users_service +from app.modules.users.schemas import UserCreate, UserGoogleCreate + +router = APIRouter(prefix="/auth", tags=["auth"]) + +COOKIE_NAME = "bmv_token" + + +def _set_token_cookie(response: Response, token: str) -> None: + secure = settings.ENVIRONMENT != "development" + response.set_cookie( + key=COOKIE_NAME, + value=token, + httponly=True, + secure=secure, + samesite="lax", + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + path="/", + ) + + +def _issue(user_id: int, role: str, response: Response) -> Token: + token = create_access_token(subject=user_id, extra={"role": role}) + _set_token_cookie(response, token) + return Token(access_token=token) + + +@router.post("/signup", response_model=Token) +def signup(data: UserCreate, response: Response, db: Session = Depends(get_db)): + user = users_service.create_user(db, data) + return _issue(user.id, user.role.value, response) + + +@router.post("/login", response_model=Token) +def login(data: LoginRequest, response: Response, db: Session = Depends(get_db)): + user = users_service.get_user_by_email(db, data.email) + if not user or not user.password_hash or not verify_password( + data.password, user.password_hash + ): + raise UnauthorizedError("Invalid email or password") + return _issue(user.id, user.role.value, response) + + +@router.post("/google", response_model=Token) +def google_login( + data: GoogleLoginRequest, response: Response, db: Session = Depends(get_db) +): + info: GoogleUserInfo = verify_google_token(data.credential) + user = users_service.create_or_get_google_user( + db, + UserGoogleCreate(email=info.email, name=info.name, google_sub=info.sub), + ) + return _issue(user.id, user.role.value, response) + + +@router.post("/logout") +def logout(response: Response): + response.delete_cookie(COOKIE_NAME, path="/") + return {"detail": "logged out"} diff --git a/backend/app/modules/auth/schemas.py b/backend/app/modules/auth/schemas.py new file mode 100644 index 000000000..ae09afbd3 --- /dev/null +++ b/backend/app/modules/auth/schemas.py @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + + +class TokenPayload(BaseModel): + sub: Optional[str] = None + exp: Optional[datetime] = None + + +class LoginRequest(BaseModel): + email: str + password: str + + +class GoogleLoginRequest(BaseModel): + credential: str # Google ID token sent from the frontend + + +class GoogleUserInfo(BaseModel): + sub: str + email: str + name: str diff --git a/backend/app/modules/bookings/__init__.py b/backend/app/modules/bookings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/modules/bookings/model.py b/backend/app/modules/bookings/model.py new file mode 100644 index 000000000..8528df0b3 --- /dev/null +++ b/backend/app/modules/bookings/model.py @@ -0,0 +1,29 @@ +import enum +from datetime import datetime + +from sqlalchemy import DateTime, Enum, Float, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin + + +class BookingStatus(str, enum.Enum): + pending = "pending" + confirmed = "confirmed" + declined = "declined" + cancelled = "cancelled" + completed = "completed" + + +class Booking(Base, TimestampMixin): + __tablename__ = "bookings" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + venue_id: Mapped[int] = mapped_column(ForeignKey("venues.id", ondelete="CASCADE"), index=True) + start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + status: Mapped[BookingStatus] = mapped_column( + Enum(BookingStatus), default=BookingStatus.pending, nullable=False + ) + total_price: Mapped[float] = mapped_column(Float, nullable=False) diff --git a/backend/app/modules/bookings/repository.py b/backend/app/modules/bookings/repository.py new file mode 100644 index 000000000..ce7c40724 --- /dev/null +++ b/backend/app/modules/bookings/repository.py @@ -0,0 +1,107 @@ +from datetime import datetime, timezone + +from sqlalchemy import and_, select +from sqlalchemy.orm import Session + +from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError, ValidationError +from app.modules.bookings.model import Booking, BookingStatus +from app.modules.venues import repository as venues_repo +from app.modules.venues.model import Venue as VenueModel + + +_ACTIVE_STATUSES = (BookingStatus.pending, BookingStatus.confirmed) +_CANCELABLE_BY_USER = (BookingStatus.pending,) + + +def list_user_bookings(db: Session, user_id: int) -> list[Booking]: + return list(db.scalars(select(Booking).where(Booking.user_id == user_id))) + + +def list_venue_owner_bookings(db: Session, owner_id: int) -> list[Booking]: + venues = venues_repo.list_owner_venues(db, owner_id) + venue_ids = [v.id for v in venues] + if not venue_ids: + return [] + return list(db.scalars(select(Booking).where(Booking.venue_id.in_(venue_ids)))) + + +def get_booking(db: Session, booking_id: int) -> Booking: + booking = db.get(Booking, booking_id) + if not booking: + raise NotFoundError("Booking not found") + return booking + + +def has_conflict( + db: Session, venue_id: int, start_at: datetime, end_at: datetime, exclude_id: int | None = None +) -> bool: + stmt = select(Booking).where( + Booking.venue_id == venue_id, + Booking.status.in_(_ACTIVE_STATUSES), + and_(Booking.start_at < end_at, Booking.end_at > start_at), + ) + if exclude_id is not None: + stmt = stmt.where(Booking.id != exclude_id) + return db.scalar(select(stmt.exists())) is True + + +def create_booking( + db: Session, user_id: int, venue_id: int, start_at: datetime, end_at: datetime +) -> Booking: + venue = venues_repo.get_approved_venue(db, venue_id) + + if start_at < datetime.now(timezone.utc): + raise ValidationError("Cannot book in the past") + + # Lock the venue row for the duration of the transaction so two concurrent + # booking requests cannot both pass the conflict check (TOCTOU). + db.execute( + select(VenueModel).where(VenueModel.id == venue_id).with_for_update() + ) + + if has_conflict(db, venue_id, start_at, end_at): + raise ConflictError("Venue is already booked for the selected time") + + duration_hours = max((end_at - start_at).total_seconds() / 3600.0, 1.0) + total_price = round(duration_hours * venue.price_per_hour, 2) + + booking = Booking( + user_id=user_id, + venue_id=venue_id, + start_at=start_at, + end_at=end_at, + status=BookingStatus.pending, + total_price=total_price, + ) + db.add(booking) + db.commit() + db.refresh(booking) + return booking + + +def cancel_booking(db: Session, booking: Booking, user_id: int) -> Booking: + if booking.user_id != user_id: + raise ForbiddenError("You cannot cancel this booking") + if booking.status not in _CANCELABLE_BY_USER: + raise ConflictError( + "Only pending bookings can be cancelled by the user. " + "Contact the owner to cancel a confirmed booking." + ) + booking.status = BookingStatus.cancelled + db.commit() + db.refresh(booking) + return booking + + +def decide_booking( + db: Session, booking: Booking, owner_id: int, accept: bool +) -> Booking: + venue = venues_repo.get_venue(db, booking.venue_id) + if venue.owner_id != owner_id: + raise ForbiddenError("You do not manage this venue's bookings") + if booking.status != BookingStatus.pending: + raise ConflictError("Booking is not pending a decision") + booking.status = BookingStatus.confirmed if accept else BookingStatus.declined + db.commit() + db.refresh(booking) + return booking diff --git a/backend/app/modules/bookings/router.py b/backend/app/modules/bookings/router.py new file mode 100644 index 000000000..22be0396d --- /dev/null +++ b/backend/app/modules/bookings/router.py @@ -0,0 +1,91 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.deps import require_owner, require_user +from app.core.exceptions import ConflictError +from app.db.session import get_db +from app.modules.bookings import repository +from app.modules.bookings.model import BookingStatus +from app.modules.bookings.schemas import ( + BookingCreate, + BookingDecision, + BookingRead, +) +from app.modules.users.model import User + +router = APIRouter(tags=["bookings"]) + + +def _enrich(booking, db: Session) -> BookingRead: # noqa: ANN001 + from app.modules.venues.model import Venue + + read = BookingRead.model_validate(booking) + venue = db.get(Venue, booking.venue_id) + read.venue_name = venue.name if venue else None + return read + + +@router.post("/bookings", response_model=BookingRead, status_code=201) +def create_booking( + data: BookingCreate, + current_user: User = Depends(require_user), + db: Session = Depends(get_db), +): + booking = repository.create_booking( + db, current_user.id, data.venue_id, data.start_at, data.end_at + ) + return _enrich(booking, db) + + +@router.get("/bookings", response_model=list[BookingRead]) +def my_bookings( + current_user: User = Depends(require_user), + db: Session = Depends(get_db), +): + bookings = repository.list_user_bookings(db, current_user.id) + return [_enrich(b, db) for b in bookings] + + +@router.post("/bookings/{booking_id}/cancel", response_model=BookingRead) +def cancel_booking( + booking_id: int, + current_user: User = Depends(require_user), + db: Session = Depends(get_db), +): + booking = repository.get_booking(db, booking_id) + booking = repository.cancel_booking(db, booking, current_user.id) + # Trigger mock refund if there was a payment + from app.modules.payments import service as payments_service + + payments_service.refund_for_booking(db, booking.id) + return _enrich(booking, db) + + +owner_bookings_router = APIRouter(prefix="/owner", tags=["owner"]) + + +@owner_bookings_router.get("/bookings", response_model=list[BookingRead]) +def owner_bookings( + current_user: User = Depends(require_owner), + db: Session = Depends(get_db), +): + bookings = repository.list_venue_owner_bookings(db, current_user.id) + return [_enrich(b, db) for b in bookings] + + +@owner_bookings_router.patch("/bookings/{booking_id}", response_model=BookingRead) +def decide_booking( + booking_id: int, + data: BookingDecision, + current_user: User = Depends(require_owner), + db: Session = Depends(get_db), +): + if data.status not in (BookingStatus.confirmed, BookingStatus.declined): + raise ConflictError("Decision must be confirmed or declined") + booking = repository.get_booking(db, booking_id) + booking = repository.decide_booking(db, booking, current_user.id, data.status == BookingStatus.confirmed) + if booking.status == BookingStatus.confirmed: + from app.modules.payments import service as payments_service + + payments_service.charge_for_booking(db, booking.id, booking.total_price) + return _enrich(booking, db) diff --git a/backend/app/modules/bookings/schemas.py b/backend/app/modules/bookings/schemas.py new file mode 100644 index 000000000..f2409b225 --- /dev/null +++ b/backend/app/modules/bookings/schemas.py @@ -0,0 +1,36 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.modules.bookings.model import BookingStatus + + +class BookingCreate(BaseModel): + venue_id: int + start_at: datetime + end_at: datetime + + @model_validator(mode="after") + def _check_range(self): + if self.end_at <= self.start_at: + raise ValueError("end_at must be after start_at") + return self + + +class BookingRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + venue_id: int + start_at: datetime + end_at: datetime + status: BookingStatus + total_price: float + created_at: datetime + venue_name: Optional[str] = None + + +class BookingDecision(BaseModel): + status: BookingStatus = Field(description="confirmed or declined") diff --git a/backend/app/modules/payments/__init__.py b/backend/app/modules/payments/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/modules/payments/model.py b/backend/app/modules/payments/model.py new file mode 100644 index 000000000..a1ca66fc5 --- /dev/null +++ b/backend/app/modules/payments/model.py @@ -0,0 +1,26 @@ +import enum + +from sqlalchemy import Enum, Float, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin + + +class PaymentStatus(str, enum.Enum): + mock_success = "mock_success" + mock_refunded = "mock_refunded" + failed = "failed" + + +class Payment(Base, TimestampMixin): + __tablename__ = "payments" + + id: Mapped[int] = mapped_column(primary_key=True) + booking_id: Mapped[int] = mapped_column( + ForeignKey("bookings.id", ondelete="CASCADE"), unique=True, nullable=False + ) + amount: Mapped[float] = mapped_column(Float, nullable=False) + status: Mapped[PaymentStatus] = mapped_column( + Enum(PaymentStatus), default=PaymentStatus.mock_success, nullable=False + ) + provider_txn_id: Mapped[str] = mapped_column(String(64), nullable=False) diff --git a/backend/app/modules/payments/schemas.py b/backend/app/modules/payments/schemas.py new file mode 100644 index 000000000..f7d133d17 --- /dev/null +++ b/backend/app/modules/payments/schemas.py @@ -0,0 +1,25 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + +from app.modules.payments.model import PaymentStatus + + +class PaymentRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + booking_id: int + amount: float + status: PaymentStatus + provider_txn_id: str + created_at: datetime + + +class OverviewStats(BaseModel): + total_users: int + total_venues: int + pending_venues: int + total_bookings: int + total_revenue: float diff --git a/backend/app/modules/payments/service.py b/backend/app/modules/payments/service.py new file mode 100644 index 000000000..e7b4fa6ff --- /dev/null +++ b/backend/app/modules/payments/service.py @@ -0,0 +1,36 @@ +import uuid + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.modules.payments.model import Payment, PaymentStatus + + +def get_payment_for_booking(db: Session, booking_id: int) -> Payment | None: + return db.scalar(select(Payment).where(Payment.booking_id == booking_id)) + + +def charge_for_booking(db: Session, booking_id: int, amount: float) -> Payment: + existing = get_payment_for_booking(db, booking_id) + if existing: + return existing + payment = Payment( + booking_id=booking_id, + amount=amount, + status=PaymentStatus.mock_success, + provider_txn_id=f"mock_{uuid.uuid4().hex[:16]}", + ) + db.add(payment) + db.commit() + db.refresh(payment) + return payment + + +def refund_for_booking(db: Session, booking_id: int) -> Payment | None: + payment = get_payment_for_booking(db, booking_id) + if not payment or payment.status != PaymentStatus.mock_success: + return payment + payment.status = PaymentStatus.mock_refunded + db.commit() + db.refresh(payment) + return payment diff --git a/backend/app/modules/users/__init__.py b/backend/app/modules/users/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/modules/users/model.py b/backend/app/modules/users/model.py new file mode 100644 index 000000000..da3ed19d9 --- /dev/null +++ b/backend/app/modules/users/model.py @@ -0,0 +1,25 @@ +import enum + +from sqlalchemy import Enum, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin + + +class UserRole(str, enum.Enum): + user = "user" + owner = "owner" + admin = "admin" + + +class User(Base, TimestampMixin): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) + role: Mapped[UserRole] = mapped_column( + Enum(UserRole), default=UserRole.user, nullable=False + ) + google_sub: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True) diff --git a/backend/app/modules/users/owner_model.py b/backend/app/modules/users/owner_model.py new file mode 100644 index 000000000..1fd344158 --- /dev/null +++ b/backend/app/modules/users/owner_model.py @@ -0,0 +1,16 @@ +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin + + +class OwnerProfile(Base, TimestampMixin): + __tablename__ = "owner_profiles" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False + ) + business_name: Mapped[str] = mapped_column(String(255), nullable=False) + contact_phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + payout_info: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/backend/app/modules/users/router.py b/backend/app/modules/users/router.py new file mode 100644 index 000000000..ea4267129 --- /dev/null +++ b/backend/app/modules/users/router.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.deps import get_current_user +from app.db.session import get_db +from app.modules.users.model import User +from app.modules.users.schemas import ( + OwnerProfileCreate, + OwnerProfileRead, + UserRead, + UserUpdate, +) +from app.modules.users import service + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/me", response_model=UserRead) +def read_me(current_user: User = Depends(get_current_user)): + return current_user + + +@router.put("/me", response_model=UserRead) +def update_me( + data: UserUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + return service.update_user(db, current_user, data) + + +@router.post("/me/owner", response_model=OwnerProfileRead) +def register_as_owner( + data: OwnerProfileCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + return service.become_owner(db, current_user, data) + + +@router.get("/me/owner", response_model=OwnerProfileRead) +def read_my_owner_profile( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + return service.get_owner_profile(db, current_user.id) diff --git a/backend/app/modules/users/schemas.py b/backend/app/modules/users/schemas.py new file mode 100644 index 000000000..30afe882c --- /dev/null +++ b/backend/app/modules/users/schemas.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import enum +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, EmailStr + +from app.modules.users.model import UserRole + +class UserBase(BaseModel): + email: EmailStr + name: str + + +class SignupRole(str, enum.Enum): + """Roles a client is allowed to self-select at signup. + Admin can never be self-assigned.""" + user = "user" + owner = "owner" + + +class UserCreate(UserBase): + password: str + role: SignupRole = SignupRole.user + + +class UserGoogleCreate(BaseModel): + email: EmailStr + name: str + google_sub: str + role: UserRole = UserRole.user + + +class UserRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + email: EmailStr + name: str + role: UserRole + google_sub: Optional[str] = None + + +class UserUpdate(BaseModel): + name: Optional[str] = None + + +class OwnerProfileCreate(BaseModel): + business_name: str + contact_phone: Optional[str] = None + payout_info: Optional[str] = None + + +class OwnerProfileRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + business_name: str + contact_phone: Optional[str] = None + payout_info: Optional[str] = None + created_at: datetime diff --git a/backend/app/modules/users/service.py b/backend/app/modules/users/service.py new file mode 100644 index 000000000..534798a59 --- /dev/null +++ b/backend/app/modules/users/service.py @@ -0,0 +1,101 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.exceptions import ConflictError, NotFoundError +from app.core.security import hash_password +from app.modules.users.model import User, UserRole +from app.modules.users.owner_model import OwnerProfile +from app.modules.users.schemas import ( + OwnerProfileCreate, + UserCreate, + UserGoogleCreate, + UserUpdate, +) + + +def get_user_by_email(db: Session, email: str) -> User | None: + return db.scalar(select(User).where(User.email == email)) + + +def get_user_by_id(db: Session, user_id: int) -> User: + user = db.get(User, user_id) + if not user: + raise NotFoundError("User not found") + return user + + +def get_user_by_google_sub(db: Session, google_sub: str) -> User | None: + return db.scalar(select(User).where(User.google_sub == google_sub)) + + +def create_user(db: Session, data: UserCreate) -> User: + if get_user_by_email(db, data.email): + raise ConflictError("Email already registered") + user = User( + email=data.email, + name=data.name, + password_hash=hash_password(data.password), + role=UserRole(data.role.value), + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def create_or_get_google_user(db: Session, data: UserGoogleCreate) -> User: + existing = get_user_by_google_sub(db, data.google_sub) + if existing: + return existing + if get_user_by_email(db, data.email): + raise ConflictError("Email already registered with a different sign-in method") + user = User( + email=data.email, + name=data.name, + google_sub=data.google_sub, + role=data.role, + password_hash=None, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def update_user(db: Session, user: User, data: UserUpdate) -> User: + if data.name is not None: + user.name = data.name + db.commit() + db.refresh(user) + return user + + +def become_owner(db: Session, user: User, profile: OwnerProfileCreate) -> OwnerProfile: + if user.role == UserRole.admin: + from app.core.exceptions import ForbiddenError + raise ForbiddenError("Admins cannot become owners") + existing = db.scalar(select(OwnerProfile).where(OwnerProfile.user_id == user.id)) + if existing: + existing.business_name = profile.business_name + existing.contact_phone = profile.contact_phone + existing.payout_info = profile.payout_info + owner_profile = existing + else: + owner_profile = OwnerProfile( + user_id=user.id, + business_name=profile.business_name, + contact_phone=profile.contact_phone, + payout_info=profile.payout_info, + ) + db.add(owner_profile) + user.role = UserRole.owner + db.commit() + db.refresh(owner_profile) + return owner_profile + + +def get_owner_profile(db: Session, user_id: int) -> OwnerProfile: + profile = db.scalar(select(OwnerProfile).where(OwnerProfile.user_id == user_id)) + if not profile: + raise NotFoundError("Owner profile not found") + return profile diff --git a/backend/app/modules/venues/__init__.py b/backend/app/modules/venues/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/modules/venues/model.py b/backend/app/modules/venues/model.py new file mode 100644 index 000000000..030acbce3 --- /dev/null +++ b/backend/app/modules/venues/model.py @@ -0,0 +1,53 @@ +import enum + +from sqlalchemy import JSON, Enum, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin + + +class VenueType(str, enum.Enum): + birthday_hall = "birthday_hall" + cafe = "cafe" + hotel = "hotel" + resort = "resort" + auditorium = "auditorium" + meetup = "meetup" + mall = "mall" + other = "other" + + +class VenueStatus(str, enum.Enum): + pending = "pending" + approved = "approved" + rejected = "rejected" + + +class Venue(Base, TimestampMixin): + __tablename__ = "venues" + + id: Mapped[int] = mapped_column(primary_key=True) + owner_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + type: Mapped[VenueType] = mapped_column(Enum(VenueType), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + address: Mapped[str] = mapped_column(String(500), nullable=False) + lat: Mapped[float | None] = mapped_column(Float, nullable=True) + lng: Mapped[float | None] = mapped_column(Float, nullable=True) + price_per_hour: Mapped[float] = mapped_column(Float, nullable=False) + capacity: Mapped[int] = mapped_column(Integer, nullable=False) + photos: Mapped[list] = mapped_column(JSON, default=list, nullable=False) + amenities: Mapped[list] = mapped_column(JSON, default=list, nullable=False) + status: Mapped[VenueStatus] = mapped_column( + Enum(VenueStatus), default=VenueStatus.pending, nullable=False, index=True + ) + + @property + def distance_km(self) -> float | None: + return getattr(self, "_distance_km", None) + + @distance_km.setter + def distance_km(self, value: float | None) -> None: + self._distance_km = value diff --git a/backend/app/modules/venues/repository.py b/backend/app/modules/venues/repository.py new file mode 100644 index 000000000..fa2a6a434 --- /dev/null +++ b/backend/app/modules/venues/repository.py @@ -0,0 +1,145 @@ +import math +from typing import Optional + +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.core.exceptions import ForbiddenError, NotFoundError +from app.modules.venues.model import Venue, VenueStatus, VenueType +from app.modules.venues.schemas import VenueSearchParams, VenueUpdate + + +def search_venues(db: Session, params: VenueSearchParams) -> list[Venue]: + stmt = select(Venue).where(Venue.status == VenueStatus.approved) + + if params.type: + stmt = stmt.where(Venue.type == params.type) + if params.min_price is not None: + stmt = stmt.where(Venue.price_per_hour >= params.min_price) + if params.max_price is not None: + stmt = stmt.where(Venue.price_per_hour <= params.max_price) + if params.min_capacity is not None: + stmt = stmt.where(Venue.capacity >= params.min_capacity) + if params.q: + like = f"%{params.q}%" + stmt = stmt.where( + or_(Venue.name.ilike(like), Venue.address.ilike(like)) + ) + + venues = list(db.scalars(stmt)) + + # Location radius filtering in Python (SQLite has no native geo). + # Apply the radius filter to the full candidate set BEFORE pagination so + # the page is sliced from the actually-in-range results. + if params.lat is not None and params.lng is not None: + radius = params.radius_km if params.radius_km is not None else 10.0 + results = [] + for v in venues: + if v.lat is None or v.lng is None: + continue + dist = haversine(params.lat, params.lng, v.lat, v.lng) + if dist <= radius: + v.distance_km = round(dist, 2) + results.append(v) + results.sort(key=lambda x: x.distance_km or 0.0) + venues = results + + return venues[params.skip : params.skip + params.limit] + + +def haversine(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + earth_radius_km = 6371.0 + d_lat = math.radians(lat2 - lat1) + d_lng = math.radians(lng2 - lng1) + a = ( + math.sin(d_lat / 2) ** 2 + + math.cos(math.radians(lat1)) + * math.cos(math.radians(lat2)) + * math.sin(d_lng / 2) ** 2 + ) + return earth_radius_km * 2 * math.asin(math.sqrt(a)) + + +def get_venue(db: Session, venue_id: int) -> Venue: + venue = db.get(Venue, venue_id) + if not venue: + raise NotFoundError("Venue not found") + return venue + + +def get_approved_venue(db: Session, venue_id: int) -> Venue: + venue = get_venue(db, venue_id) + if venue.status != VenueStatus.approved: + raise NotFoundError("Venue not available") + return venue + + +def create_venue(db: Session, owner_id: int, data) -> Venue: # noqa: ANN001 + venue = Venue( + owner_id=owner_id, + name=data.name, + type=data.type, + description=data.description, + address=data.address, + lat=data.lat, + lng=data.lng, + price_per_hour=data.price_per_hour, + capacity=data.capacity, + photos=data.photos, + amenities=data.amenities, + status=VenueStatus.pending, + ) + db.add(venue) + db.commit() + db.refresh(venue) + return venue + + +def update_venue(db: Session, venue: Venue, owner_id: int, data: VenueUpdate) -> Venue: + if venue.owner_id != owner_id: + raise ForbiddenError("You do not own this venue") + updates = data.model_dump(exclude_unset=True) + for field, value in updates.items(): + setattr(venue, field, value) + db.commit() + db.refresh(venue) + return venue + + +def list_owner_venues(db: Session, owner_id: int) -> list[Venue]: + return list(db.scalars(select(Venue).where(Venue.owner_id == owner_id))) + + +def list_pending_venues(db: Session, skip: int = 0, limit: int = 50) -> list[Venue]: + stmt = ( + select(Venue) + .where(Venue.status == VenueStatus.pending) + .order_by(Venue.created_at.desc()) + .offset(skip) + .limit(limit) + ) + return list(db.scalars(stmt)) + + +def list_all_venues(db: Session, skip: int = 0, limit: int = 50) -> list[Venue]: + stmt = ( + select(Venue) + .order_by(Venue.created_at.desc()) + .offset(skip) + .limit(limit) + ) + return list(db.scalars(stmt)) + + +def set_venue_status(db: Session, venue: Venue, status: VenueStatus) -> Venue: + venue.status = status + db.commit() + db.refresh(venue) + return venue + + +def count_by_status(db: Session, status: Optional[VenueStatus] = None) -> int: + stmt = select(func.count()).select_from(Venue) + if status is not None: + stmt = stmt.where(Venue.status == status) + return int(db.scalar(stmt) or 0) diff --git a/backend/app/modules/venues/router.py b/backend/app/modules/venues/router.py new file mode 100644 index 000000000..450a44838 --- /dev/null +++ b/backend/app/modules/venues/router.py @@ -0,0 +1,86 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.core.deps import get_current_user, require_owner +from app.db.session import get_db +from app.modules.users.model import User +from app.modules.venues import repository, service +from app.modules.venues.model import VenueStatus, VenueType +from app.modules.venues.schemas import ( + VenueCreate, + VenueRead, + VenueSearchParams, + VenueUpdate, +) + +router = APIRouter(tags=["venues"]) + + +def _params( + lat: float | None = None, + lng: float | None = None, + radius_km: float | None = None, + type: VenueType | None = None, + min_price: float | None = None, + max_price: float | None = None, + min_capacity: int | None = None, + q: str | None = None, + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), +) -> VenueSearchParams: + return VenueSearchParams( + lat=lat, + lng=lng, + radius_km=radius_km, + type=type, + min_price=min_price, + max_price=max_price, + min_capacity=min_capacity, + q=q, + skip=skip, + limit=limit, + ) + + +@router.get("/venues", response_model=list[VenueRead]) +def search_venues( + params: VenueSearchParams = Depends(_params), + db: Session = Depends(get_db), +): + return service.search(db, params) + + +@router.get("/venues/{venue_id}", response_model=VenueRead) +def get_venue(venue_id: int, db: Session = Depends(get_db)): + return service.get_public(db, venue_id) + + +owner_router = APIRouter(prefix="/owner", tags=["owner"]) + + +@owner_router.post("/venues", response_model=VenueRead, status_code=201) +def create_venue( + data: VenueCreate, + current_user: User = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.create(db, current_user.id, data) + + +@owner_router.get("/venues", response_model=list[VenueRead]) +def list_my_venues( + current_user: User = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.list_for_owner(db, current_user.id) + + +@owner_router.put("/venues/{venue_id}", response_model=VenueRead) +def update_venue( + venue_id: int, + data: VenueUpdate, + current_user: User = Depends(require_owner), + db: Session = Depends(get_db), +): + venue = repository.get_venue(db, venue_id) + return service.update(db, venue, current_user.id, data) diff --git a/backend/app/modules/venues/schemas.py b/backend/app/modules/venues/schemas.py new file mode 100644 index 000000000..0bbb4a8b0 --- /dev/null +++ b/backend/app/modules/venues/schemas.py @@ -0,0 +1,63 @@ +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from app.modules.venues.model import VenueStatus, VenueType + + +class VenueBase(BaseModel): + name: str = Field(min_length=1, max_length=255) + type: VenueType + description: Optional[str] = None + address: str = Field(min_length=1, max_length=500) + lat: Optional[float] = None + lng: Optional[float] = None + price_per_hour: float = Field(ge=0) + capacity: int = Field(ge=1) + photos: List[str] = Field(default_factory=list) + amenities: List[str] = Field(default_factory=list) + + +class VenueCreate(VenueBase): + pass + + +class VenueUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + address: Optional[str] = None + lat: Optional[float] = None + lng: Optional[float] = None + price_per_hour: Optional[float] = None + capacity: Optional[int] = None + photos: Optional[List[str]] = None + amenities: Optional[List[str]] = None + + +class VenueRead(VenueBase): + model_config = ConfigDict(from_attributes=True) + + id: int + owner_id: int + status: VenueStatus + created_at: datetime + distance_km: Optional[float] = None + + +class VenueSearchParams(BaseModel): + lat: Optional[float] = None + lng: Optional[float] = None + radius_km: Optional[float] = None + type: Optional[VenueType] = None + min_price: Optional[float] = None + max_price: Optional[float] = None + min_capacity: Optional[int] = None + date: Optional[datetime] = None + q: Optional[str] = None + skip: int = 0 + limit: int = 20 + + +class VenueStatusUpdate(BaseModel): + status: VenueStatus diff --git a/backend/app/modules/venues/service.py b/backend/app/modules/venues/service.py new file mode 100644 index 000000000..87de84789 --- /dev/null +++ b/backend/app/modules/venues/service.py @@ -0,0 +1,33 @@ +from sqlalchemy.orm import Session + +from app.modules.venues import repository +from app.modules.venues.model import Venue, VenueStatus +from app.modules.venues.schemas import VenueCreate, VenueSearchParams, VenueUpdate + + +def search(db: Session, params: VenueSearchParams) -> list[Venue]: + return repository.search_venues(db, params) + + +def get_public(db: Session, venue_id: int) -> Venue: + return repository.get_approved_venue(db, venue_id) + + +def create(db: Session, owner_id: int, data: VenueCreate) -> Venue: + return repository.create_venue(db, owner_id, data) + + +def update(db: Session, venue: Venue, owner_id: int, data: VenueUpdate) -> Venue: + return repository.update_venue(db, venue, owner_id, data) + + +def list_for_owner(db: Session, owner_id: int) -> list[Venue]: + return repository.list_owner_venues(db, owner_id) + + +def approve(db: Session, venue: Venue) -> Venue: + return repository.set_venue_status(db, venue, VenueStatus.approved) + + +def reject(db: Session, venue: Venue) -> Venue: + return repository.set_venue_status(db, venue, VenueStatus.rejected) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 000000000..ad1871126 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +SQLAlchemy==2.0.36 +alembic==1.14.0 +pydantic==2.10.4 +pydantic-settings==2.7.0 +python-jose[cryptography]==3.3.0 +bcrypt==4.2.1 +authlib==1.3.2 +httpx==0.28.1 +python-multipart==0.0.20 +email-validator==2.2.0 +pytest==8.3.4 +pytest-asyncio==0.25.0 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/test_smoke.py b/backend/tests/test_smoke.py new file mode 100644 index 000000000..72ff304ea --- /dev/null +++ b/backend/tests/test_smoke.py @@ -0,0 +1,185 @@ +"""Smoke tests covering the MVP booking flow and role enforcement.""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from fastapi.testclient import TestClient # noqa: E402 + +from app.db.base import Base # noqa: E402 +from app.db.session import engine, SessionLocal # noqa: E402 +from app.main import app # noqa: E402 + +Base.metadata.drop_all(bind=engine) +Base.metadata.create_all(bind=engine) + + +def _client(): + return TestClient(app) + + +def test_signup_login_and_booking_flow(): + client = _client() + + owner = client.post( + "/api/v1/auth/signup", + json={ + "email": "owner@test.io", + "name": "Owner", + "password": "secret123", + "role": "owner", + }, + ) + assert owner.status_code == 200, owner.text + owner_token = owner.json()["access_token"] + + user = client.post( + "/api/v1/auth/signup", + json={ + "email": "user@test.io", + "name": "User", + "password": "secret123", + "role": "user", + }, + ) + assert user.status_code == 200, user.text + user_token = user.json()["access_token"] + + # Owner creates a venue (goes to pending) + venue = client.post( + "/api/v1/owner/venues", + headers={"Authorization": f"Bearer {owner_token}"}, + json={ + "name": "Test Hall", + "type": "birthday_hall", + "address": "1 Test St", + "lat": 12.97, + "lng": 77.59, + "price_per_hour": 40.0, + "capacity": 100, + "photos": [], + "amenities": ["ac"], + }, + ) + assert venue.status_code == 201, venue.text + venue_id = venue.json()["id"] + assert venue.json()["status"] == "pending" + + # Admin approves. Admin role cannot be self-assigned via signup, so create + # the admin account directly in the DB (as a real bootstrap would). + from app.db.session import SessionLocal + from app.core.security import hash_password + from app.modules.users.model import User, UserRole + + with SessionLocal() as db: + admin_user = User( + email="admin@test.io", + name="Admin", + password_hash=hash_password("secret123"), + role=UserRole.admin, + ) + db.add(admin_user) + db.commit() + db.refresh(admin_user) + admin_id = admin_user.id + + admin_login = client.post( + "/api/v1/auth/login", + json={"email": "admin@test.io", "password": "secret123"}, + ) + assert admin_login.status_code == 200, admin_login.text + admin_token = admin_login.json()["access_token"] + approved = client.patch( + f"/api/v1/admin/venues/{venue_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + json={"status": "approved"}, + ) + assert approved.status_code == 200 + assert approved.json()["status"] == "approved" + + # User searches venues + search = client.get("/api/v1/venues", params={"type": "birthday_hall"}) + assert search.status_code == 200 + assert any(v["id"] == venue_id for v in search.json()) + + # User books + from datetime import datetime, timedelta, timezone + + start = datetime.now(timezone.utc) + timedelta(days=2) + booking = client.post( + "/api/v1/bookings", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "venue_id": venue_id, + "start_at": start.isoformat(), + "end_at": (start + timedelta(hours=3)).isoformat(), + }, + ) + assert booking.status_code == 201, booking.text + booking_id = booking.json()["id"] + assert booking.json()["total_price"] == 120.0 + + # Conflict booking + conflict = client.post( + "/api/v1/bookings", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "venue_id": venue_id, + "start_at": start.isoformat(), + "end_at": (start + timedelta(hours=2)).isoformat(), + }, + ) + assert conflict.status_code == 409 + + # User cancels while still pending (cancel only allowed pre-confirm) + cancelled = client.post( + f"/api/v1/bookings/{booking_id}/cancel", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert cancelled.status_code == 200, cancelled.text + assert cancelled.json()["status"] == "cancelled" + + # Cancelling again / after cancel is rejected + re_cancel = client.post( + f"/api/v1/bookings/{booking_id}/cancel", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert re_cancel.status_code == 409 + + # Create a second booking; owner accepts, then user cancel must be rejected + booking2 = client.post( + "/api/v1/bookings", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "venue_id": venue_id, + "start_at": (start + timedelta(days=1)).isoformat(), + "end_at": ((start + timedelta(days=1)) + timedelta(hours=2)).isoformat(), + }, + ) + booking2_id = booking2.json()["id"] + + decided = client.patch( + f"/api/v1/owner/bookings/{booking2_id}", + headers={"Authorization": f"Bearer {owner_token}"}, + json={"status": "confirmed"}, + ) + assert decided.status_code == 200, decided.text + assert decided.json()["status"] == "confirmed" + + cancel_confirmed = client.post( + f"/api/v1/bookings/{booking2_id}/cancel", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert cancel_confirmed.status_code == 409 # policy: cannot cancel confirmed + + # Role enforcement: user cannot hit owner endpoint + forbidden = client.get( + "/api/v1/owner/venues", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert forbidden.status_code == 403 + + +if __name__ == "__main__": + test_signup_login_and_booking_flow() + print("SMOKE_TEST_OK") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..16d43340e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + backend: + build: ./backend + ports: + - "8000:8000" + environment: + - DATABASE_URL=sqlite:///./data/bmv.db + - ENVIRONMENT=development + - JWT_SECRET=change-me-in-production + - BACKEND_CORS_ORIGINS=["http://localhost:5173"] + - GOOGLE_CLIENT_ID= + volumes: + - ./backend:/app + - backend_data:/app/data + + frontend: + build: ./frontend + ports: + - "5173:5173" + volumes: + - ./frontend:/app + - /app/node_modules + depends_on: + - backend + +volumes: + backend_data: diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 000000000..9c68e428a --- /dev/null +++ b/docs/api.md @@ -0,0 +1,63 @@ +# BookMyVenue — API Contract (v1) + +Base URL: `/api/v1` + +All authenticated routes require `Authorization: Bearer `. + +## Auth + +| Method | Path | Body | Returns | Access | +|--------|------------------|------------------------------|---------------|---------| +| POST | /auth/signup | `{email,name,password,role}` | `Token` | public | +| POST | /auth/login | `{email,password}` | `Token` | public | +| POST | /auth/google | `{credential}` | `Token` | public | + +## Users + +| Method | Path | Body | Returns | Access | +|--------|------------------|-------------------------------|--------------------|--------| +| GET | /users/me | - | `UserRead` | any | +| PUT | /users/me | `{name?}` | `UserRead` | any | +| POST | /users/me/owner | `{business_name,...}` | `OwnerProfileRead` | any | +| GET | /users/me/owner | - | `OwnerProfileRead` | any | + +## Venues (public) + +| Method | Path | Query | Returns | Access | +|--------|------------------|-------------------------------------------------------------|----------------|--------| +| GET | /venues | lat,lng,radius_km,type,min_price,max_price,min_capacity,q | `VenueRead[]` | public | +| GET | /venues/{id} | - | `VenueRead` | public | + +## Owner + +| Method | Path | Body | Returns | Access | +|--------|------------------------|-----------------------|----------------|--------| +| POST | /owner/venues | `VenueCreate` | `VenueRead` | owner | +| GET | /owner/venues | - | `VenueRead[]` | owner | +| PUT | /owner/venues/{id} | `VenueUpdate` | `VenueRead` | owner | +| GET | /owner/bookings | - | `BookingRead[]`| owner | +| PATCH | /owner/bookings/{id} | `{status}` | `BookingRead` | owner | + +## Bookings (user) + +| Method | Path | Body | Returns | Access | +|--------|----------------------------|-----------------------|----------------|--------| +| POST | /bookings | `{venue_id,start,end}`| `BookingRead` | any | +| GET | /bookings | - | `BookingRead[]`| any | +| POST | /bookings/{id}/cancel | - | `BookingRead` | any | + +## Admin + +| Method | Path | Body | Returns | Access | +|--------|----------------------------|-----------------|------------------|--------| +| GET | /admin/overview | - | `OverviewStats` | admin | +| GET | /admin/venues/pending | - | `VenueRead[]` | admin | +| GET | /admin/venues | - | `VenueRead[]` | admin | +| PATCH | /admin/venues/{id} | `{status}` | `VenueRead` | admin | +| GET | /admin/bookings | - | `BookingRead[]` | admin | + +## Health + +| Method | Path | Returns | +|--------|---------|---------| +| GET | /health | `{status: ok}` | diff --git a/docs/erd.md b/docs/erd.md new file mode 100644 index 000000000..241021df7 --- /dev/null +++ b/docs/erd.md @@ -0,0 +1,74 @@ +# BookMyVenue — Data Model (ERD) + +Single `users` table with a `role` enum (`user` | `owner` | `admin`). + +``` +users + id PK + email unique + name + password_hash nullable (null for Google sign-in) + role enum(user, owner, admin) + google_sub nullable, unique + created_at + updated_at + +owner_profiles 1:1 -> users (only for owners) + id PK + user_id FK -> users.id, unique + business_name + contact_phone nullable + payout_info nullable + created_at + updated_at + +venues + id PK + owner_id FK -> users.id + name + type enum(birthday_hall, cafe, hotel, resort, auditorium, + meetup, mall, other) + description nullable + address + lat nullable + lng nullable + price_per_hour + capacity + photos JSON array + amenities JSON array + status enum(pending, approved, rejected) + created_at + updated_at + +bookings + id PK + user_id FK -> users.id + venue_id FK -> venues.id + start_at + end_at + status enum(pending, confirmed, declined, cancelled, completed) + total_price + created_at + updated_at + +payments 1:1 -> bookings + id PK + booking_id FK -> bookings.id, unique + amount + status enum(mock_success, mock_refunded, failed) + provider_txn_id string (fake UUID for MVP) + created_at + updated_at +``` + +## Booking state machine + +``` + ┌── confirmed ──► completed +pending ────┤ + ├── declined + └── cancelled (by user) -> payment mock_refunded +``` + +- Payment record is created when a booking moves to `confirmed`. +- On `cancelled`, the linked payment is flipped to `mock_refunded`. diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 000000000..1aa35a78d --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +# syntax=docker/dockerfile:1 +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 000000000..385c3d291 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + BookMyVenue + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 000000000..65ac4382d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3131 @@ +{ + "name": "bmv-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bmv-frontend", + "version": "0.1.0", + "dependencies": { + "@hookform/resolvers": "^3.9.1", + "@tanstack/react-query": "^5.62.0", + "axios": "^1.7.9", + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.54.1", + "react-router-dom": "^6.28.0", + "tailwind-merge": "^2.5.5", + "zod": "^3.24.1", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@hookform/resolvers": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", + "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.4", + "caniuse-lite": "^1.0.30001799", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.380", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", + "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-hook-form": { + "version": "7.80.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.80.0.tgz", + "integrity": "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..df2f35880 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "bmv-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint .", + "typecheck": "tsc -b --noEmit" + }, + "dependencies": { + "@hookform/resolvers": "^3.9.1", + "@tanstack/react-query": "^5.62.0", + "axios": "^1.7.9", + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.54.1", + "react-router-dom": "^6.28.0", + "tailwind-merge": "^2.5.5", + "zod": "^3.24.1", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 000000000..2aa7205d4 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 000000000..61826de60 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,101 @@ +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Routes, Route, Navigate } from "react-router-dom"; +import { Footer, Navbar } from "@/components/layout/Navbar"; +import { ProtectedRoute } from "@/components/ProtectedRoute"; +import { HomePage } from "@/features/venues/HomePage"; +import { VenueListPage } from "@/features/venues/VenueListPage"; +import { VenueDetailPage } from "@/features/venues/VenueDetailPage"; +import { LoginPage } from "@/features/auth/LoginPage"; +import { SignupPage } from "@/features/auth/SignupPage"; +import { BookingsPage } from "@/features/bookings/BookingsPage"; +import { OwnerDashboardPage } from "@/features/owner/OwnerDashboardPage"; +import { SubmitVenuePage } from "@/features/owner/SubmitVenuePage"; +import { ManageBookingsPage } from "@/features/owner/ManageBookingsPage"; +import { AdminDashboardPage } from "@/features/admin/AdminDashboardPage"; +import { AdminApprovalsPage } from "@/features/admin/AdminApprovalsPage"; +import { authApi } from "@/api/auth"; +import { useAuthStore } from "@/stores/authStore"; + +export default function App() { + const { isAuthenticated, setUser, logout } = useAuthStore(); + const { data, isError } = useQuery({ + queryKey: ["me"], + queryFn: authApi.me, + enabled: isAuthenticated, + retry: false, + }); + + useEffect(() => { + if (data) setUser(data); + }, [data, setUser]); + + useEffect(() => { + if (isError && isAuthenticated) logout(); + }, [isError, isAuthenticated, logout]); + + return ( +
+ +
+ + } /> + } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + +
+
+
+ ); +} diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 000000000..7c74fc31d --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,18 @@ +import { apiClient } from "./client"; +import type { Booking, OverviewStats, Venue, VenueStatus } from "@/lib/types"; + +export const adminApi = { + overview: () => + apiClient.get("/admin/overview").then((r) => r.data), + + pendingVenues: () => + apiClient.get("/admin/venues/pending").then((r) => r.data), + + allVenues: () => apiClient.get("/admin/venues").then((r) => r.data), + + setVenueStatus: (id: number, status: VenueStatus) => + apiClient.patch(`/admin/venues/${id}`, { status }).then((r) => r.data), + + allBookings: () => + apiClient.get("/admin/bookings").then((r) => r.data), +}; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 000000000..07624fc03 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,28 @@ +import { apiClient } from "./client"; +import type { AuthResponse, User, UserRole } from "@/lib/types"; + +export const authApi = { + signup: (data: { + email: string; + name: string; + password: string; + role: UserRole; + }) => + apiClient + .post("/auth/signup", data) + .then((r) => r.data), + + login: (email: string, password: string) => + apiClient + .post("/auth/login", { email, password }) + .then((r) => r.data), + + google: (credential: string) => + apiClient + .post("/auth/google", { credential }) + .then((r) => r.data), + + logout: () => apiClient.post("/auth/logout").then((r) => r.data), + + me: () => apiClient.get("/users/me").then((r) => r.data), +}; diff --git a/frontend/src/api/bookings.ts b/frontend/src/api/bookings.ts new file mode 100644 index 000000000..1574a02f9 --- /dev/null +++ b/frontend/src/api/bookings.ts @@ -0,0 +1,26 @@ +import { apiClient } from "./client"; +import type { Booking, BookingStatus } from "@/lib/types"; + +export interface BookingCreatePayload { + venue_id: number; + start_at: string; + end_at: string; +} + +export const bookingsApi = { + create: (data: BookingCreatePayload) => + apiClient.post("/bookings", data).then((r) => r.data), + + list: () => apiClient.get("/bookings").then((r) => r.data), + + cancel: (id: number) => + apiClient.post(`/bookings/${id}/cancel`).then((r) => r.data), + + ownerList: () => + apiClient.get("/owner/bookings").then((r) => r.data), + + ownerDecide: (id: number, status: BookingStatus) => + apiClient + .patch(`/owner/bookings/${id}`, { status }) + .then((r) => r.data), +}; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 000000000..8270c430b --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,53 @@ +import axios from "axios"; +import { API_BASE_URL } from "@/lib/constants"; + +export const apiClient = axios.create({ + baseURL: API_BASE_URL, + withCredentials: true, + headers: { "Content-Type": "application/json" }, +}); + +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error?.response?.status === 401) { + localStorage.removeItem("bmv_session"); + localStorage.removeItem("bmv_user"); + } + return Promise.reject(error); + }, +); + +export function getStoredUser(): import("@/lib/types").User | null { + const raw = localStorage.getItem("bmv_user"); + if (!raw) return null; + try { + return JSON.parse(raw) as import("@/lib/types").User; + } catch { + return null; + } +} + +export function setSessionUser(user: import("@/lib/types").User): void { + localStorage.setItem("bmv_session", "1"); + localStorage.setItem("bmv_user", JSON.stringify(user)); +} + +export function clearSession(): void { + localStorage.removeItem("bmv_session"); + localStorage.removeItem("bmv_user"); +} + +export function hasSessionFlag(): boolean { + return localStorage.getItem("bmv_session") === "1"; +} + +export function apiErrorMessage(error: unknown): string { + if (axios.isAxiosError(error)) { + return ( + (error.response?.data as { detail?: string } | undefined)?.detail ?? + error.message + ); + } + return "Something went wrong"; +} diff --git a/frontend/src/api/venues.ts b/frontend/src/api/venues.ts new file mode 100644 index 000000000..007f7d86e --- /dev/null +++ b/frontend/src/api/venues.ts @@ -0,0 +1,45 @@ +import { apiClient } from "./client"; +import type { Venue, VenueType } from "@/lib/types"; + +export interface VenueSearchParams { + lat?: number; + lng?: number; + radius_km?: number; + type?: VenueType; + min_price?: number; + max_price?: number; + min_capacity?: number; + q?: string; + skip?: number; + limit?: number; +} + +export interface VenueCreatePayload { + name: string; + type: VenueType; + description?: string; + address: string; + lat?: number; + lng?: number; + price_per_hour: number; + capacity: number; + photos?: string[]; + amenities?: string[]; +} + +export const venuesApi = { + search: (params: VenueSearchParams = {}) => + apiClient.get("/venues", { params }).then((r) => r.data), + + get: (id: number) => + apiClient.get(`/venues/${id}`).then((r) => r.data), + + ownerList: () => + apiClient.get("/owner/venues").then((r) => r.data), + + ownerCreate: (data: VenueCreatePayload) => + apiClient.post("/owner/venues", data).then((r) => r.data), + + ownerUpdate: (id: number, data: Partial) => + apiClient.put(`/owner/venues/${id}`, data).then((r) => r.data), +}; diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 000000000..935bc24ef --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,22 @@ +import { Navigate } from "react-router-dom"; +import type { ReactNode } from "react"; +import { useAuthStore } from "@/stores/authStore"; +import type { UserRole } from "@/lib/types"; + +export function ProtectedRoute({ + children, + roles, +}: { + children: ReactNode; + roles?: UserRole[]; +}) { + const { isAuthenticated, user } = useAuthStore(); + + if (!isAuthenticated) { + return ; + } + if (roles && user && !roles.includes(user.role)) { + return ; + } + return <>{children}; +} diff --git a/frontend/src/components/StatusBadges.tsx b/frontend/src/components/StatusBadges.tsx new file mode 100644 index 000000000..425f689d1 --- /dev/null +++ b/frontend/src/components/StatusBadges.tsx @@ -0,0 +1,24 @@ +import { Badge } from "@/components/ui/Card"; +import type { BookingStatus, VenueStatus } from "@/lib/types"; + +export function BookingStatusBadge({ status }: { status: BookingStatus }) { + const map: Record = { + pending: { color: "yellow", label: "Pending" }, + confirmed: { color: "green", label: "Confirmed" }, + declined: { color: "red", label: "Declined" }, + cancelled: { color: "gray", label: "Cancelled" }, + completed: { color: "green", label: "Completed" }, + }; + const { color, label } = map[status]; + return {label}; +} + +export function VenueStatusBadge({ status }: { status: VenueStatus }) { + const map: Record = { + pending: { color: "yellow", label: "Pending" }, + approved: { color: "green", label: "Approved" }, + rejected: { color: "red", label: "Rejected" }, + }; + const { color, label } = map[status]; + return {label}; +} diff --git a/frontend/src/components/layout/Navbar.tsx b/frontend/src/components/layout/Navbar.tsx new file mode 100644 index 000000000..3bacd72d1 --- /dev/null +++ b/frontend/src/components/layout/Navbar.tsx @@ -0,0 +1,95 @@ +import { Link, NavLink, useNavigate } from "react-router-dom"; +import { useAuthStore } from "@/stores/authStore"; +import { Button } from "@/components/ui/Button"; +import { authApi } from "@/api/auth"; + +export function Navbar() { + const { isAuthenticated, user, logout } = useAuthStore(); + const navigate = useNavigate(); + + const linkClass = ({ isActive }: { isActive: boolean }) => + `text-sm font-medium hover:text-brand-600 ${ + isActive ? "text-brand-600" : "text-gray-600" + }`; + + const handleLogout = async () => { + try { + await authApi.logout(); + } catch { + // ignore network errors; clear local state regardless + } + logout(); + navigate("/"); + }; + + return ( +
+
+ + + B + + + BookMyVenue + + + + + +
+ {isAuthenticated && user ? ( + <> + + {user.name}{" "} + ({user.role}) + + + + ) : ( + <> + + + + + + + + )} +
+
+
+ ); +} + +export function Footer() { + return ( +
+
+ BookMyVenue © {new Date().getFullYear()} · Find & book the perfect nearby space. +
+
+ ); +} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 000000000..ff21afbe1 --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,38 @@ +import { forwardRef } from "react"; +import type { ButtonHTMLAttributes } from "react"; +import { cn } from "@/lib/utils"; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: "primary" | "secondary" | "outline" | "ghost" | "danger"; + size?: "sm" | "md" | "lg"; +} + +const variants: Record, string> = { + primary: "bg-brand-600 text-white hover:bg-brand-700", + secondary: "bg-gray-900 text-white hover:bg-gray-800", + outline: "border border-gray-300 bg-white text-gray-700 hover:border-brand-500 hover:text-brand-600", + ghost: "text-gray-700 hover:bg-gray-100", + danger: "bg-red-600 text-white hover:bg-red-700", +}; + +const sizes: Record, string> = { + sm: "h-8 px-3 text-sm", + md: "h-10 px-4 text-sm", + lg: "h-12 px-6 text-base", +}; + +export const Button = forwardRef( + ({ className, variant = "primary", size = "md", ...props }, ref) => ( +