diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000..b9e0d766a6 Binary files /dev/null and b/.DS_Store differ diff --git a/.env b/.env index 1d44286e25..ffc1ff74b7 100644 --- a/.env +++ b/.env @@ -13,20 +13,20 @@ FRONTEND_HOST=http://localhost:5173 # Environment: local, staging, production ENVIRONMENT=local -PROJECT_NAME="Full Stack FastAPI Project" -STACK_NAME=full-stack-fastapi-project +PROJECT_NAME="VNRunner" +STACK_NAME=vn-runner # Backend BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" -SECRET_KEY=changethis -FIRST_SUPERUSER=admin@example.com -FIRST_SUPERUSER_PASSWORD=changethis +SECRET_KEY=vnrunner-secret-key +FIRST_SUPERUSER=admin@vnrunner.com +FIRST_SUPERUSER_PASSWORD=password # Emails SMTP_HOST= SMTP_USER= SMTP_PASSWORD= -EMAILS_FROM_EMAIL=info@example.com +EMAILS_FROM_EMAIL=info@vnrunner.com SMTP_TLS=True SMTP_SSL=False SMTP_PORT=587 @@ -34,12 +34,21 @@ SMTP_PORT=587 # Postgres POSTGRES_SERVER=localhost POSTGRES_PORT=5432 -POSTGRES_DB=app -POSTGRES_USER=postgres -POSTGRES_PASSWORD=changethis +POSTGRES_DB=vnrunner +POSTGRES_USER=tantran +POSTGRES_PASSWORD=password SENTRY_DSN= +# AI / Embeddings +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +EMBEDDING_MODEL=text-embedding-3-small +EMBEDDING_DIMENSIONS=1536 + +# Redis +REDIS_URL=redis://localhost:6379 + # Configure these with your own Docker registry images DOCKER_IMAGE_BACKEND=backend DOCKER_IMAGE_FRONTEND=frontend diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..c92f70b375 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,479 @@ +# FastAPI Full-Stack Project Instructions + +This workspace uses the [FastAPI Full-Stack Template](https://github.com/fastapi/full-stack-fastapi-template), a production-ready full-stack application with modern Python backend and React frontend, **enhanced with RBAC (Role-Based Access Control) and a separate Next.js runner site**. + +## Technology Stack + +### Backend (Python/FastAPI) +- **FastAPI**: Modern, high-performance Python web framework +- **SQLModel**: SQL database ORM with Pydantic integration +- **PostgreSQL**: Primary database +- **Pydantic**: Data validation and settings management +- **Alembic**: Database migrations +- **JWT**: Token-based authentication +- **RBAC**: Role-based access control with 4 predefined roles +- **Pytest**: Testing framework + +### Frontend - Admin Portal (TypeScript/React) +- **React**: UI framework +- **TypeScript**: Type-safe JavaScript +- **Vite**: Build tool and dev server +- **TanStack Query**: Data fetching and caching +- **TanStack Router**: Type-safe routing +- **Tailwind CSS**: Utility-first CSS framework +- **shadcn/ui**: Component library +- **Playwright**: End-to-end testing +- **Access**: `dashboard.domain.com` (admin and organizers only) + +### Frontend - Runner Site (TypeScript/Next.js) +- **Next.js 15**: React framework with SSR/SSG for SEO +- **TypeScript**: Type-safe JavaScript +- **Tailwind CSS**: Utility-first CSS framework +- **App Router**: Next.js 15 app directory +- **Server Components**: For optimal performance +- **Access**: `domain.com` (public + authenticated runners) + +## Project Structure + +### Backend (`/backend`) +- `app/models.py` - SQLModel database models and Pydantic schemas (includes Role and RBAC) +- `app/crud.py` - CRUD operations including role management +- `app/api/routes/` - API endpoint definitions + - `roles.py` - Role management endpoints (admin-only) + - `users.py` - User management with role assignment + - `items.py` - Example resource endpoints +- `app/api/deps.py` - FastAPI dependencies (auth, database sessions, RBAC) +- `app/core/config.py` - Settings via Pydantic BaseSettings +- `app/core/security.py` - Password hashing and JWT token handling +- `app/core/db.py` - Database engine, session management, and role initialization +- `tests/` - Pytest test suite + +### Frontend - Admin Portal (`/frontend`) +- `src/routes/` - TanStack Router route definitions +- `src/components/` - React components (organized by feature) + - `Admin/` - Admin-specific components + - `Items/` - Resource management + - `UserSettings/` - User profile management +- `src/client/` - Auto-generated API client from OpenAPI spec +- `src/hooks/` - Custom React hooks (useAuth, etc.) +- `tests/` - Playwright end-to-end tests +- **Purpose**: Admin dashboard for system management + +### Frontend - Runner Site (`/runner-site`) +- `app/` - Next.js 15 app directory + - `page.tsx` - Home page (public) + - `login/` - Authentication pages + - `dashboard/` - Runner dashboard (authenticated) +- `lib/` - Utilities and configurations + - `api-client.ts` - API client with authentication + - `auth-context.tsx` - React context for authentication + - `config.ts` - Environment configuration +- **Purpose**: Public-facing site for runners with SEO optimization + +## Key Patterns and Conventions + +### RBAC (Role-Based Access Control) + +The system includes a comprehensive RBAC system with four predefined roles: + +1. **Admin** - Full system access, user and role management +2. **Runner** - Register for races, manage own profile +3. **Organizer** - Create and manage races +4. **Volunteer** - Assist with race operations + +#### Using RBAC in Endpoints + +```python +from app.api.deps import AdminUser, RunnerUser, require_role, require_any_role +from fastapi import Depends + +# Method 1: Predefined dependencies +@router.get("/admin-only") +def admin_endpoint(current_user: AdminUser) -> Any: + """Only admins can access.""" + return {"message": "Admin access"} + +# Method 2: Custom role requirements +@router.get("/staff", dependencies=[Depends(require_any_role(["organizer", "volunteer"]))]) +def staff_endpoint() -> Any: + """Staff members can access.""" + return {"message": "Staff access"} + +# Method 3: Manual checking +from app import crud + +@router.post("/races") +def create_race(current_user: CurrentUser, race_in: RaceCreate) -> Any: + if not crud.user_has_any_role(current_user, ["admin", "organizer"]): + raise HTTPException(status_code=403, detail="Insufficient permissions") + return race +``` + +**Note**: Users with `is_superuser=True` bypass all role checks. + +#### Role Management API + +- `GET /api/v1/roles/` - List all roles (admin-only) +- `POST /api/v1/roles/` - Create role (admin-only) +- `POST /api/v1/roles/{role_id}/assign/{user_id}` - Assign role to user +- `DELETE /api/v1/roles/{role_id}/remove/{user_id}` - Remove role from user + +For complete RBAC documentation, see [RBAC.md](../RBAC.md). + +### Backend Patterns + +#### 1. Model Architecture (SQLModel) +Models follow a specific pattern with separate classes for different purposes: +- `*Base` - Shared properties between multiple models (e.g., `UserBase`) +- `*Create` - Properties received via API on creation (includes password) +- `*Update` - Properties for updates (all fields optional) +- `*Public` - Properties returned via API (excludes sensitive data like hashed_password) +- `*` (table=True) - Actual database table model +- `*sPublic` - List response with data array and count + +Example: +```python +class UserBase(SQLModel): + email: EmailStr = Field(unique=True, index=True) + is_active: bool = True + full_name: str | None = None + +class UserCreate(UserBase): + password: str = Field(min_length=8) + +class UserUpdate(UserBase): + email: EmailStr | None = None + password: str | None = None + +class User(UserBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + hashed_password: str + items: list["Item"] = Relationship(back_populates="owner") + +class UserPublic(UserBase): + id: uuid.UUID + +class UsersPublic(SQLModel): + data: list[UserPublic] + count: int +``` + +#### 2. CRUD Operations +- Defined in `app/crud.py` +- Accept `session: Session` as parameter +- Use SQLModel's `model_validate()` for creating objects +- Use `sqlmodel_update()` for updating objects +- Always commit and refresh after database operations + +```python +def create_user(*, session: Session, user_create: UserCreate) -> User: + db_obj = User.model_validate( + user_create, + update={"hashed_password": get_password_hash(user_create.password)} + ) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj +``` + +#### 3. API Routes +- Organized in `app/api/routes/` by resource (users, items, login) +- Use FastAPI's `APIRouter` with prefix and tags +- Leverage dependency injection for common patterns: + - `SessionDep` - Database session + - `CurrentUser` - Authenticated user + - `get_current_active_superuser` - Admin-only endpoints +- Follow RESTful conventions (GET, POST, PUT/PATCH, DELETE) +- Return appropriate response models + +```python +@router.get("/", response_model=ItemsPublic) +def read_items( + session: SessionDep, + current_user: CurrentUser, + skip: int = 0, + limit: int = 100 +) -> Any: + """Retrieve items.""" + # Superusers see all items, regular users see only their own + if current_user.is_superuser: + statement = select(Item).offset(skip).limit(limit) + else: + statement = select(Item).where(Item.owner_id == current_user.id) + items = session.exec(statement).all() + return ItemsPublic(data=items, count=len(items)) +``` + +#### 4. Dependencies (Dependency Injection) +- Defined in `app/api/deps.py` +- Use type annotations with `Annotated` for reusable dependencies +- Common dependencies: + - `SessionDep` - Database session from connection pool + - `TokenDep` - JWT token from OAuth2 scheme + - `CurrentUser` - Authenticated user from token + - `get_current_active_superuser()` - Admin user verification + +```python +SessionDep = Annotated[Session, Depends(get_db)] +CurrentUser = Annotated[User, Depends(get_current_user)] +``` + +#### 5. Authentication & Security +- JWT token-based authentication +- Passwords hashed with Argon2 +- Token issued via `/api/v1/login/access-token` endpoint +- Protected routes use `CurrentUser` dependency +- Admin-only routes use `Depends(get_current_active_superuser)` +- Timing attack prevention in authentication (dummy hash comparison) + +#### 6. Configuration +- Uses Pydantic `BaseSettings` for environment-based configuration +- Settings loaded from `../.env` file (one level above backend/) +- Database URL computed from individual components +- CORS origins validated and parsed +- Environment-specific behavior (local/staging/production) + +#### 7. Database Queries +- Use SQLModel's `select()` for queries +- Use `session.exec()` to execute statements +- Add `.where()` clauses for filtering +- Use `func.count()` for counting records +- Order with `.order_by()`, paginate with `.offset()` and `.limit()` + +```python +statement = ( + select(Item) + .where(Item.owner_id == user_id) + .order_by(col(Item.created_at).desc()) + .offset(skip) + .limit(limit) +) +items = session.exec(statement).all() +``` + +### Frontend Patterns + +#### 1. API Client +- Auto-generated from OpenAPI spec using `@hey-api/openapi-ts` +- Located in `src/client/` +- Regenerate with: `bash scripts/generate-client.sh` +- Import from `@/client` for type-safe API calls + +#### 2. React Components +- Organized by feature in `src/components/` (Admin, Items, UserSettings, etc.) +- Use shadcn/ui components from `src/components/ui/` +- Functional components with TypeScript +- Use custom hooks from `src/hooks/` + +#### 3. Routing +- TanStack Router for type-safe routing +- Route files in `src/routes/` +- Auto-generated route tree in `routeTree.gen.ts` +- Use layouts (`_layout.tsx`) for shared UI structure + +#### 4. State Management +- TanStack Query for server state (data fetching, caching, mutations) +- React hooks (useState, useContext) for local state +- Custom hooks for reusable logic (useAuth, useCustomToast) + +#### 5. Styling +- Tailwind CSS utility classes +- Theme support via `theme-provider.tsx` +- Dark mode compatible components +- shadcn/ui for pre-built accessible components + +## Development Workflow + +### Local Development + +#### Backend Development +```bash +cd backend +uv sync # Install dependencies +source .venv/bin/activate # Activate virtual environment +fastapi dev app/main.py # Run development server +``` + +#### Frontend Development +```bash +cd frontend +bun install # Install dependencies +bun run dev # Run development server (http://localhost:5173) +``` + +#### Docker Compose (Recommended) +```bash +docker compose watch # Start full stack with hot reload +``` +Access points: +- Frontend: http://localhost:5173 +- Backend API: http://localhost:8000 +- API Docs: http://localhost:8000/docs +- Adminer (DB): http://localhost:8080 +- Mailcatcher: http://localhost:1080 + +### Testing + +#### Backend Tests +```bash +cd backend +bash scripts/test.sh # Run all tests +pytest tests/api/ # Run specific test directory +pytest -k "test_name" # Run specific test +``` + +#### Frontend Tests +```bash +cd frontend +bun run test:e2e # Run Playwright tests +``` + +### Database Migrations + +#### Create Migration +```bash +cd backend +alembic revision --autogenerate -m "Add new field" +``` + +#### Apply Migrations +```bash +alembic upgrade head +``` + +### Code Quality + +#### Backend Linting/Formatting +```bash +cd backend +bash scripts/format.sh # Format code with ruff +bash scripts/lint.sh # Lint code +``` + +#### Frontend Linting/Formatting +```bash +cd frontend +bun run format # Format with Biome +bun run lint # Lint with Biome +``` + +## Common Tasks + +### Adding a New Backend Feature + +1. **Define Models** in `backend/app/models.py`: + - Create Base, Create, Update, Public, and table models + - Add relationships if needed + - Include proper Field validators + +2. **Create CRUD Functions** in `backend/app/crud.py`: + - Add create, read, update, delete functions + - Use session parameter and proper error handling + +3. **Create API Routes** in `backend/app/api/routes/`: + - Create new router file if needed + - Define endpoints with proper HTTP methods + - Use dependencies (SessionDep, CurrentUser) + - Add response models and docstrings + +4. **Register Router** in `backend/app/api/main.py`: + ```python + api_router.include_router(your_router.router) + ``` + +5. **Create Migration**: + ```bash + cd backend + alembic revision --autogenerate -m "Add your_feature" + alembic upgrade head + ``` + +6. **Write Tests** in `backend/tests/`: + - Create test file in appropriate subdirectory + - Use fixtures from conftest.py + - Test CRUD operations and API endpoints + +7. **Regenerate Frontend Client**: + ```bash + bash scripts/generate-client.sh + ``` + +### Adding a New Frontend Feature + +1. **Create Component** in `frontend/src/components/`: + - Use TypeScript for type safety + - Import shadcn/ui components as needed + - Use TanStack Query for data fetching + +2. **Create Route** (if needed) in `frontend/src/routes/`: + - Define route file with proper exports + - Use loader for data fetching + - Handle authentication if required + +3. **Use API Client** from `src/client/`: + - Import generated client functions + - Wrap in TanStack Query hooks + +4. **Write Tests** in `frontend/tests/`: + - Create Playwright test spec + - Use auth setup from `auth.setup.ts` + - Test user interactions and assertions + +## File Organization Best Practices + +### Backend +- Keep models thin - business logic goes in CRUD functions or services +- One router per resource/feature +- Use deps.py for reusable dependencies +- Put utilities in `app/utils.py` +- Configuration only in `core/config.py` + +### Frontend +- Components organized by feature (Admin/, Items/, UserSettings/) +- Shared components in Common/ or ui/ +- One route file per page +- Custom hooks in hooks/ directory +- Utilities in lib/ or utils.ts + +## Security Considerations + +- Never expose `hashed_password` in API responses (use *Public models) +- Always validate user permissions before operations +- Use `CurrentUser` dependency for authenticated endpoints +- Check ownership or superuser status for resource access +- Passwords must be min 8 characters +- JWT tokens expire after 8 days (configurable) +- CORS configured via settings + +## Environment Variables + +Key variables (in `.env` file at project root): +- `PROJECT_NAME` - Application name +- `SECRET_KEY` - JWT signing key +- `POSTGRES_*` - Database connection details +- `FIRST_SUPERUSER*` - Initial admin user +- `SMTP_*` - Email configuration +- `FRONTEND_HOST` - Frontend URL for CORS + +## Common Gotchas + +1. **Database Session**: Always use `SessionDep` dependency, never create sessions manually +2. **Model Validation**: Use `model_validate()` for creating and `sqlmodel_update()` for updating +3. **Commit & Refresh**: Always commit changes and refresh objects to get updated data +4. **Response Models**: Specify `response_model` on all endpoints to ensure proper serialization +5. **UUID Types**: Use `uuid.UUID` type, not strings, for ID fields +6. **Frontend Client**: Regenerate after backend API changes +7. **Pre-commit Hooks**: Install with `pre-commit install` to auto-format on commit + +## Additional Resources + +- Project README: `/README.md` +- Development Guide: `/development.md` +- Contributing: `/CONTRIBUTING.md` +- Backend README: `/backend/README.md` +- Frontend README: `/frontend/README.md` +- FastAPI Docs: https://fastapi.tiangolo.com +- SQLModel Docs: https://sqlmodel.tiangolo.com +- TanStack Query: https://tanstack.com/query +- TanStack Router: https://tanstack.com/router diff --git a/.github/instructions/README.md b/.github/instructions/README.md new file mode 100644 index 0000000000..704664343e --- /dev/null +++ b/.github/instructions/README.md @@ -0,0 +1,95 @@ +# Agent Instructions + +This directory contains specialized instruction files that guide AI agents (like GitHub Copilot) when working with specific parts of the codebase. + +## Instruction Files + +### 1. Backend Python Instructions +**File:** `backend-python.instructions.md` +**Applies to:** `backend/**/*.py` (excluding tests) + +Provides guidance for: +- SQLModel model patterns (Base, Create, Update, Table, Public models) +- CRUD operations with proper session handling +- FastAPI route patterns and dependency injection +- Database queries with SQLModel +- Security best practices +- Import organization + +### 2. Frontend React Instructions +**File:** `frontend-react.instructions.md` +**Applies to:** `frontend/src/**/*.{ts,tsx}` (excluding tests and generated code) + +Provides guidance for: +- React component structure and organization +- TanStack Query for data fetching (queries and mutations) +- TanStack Router for routing +- TypeScript patterns and type safety +- shadcn/ui component usage +- Tailwind CSS styling conventions +- Custom hooks usage +- Form handling and validation + +### 3. Testing Instructions +**File:** `testing.instructions.md` +**Applies to:** `backend/tests/**/*.py` and `frontend/tests/**/*.{ts,spec.ts}` + +Provides guidance for: +- Pytest patterns for backend testing +- Playwright patterns for frontend E2E testing +- Test fixtures and utilities +- API endpoint testing +- CRUD operation testing +- Permission and authentication testing +- Form and UI interaction testing + +### 4. Database Migration Instructions +**File:** `database-migrations.instructions.md` +**Applies to:** `backend/alembic/**/*.py` and `backend/alembic.ini` + +Provides guidance for: +- Creating and applying Alembic migrations +- Common migration patterns (add column, rename, indexes, foreign keys) +- Data migration patterns +- Migration testing and troubleshooting +- Best practices for reversible migrations +- Environment-specific considerations + +## Workspace-Level Instructions + +**File:** `../.github/copilot-instructions.md` + +This file contains general project instructions that apply to the entire workspace, including: +- Project overview and technology stack +- Directory structure +- Development workflows +- Common tasks and patterns +- Security considerations +- Environment setup + +## How These Work + +These instruction files are automatically detected by GitHub Copilot and used to provide context-aware assistance when you're working on files that match the `applyTo` patterns defined in each file's frontmatter. + +For example: +- When editing a file in `backend/app/api/routes/`, the **Backend Python Instructions** will be loaded +- When editing a React component in `frontend/src/components/`, the **Frontend React Instructions** will be loaded +- When editing a test file, the **Testing Instructions** will be loaded + +## Updating Instructions + +If you need to update these instructions: +1. Edit the relevant `.instructions.md` file +2. Keep the YAML frontmatter intact (between the `---` markers) +3. Ensure the `applyTo` patterns are correct +4. Test with Copilot to verify the instructions are being applied + +## Template Source + +This project is based on the [FastAPI Full-Stack Template](https://github.com/fastapi/full-stack-fastapi-template). + +For more information about the template and its conventions, see: +- Project README: `/README.md` +- Development Guide: `/development.md` +- Backend README: `/backend/README.md` +- Frontend README: `/frontend/README.md` diff --git a/.github/instructions/backend-python.instructions.md b/.github/instructions/backend-python.instructions.md new file mode 100644 index 0000000000..a2289a28e8 --- /dev/null +++ b/.github/instructions/backend-python.instructions.md @@ -0,0 +1,298 @@ +--- +description: "Instructions for working with FastAPI backend Python files including models, CRUD operations, API routes, and dependencies." +applyTo: "backend/**/*.py" +--- + +# Backend Python Development Instructions + +## FastAPI Backend Patterns + +### When working with Models (`backend/app/models.py`) + +Always follow the SQLModel pattern with separate classes: + +1. **Base Model** - Shared properties: + ```python + class ResourceBase(SQLModel): + name: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=500) + ``` + +2. **Create Model** - Properties for API creation: + ```python + class ResourceCreate(ResourceBase): + # Add any create-specific fields + pass + ``` + +3. **Update Model** - All fields optional: + ```python + class ResourceUpdate(SQLModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, max_length=500) + ``` + +4. **Table Model** - Database table (use `table=True`): + ```python + class Resource(ResourceBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), + ) + owner_id: uuid.UUID = Field(foreign_key="user.id") + owner: User = Relationship(back_populates="resources") + ``` + +5. **Public Model** - API response (excludes sensitive data): + ```python + class ResourcePublic(ResourceBase): + id: uuid.UUID + created_at: datetime | None = None + ``` + +6. **List Public Model** - Paginated response: + ```python + class ResourcesPublic(SQLModel): + data: list[ResourcePublic] + count: int + ``` + +**Critical Rules:** +- NEVER expose `hashed_password` or sensitive fields in Public models +- Always use `uuid.UUID` type for IDs, not strings +- Use `Field()` for validation (min_length, max_length, unique, index) +- Use `Relationship()` for foreign key relationships +- Include `created_at` timestamp for all table models + +### When working with CRUD (`backend/app/crud.py`) + +CRUD functions should: + +1. **Accept session as parameter**: + ```python + def create_resource(*, session: Session, resource_in: ResourceCreate, owner_id: uuid.UUID) -> Resource: + ``` + +2. **Use `model_validate()` for creation**: + ```python + db_obj = Resource.model_validate( + resource_in, + update={"owner_id": owner_id} + ) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + ``` + +3. **Use `sqlmodel_update()` for updates**: + ```python + def update_resource(*, session: Session, db_obj: Resource, resource_in: ResourceUpdate) -> Resource: + resource_data = resource_in.model_dump(exclude_unset=True) + db_obj.sqlmodel_update(resource_data) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + ``` + +4. **Always commit and refresh** after database operations + +**Critical Rules:** +- Use keyword-only arguments (start with `*`) +- Always get objects before updating them +- Use `exclude_unset=True` to only update provided fields +- Include error handling for database constraints + +### When working with API Routes (`backend/app/api/routes/*.py`) + +1. **Router setup**: + ```python + from fastapi import APIRouter, HTTPException + from app.api.deps import CurrentUser, SessionDep + + router = APIRouter(prefix="/resources", tags=["resources"]) + ``` + +2. **List endpoint**: + ```python + @router.get("/", response_model=ResourcesPublic) + def read_resources( + session: SessionDep, + current_user: CurrentUser, + skip: int = 0, + limit: int = 100 + ) -> Any: + """Retrieve resources.""" + # Superusers see all, users see only their own + if current_user.is_superuser: + statement = select(Resource).offset(skip).limit(limit) + else: + statement = select(Resource).where(Resource.owner_id == current_user.id) + resources = session.exec(statement).all() + count = len(resources) + return ResourcesPublic(data=resources, count=count) + ``` + +3. **Get by ID**: + ```python + @router.get("/{id}", response_model=ResourcePublic) + def read_resource(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: + """Get resource by ID.""" + resource = session.get(Resource, id) + if not resource: + raise HTTPException(status_code=404, detail="Resource not found") + if not current_user.is_superuser and resource.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + return resource + ``` + +4. **Create**: + ```python + @router.post("/", response_model=ResourcePublic) + def create_resource( + *, session: SessionDep, current_user: CurrentUser, resource_in: ResourceCreate + ) -> Any: + """Create new resource.""" + resource = crud.create_resource( + session=session, resource_in=resource_in, owner_id=current_user.id + ) + return resource + ``` + +5. **Update**: + ```python + @router.put("/{id}", response_model=ResourcePublic) + def update_resource( + *, session: SessionDep, current_user: CurrentUser, id: uuid.UUID, resource_in: ResourceUpdate + ) -> Any: + """Update resource.""" + resource = session.get(Resource, id) + if not resource: + raise HTTPException(status_code=404, detail="Resource not found") + if not current_user.is_superuser and resource.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + resource = crud.update_resource(session=session, db_obj=resource, resource_in=resource_in) + return resource + ``` + +6. **Delete**: + ```python + @router.delete("/{id}") + def delete_resource(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Message: + """Delete resource.""" + resource = session.get(Resource, id) + if not resource: + raise HTTPException(status_code=404, detail="Resource not found") + if not current_user.is_superuser and resource.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + session.delete(resource) + session.commit() + return Message(message="Resource deleted successfully") + ``` + +**Critical Rules:** +- Always specify `response_model` on endpoints +- Use `SessionDep` and `CurrentUser` dependencies +- Check permissions (superuser or ownership) +- Return 404 if not found, 403 if no permission +- Use descriptive docstrings +- For admin-only: `dependencies=[Depends(get_current_active_superuser)]` + +### When working with Dependencies (`backend/app/api/deps.py`) + +Use `Annotated` for type-safe dependencies: + +```python +SessionDep = Annotated[Session, Depends(get_db)] +TokenDep = Annotated[str, Depends(reusable_oauth2)] +CurrentUser = Annotated[User, Depends(get_current_user)] +``` + +Custom dependencies should: +- Accept other dependencies as parameters +- Raise HTTPException for errors (404, 403, 401) +- Return the dependency value + +### Database Queries + +Use SQLModel's query patterns: + +```python +# Simple select +statement = select(Resource).where(Resource.owner_id == user_id) +resources = session.exec(statement).all() + +# With ordering and pagination +statement = ( + select(Resource) + .where(Resource.owner_id == user_id) + .order_by(col(Resource.created_at).desc()) + .offset(skip) + .limit(limit) +) + +# Count +count_statement = select(func.count()).select_from(Resource) +count = session.exec(count_statement).one() + +# Get by ID +resource = session.get(Resource, id) +``` + +### Configuration (`backend/app/core/config.py`) + +Settings use Pydantic BaseSettings: +- Read from `../.env` file (one level above backend/) +- Use `computed_field` for derived properties +- Use `model_validator` for complex validation +- No hardcoded secrets + +### Security Best Practices + +1. **Never expose sensitive data** in Public models +2. **Always check permissions** before operations +3. **Use timing-attack prevention** in authentication +4. **Hash passwords** with Argon2 (via `get_password_hash()`) +5. **Validate tokens** with proper error handling +6. **Check ownership** or superuser status for resources + +### Import Organization + +Order imports as: +1. Standard library +2. Third-party (FastAPI, SQLModel, etc.) +3. Local application imports + +```python +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException +from sqlmodel import Session, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import Resource, ResourceCreate, ResourcePublic +``` + +### Error Handling + +Use HTTPException with appropriate status codes: +- 400: Bad Request (validation errors) +- 401: Unauthorized (missing/invalid token) +- 403: Forbidden (insufficient permissions) +- 404: Not Found +- 409: Conflict (duplicate resource) +- 500: Internal Server Error (unexpected errors) + +### When Adding New Models/Routes + +1. Define all model classes in `models.py` +2. Add CRUD functions in `crud.py` +3. Create router file in `api/routes/` +4. Register router in `api/main.py` +5. Create migration: `alembic revision --autogenerate -m "description"` +6. Apply migration: `alembic upgrade head` +7. Write tests in `tests/api/routes/` or `tests/crud/` +8. Regenerate frontend client: `bash scripts/generate-client.sh` diff --git a/.github/instructions/database-migrations.instructions.md b/.github/instructions/database-migrations.instructions.md new file mode 100644 index 0000000000..826a5692a1 --- /dev/null +++ b/.github/instructions/database-migrations.instructions.md @@ -0,0 +1,447 @@ +--- +description: "Instructions for working with database migrations using Alembic in the FastAPI template." +applyTo: "backend/alembic/**/*" +--- + +# Database Migration Instructions + +## Alembic Migrations + +This project uses [Alembic](https://alembic.sqlalchemy.org/) for database migrations with SQLModel. + +### Migration File Location + +``` +backend/ +├── alembic.ini # Alembic configuration +└── app/ + └── alembic/ + ├── env.py # Migration environment setup + ├── script.py.mako # Migration template + └── versions/ # Migration files + ├── xxxx_initial.py + └── yyyy_add_field.py +``` + +## Common Migration Tasks + +### Creating a New Migration + +After modifying models in `backend/app/models.py`: + +```bash +cd backend + +# Activate virtual environment +source .venv/bin/activate + +# Generate migration automatically (recommended) +alembic revision --autogenerate -m "Add user profile fields" + +# Or create empty migration (for data migrations) +alembic revision -m "Migrate user data" +``` + +**What --autogenerate does:** +- Compares database schema with SQLModel models +- Generates migration with `upgrade()` and `downgrade()` functions +- Creates new file in `app/alembic/versions/` + +### Migration File Structure + +```python +"""Add user profile fields + +Revision ID: abc123def456 +Revises: xyz789 +Create Date: 2024-03-28 10:30:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# Revision identifiers +revision = 'abc123def456' +down_revision = 'xyz789' # Previous migration +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Apply changes to database.""" + # Add column + op.add_column('user', sa.Column('bio', sa.String(length=500), nullable=True)) + + # Create index + op.create_index('ix_user_bio', 'user', ['bio']) + + # Add foreign key + op.add_column('item', sa.Column('category_id', sa.UUID(), nullable=True)) + op.create_foreign_key( + 'fk_item_category', + 'item', + 'category', + ['category_id'], + ['id'] + ) + + +def downgrade() -> None: + """Revert changes.""" + op.drop_constraint('fk_item_category', 'item', type_='foreignkey') + op.drop_column('item', 'category_id') + op.drop_index('ix_user_bio', 'user') + op.drop_column('user', 'bio') +``` + +### Applying Migrations + +```bash +# Apply all pending migrations +alembic upgrade head + +# Apply specific number of migrations forward +alembic upgrade +2 + +# Apply to specific revision +alembic upgrade abc123def456 + +# Show current revision +alembic current + +# Show migration history +alembic history + +# Show pending migrations +alembic heads +``` + +### Reverting Migrations + +```bash +# Revert last migration +alembic downgrade -1 + +# Revert all migrations +alembic downgrade base + +# Revert to specific revision +alembic downgrade xyz789 + +# Revert specific number of migrations +alembic downgrade -2 +``` + +## Migration Patterns + +### Adding a Column + +```python +def upgrade() -> None: + op.add_column( + 'table_name', + sa.Column('new_field', sa.String(length=255), nullable=True) + ) + +def downgrade() -> None: + op.drop_column('table_name', 'new_field') +``` + +### Adding a Required Column (with default) + +```python +def upgrade() -> None: + # Step 1: Add column as nullable + op.add_column( + 'table_name', + sa.Column('required_field', sa.String(length=255), nullable=True) + ) + + # Step 2: Set default values for existing rows + op.execute("UPDATE table_name SET required_field = 'default' WHERE required_field IS NULL") + + # Step 3: Make column non-nullable + op.alter_column('table_name', 'required_field', nullable=False) + +def downgrade() -> None: + op.drop_column('table_name', 'required_field') +``` + +### Renaming a Column + +```python +def upgrade() -> None: + op.alter_column('table_name', 'old_name', new_column_name='new_name') + +def downgrade() -> None: + op.alter_column('table_name', 'new_name', new_column_name='old_name') +``` + +### Adding an Index + +```python +def upgrade() -> None: + op.create_index('ix_table_field', 'table_name', ['field_name']) + +def downgrade() -> None: + op.drop_index('ix_table_field', 'table_name') +``` + +### Adding a Foreign Key + +```python +def upgrade() -> None: + op.add_column( + 'child_table', + sa.Column('parent_id', sa.UUID(), nullable=True) + ) + op.create_foreign_key( + 'fk_child_parent', + 'child_table', + 'parent_table', + ['parent_id'], + ['id'], + ondelete='CASCADE' # Optional: cascade delete + ) + +def downgrade() -> None: + op.drop_constraint('fk_child_parent', 'child_table', type_='foreignkey') + op.drop_column('child_table', 'parent_id') +``` + +### Creating a New Table + +```python +def upgrade() -> None: + op.create_table( + 'new_table', + sa.Column('id', sa.UUID(), primary_key=True), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('owner_id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['user.id']), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_new_table_name', 'new_table', ['name']) + +def downgrade() -> None: + op.drop_index('ix_new_table_name', 'new_table') + op.drop_table('new_table') +``` + +### Data Migration + +```python +from alembic import op +from sqlalchemy import text + +def upgrade() -> None: + # Migrate data + op.execute( + text(""" + UPDATE user + SET full_name = CONCAT(first_name, ' ', last_name) + WHERE full_name IS NULL + """) + ) + +def downgrade() -> None: + # Revert data migration + op.execute( + text(""" + UPDATE user + SET full_name = NULL + """) + ) +``` + +### Adding Enum Type + +```python +import enum +from sqlalchemy.dialects import postgresql + +class StatusEnum(enum.Enum): + ACTIVE = "active" + INACTIVE = "inactive" + PENDING = "pending" + +def upgrade() -> None: + # Create enum type (PostgreSQL) + status_enum = postgresql.ENUM('active', 'inactive', 'pending', name='status_enum') + status_enum.create(op.get_bind()) + + # Add column with enum + op.add_column( + 'table_name', + sa.Column('status', sa.Enum(StatusEnum), nullable=False, server_default='pending') + ) + +def downgrade() -> None: + op.drop_column('table_name', 'status') + + # Drop enum type + status_enum = postgresql.ENUM(name='status_enum') + status_enum.drop(op.get_bind()) +``` + +## Best Practices + +### 1. Always Review Auto-generated Migrations + +```bash +# After generating migration +alembic revision --autogenerate -m "description" + +# Check the generated file in app/alembic/versions/ +# Review upgrade() and downgrade() functions +# Make manual adjustments if needed +``` + +**Common issues with --autogenerate:** +- Doesn't detect column renames (sees as drop + add) +- Doesn't detect table renames +- May miss some constraint changes +- Doesn't handle data migrations + +### 2. Make Migrations Reversible + +Always implement `downgrade()` function: + +```python +def upgrade() -> None: + op.add_column('user', sa.Column('bio', sa.String(500))) + +def downgrade() -> None: + op.drop_column('user', 'bio') # Must be reversible! +``` + +### 3. Test Migrations + +```bash +# Test upgrade +alembic upgrade head + +# Test downgrade +alembic downgrade -1 + +# Test upgrade again +alembic upgrade head +``` + +### 4. Use Descriptive Names + +Good: `alembic revision --autogenerate -m "Add user bio and avatar fields"` +Bad: `alembic revision --autogenerate -m "Update user"` + +### 5. Keep Migrations Small + +- One logical change per migration +- Don't mix schema changes with data migrations +- Split large migrations into smaller ones + +### 6. Handle Existing Data + +When adding non-nullable columns: +1. Add as nullable first +2. Populate existing rows +3. Make non-nullable + +### 7. Use Batch Operations for SQLite + +For SQLite compatibility (local dev): + +```python +def upgrade() -> None: + with op.batch_alter_table('table_name') as batch_op: + batch_op.add_column(sa.Column('new_field', sa.String(255))) + batch_op.create_index('ix_table_new_field', ['new_field']) +``` + +## Troubleshooting + +### Migration Conflicts + +If you get "Multiple heads" error: + +```bash +# See all heads +alembic heads + +# Merge migrations +alembic merge heads -m "Merge migrations" +``` + +### Reset Database (Development Only) + +```bash +# WARNING: Destroys all data! +alembic downgrade base +alembic upgrade head +``` + +Or use Docker: + +```bash +docker compose down -v # Removes volumes +docker compose up -d +``` + +### Check Migration SQL + +See SQL without applying: + +```bash +# Show SQL for next migration +alembic upgrade head --sql + +# Show SQL for specific revision +alembic upgrade abc123:def456 --sql +``` + +### Fix Failed Migration + +If migration fails midway: + +```bash +# Check current revision +alembic current + +# Manually fix database or migration file + +# Stamp to specific revision (mark as complete without running) +alembic stamp abc123def456 + +# Continue from there +alembic upgrade head +``` + +## Environment-Specific Migrations + +Migrations run in: +- **Development**: Local PostgreSQL (Docker) +- **Testing**: Test database (automatic in tests) +- **Production**: Production database (CI/CD) + +**Never run migrations manually in production!** Use deployment scripts. + +## Workflow Checklist + +When adding new models/fields: + +- [ ] Update models in `backend/app/models.py` +- [ ] Generate migration: `alembic revision --autogenerate -m "description"` +- [ ] Review generated migration file +- [ ] Test upgrade: `alembic upgrade head` +- [ ] Test downgrade: `alembic downgrade -1` +- [ ] Test upgrade again: `alembic upgrade head` +- [ ] Commit migration file to git +- [ ] Update tests if needed +- [ ] Regenerate frontend client: `bash scripts/generate-client.sh` + +## Additional Resources + +- Alembic Documentation: https://alembic.sqlalchemy.org/ +- SQLModel Documentation: https://sqlmodel.tiangolo.com/ +- Project Guide: `/backend/README.md` diff --git a/.github/instructions/frontend-react.instructions.md b/.github/instructions/frontend-react.instructions.md new file mode 100644 index 0000000000..1adb4ba3aa --- /dev/null +++ b/.github/instructions/frontend-react.instructions.md @@ -0,0 +1,421 @@ +--- +description: "Instructions for working with React/TypeScript frontend files including components, routes, hooks, and API client usage." +applyTo: "frontend/src/**/*.{ts,tsx}" +--- + +# Frontend React/TypeScript Development Instructions + +## React + TypeScript + Vite Patterns + +### Component Structure + +Organize components by feature in `frontend/src/components/`: + +``` +components/ +├── Admin/ # Admin-specific components +├── Items/ # Item management components +├── UserSettings/ # User settings components +├── Common/ # Shared components +├── Sidebar/ # Navigation components +└── ui/ # shadcn/ui components (don't modify) +``` + +### Component Pattern + +Use functional components with TypeScript: + +```tsx +import { useState } from 'react' +import { useQuery, useMutation } from '@tanstack/react-query' +import { Button } from '@/components/ui/button' +import { ResourcesService, type ResourcePublic, type ResourceCreate } from '@/client' +import { useCustomToast } from '@/hooks/useCustomToast' + +interface ResourceListProps { + userId?: string + showAll?: boolean +} + +export function ResourceList({ userId, showAll = false }: ResourceListProps) { + const [isOpen, setIsOpen] = useState(false) + const showToast = useCustomToast() + + // Data fetching with TanStack Query + const { data, isLoading, error } = useQuery({ + queryKey: ['resources', userId], + queryFn: () => ResourcesService.readResources({ skip: 0, limit: 100 }), + }) + + // Mutation for create/update + const createMutation = useMutation({ + mutationFn: (resourceData: ResourceCreate) => + ResourcesService.createResource({ requestBody: resourceData }), + onSuccess: () => { + showToast('Success', 'Resource created successfully', 'success') + queryClient.invalidateQueries({ queryKey: ['resources'] }) + }, + onError: (err) => { + showToast('Error', 'Failed to create resource', 'error') + }, + }) + + if (isLoading) return
Loading...
+ if (error) return
Error loading resources
+ + return ( +
+ {data?.data.map((resource) => ( + + ))} +
+ ) +} +``` + +### Key Patterns + +#### 1. API Client Usage + +Import from auto-generated client (regenerate after backend changes): + +```tsx +import { + ResourcesService, + UsersService, + type ResourcePublic, + type ResourceCreate, + type UserPublic +} from '@/client' +``` + +**Never modify files in `src/client/` - they are auto-generated!** + +#### 2. TanStack Query for Data Fetching + +**Query (GET requests):** +```tsx +const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['resources', filters], // Cache key + queryFn: () => ResourcesService.readResources({ skip: 0, limit: 100 }), + enabled: !!userId, // Only run if condition is true +}) +``` + +**Mutation (POST/PUT/DELETE):** +```tsx +const mutation = useMutation({ + mutationFn: (data: ResourceCreate) => + ResourcesService.createResource({ requestBody: data }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['resources'] }) + showToast('Success', 'Created successfully', 'success') + }, + onError: (error) => { + showToast('Error', error.message, 'error') + }, +}) + +// Use in handler +const handleCreate = async (data: ResourceCreate) => { + mutation.mutate(data) +} +``` + +#### 3. Custom Hooks + +Use hooks from `src/hooks/`: + +```tsx +import { useAuth } from '@/hooks/useAuth' +import { useCustomToast } from '@/hooks/useCustomToast' +import { useMobile } from '@/hooks/useMobile' + +function MyComponent() { + const { user, logout } = useAuth() + const showToast = useCustomToast() + const isMobile = useMobile() + + // Your component logic +} +``` + +#### 4. Form Handling + +Forms should use controlled components: + +```tsx +import { useState } from 'react' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' + +function ResourceForm() { + const [formData, setFormData] = useState({ + title: '', + description: '', + }) + + const handleChange = (e: React.ChangeEvent) => { + setFormData({ ...formData, [e.target.name]: e.target.value }) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + // Submit logic + } + + return ( +
+ + +
+ ) +} +``` + +#### 5. shadcn/ui Components + +Import pre-built components from `@/components/ui/`: + +```tsx +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +``` + +**Do not modify files in `components/ui/` - they are managed by shadcn!** + +#### 6. Styling with Tailwind + +Use Tailwind utility classes: + +```tsx +
+

Title

+ +
+``` + +Common patterns: +- Layout: `flex`, `grid`, `space-y-4`, `gap-4` +- Spacing: `p-4`, `m-2`, `px-6`, `py-3` +- Colors: `bg-primary`, `text-muted-foreground`, `border-border` +- Dark mode: automatic via theme (don't add dark: variants manually) + +### Routing with TanStack Router + +Route files in `frontend/src/routes/`: + +```tsx +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { ResourceList } from '@/components/Resources/ResourceList' + +export const Route = createFileRoute('/resources')({ + component: ResourcesPage, + // Optional: Load data before rendering + loader: async () => { + const resources = await ResourcesService.readResources() + return { resources } + }, +}) + +function ResourcesPage() { + const { resources } = Route.useLoaderData() + const navigate = useNavigate() + + return ( +
+

Resources

+ +
+ ) +} +``` + +**Route structure maps to URLs:** +- `routes/index.tsx` → `/` +- `routes/resources/index.tsx` → `/resources` +- `routes/resources/$id.tsx` → `/resources/:id` +- `routes/_layout.tsx` → Layout wrapper for child routes + +### Authentication + +Use the `useAuth` hook: + +```tsx +import { useAuth } from '@/hooks/useAuth' + +function ProtectedComponent() { + const { user, isLoading, logout } = useAuth() + + if (isLoading) return
Loading...
+ if (!user) return
Please login
+ + return ( +
+

Welcome, {user.full_name}

+ {user.is_superuser && } +
+ ) +} +``` + +### TypeScript Best Practices + +1. **Use generated types** from `@/client`: + ```tsx + import type { ResourcePublic, UserPublic } from '@/client' + ``` + +2. **Define component props**: + ```tsx + interface ComponentProps { + title: string + isActive?: boolean + onSubmit: (data: FormData) => Promise + } + ``` + +3. **Type event handlers**: + ```tsx + const handleClick = (e: React.MouseEvent) => {} + const handleChange = (e: React.ChangeEvent) => {} + const handleSubmit = (e: React.FormEvent) => {} + ``` + +4. **Type state properly**: + ```tsx + const [data, setData] = useState(null) + const [items, setItems] = useState([]) + ``` + +### State Management + +1. **Server State**: Use TanStack Query (for API data) +2. **Component State**: Use `useState` (for UI state) +3. **Shared State**: Use React Context or prop drilling +4. **Form State**: Controlled components with `useState` + +### Error Handling + +Always handle errors from API calls: + +```tsx +const { data, error, isLoading } = useQuery({ + queryKey: ['resource', id], + queryFn: () => ResourcesService.readResource({ id }), +}) + +if (isLoading) return +if (error) { + return +} +if (!data) return null +``` + +### Performance Optimization + +1. **Memoize expensive computations**: + ```tsx + const filteredItems = useMemo( + () => items.filter(item => item.active), + [items] + ) + ``` + +2. **Memoize callbacks**: + ```tsx + const handleClick = useCallback(() => { + doSomething(id) + }, [id]) + ``` + +3. **Use query invalidation** instead of refetching: + ```tsx + queryClient.invalidateQueries({ queryKey: ['resources'] }) + ``` + +### Accessibility + +- Use semantic HTML elements +- Add ARIA labels when needed +- shadcn/ui components have accessibility built-in +- Test keyboard navigation + +### Testing + +Write Playwright tests in `frontend/tests/`: + +```typescript +import { test, expect } from '@playwright/test' + +test('should create resource', async ({ page }) => { + await page.goto('/resources') + await page.click('button:has-text("New Resource")') + await page.fill('input[name="title"]', 'Test Resource') + await page.click('button:has-text("Create")') + await expect(page.locator('text=Test Resource')).toBeVisible() +}) +``` + +### Common Patterns + +#### Loading States +```tsx +{isLoading && } +{data && } +``` + +#### Empty States +```tsx +{data?.data.length === 0 && ( + Create First Resource} + /> +)} +``` + +#### Modals/Dialogs +```tsx + + + + + + + Dialog Title + + {/* Dialog content */} + + +``` + +### When Adding New Features + +1. Check if API client needs regeneration: `bash scripts/generate-client.sh` +2. Create component in appropriate feature folder +3. Use TanStack Query for data fetching +4. Import types from `@/client` +5. Use shadcn/ui components for UI +6. Style with Tailwind classes +7. Handle loading and error states +8. Write Playwright tests +9. Test dark mode compatibility + +### Don't Do This + +❌ Modify files in `src/client/` (auto-generated) +❌ Modify files in `src/components/ui/` (managed by shadcn) +❌ Use inline styles (use Tailwind classes) +❌ Fetch data with fetch/axios (use generated client) +❌ Store server state in useState (use TanStack Query) +❌ Forget error handling +❌ Skip TypeScript types diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md new file mode 100644 index 0000000000..b33f6e4988 --- /dev/null +++ b/.github/instructions/testing.instructions.md @@ -0,0 +1,545 @@ +--- +description: "Instructions for writing and maintaining tests for both backend (Pytest) and frontend (Playwright) in the FastAPI full-stack template." +applyTo: "**/tests/**/*" +--- + +# Testing Instructions + +## Backend Tests (Pytest) + +### Test Organization + +``` +backend/tests/ +├── conftest.py # Pytest fixtures +├── api/ # API endpoint tests +│ └── routes/ +│ ├── test_items.py +│ ├── test_users.py +│ └── test_login.py +├── crud/ # CRUD operation tests +│ └── test_user.py +└── utils/ # Test utilities + ├── user.py + ├── item.py + └── utils.py +``` + +### Fixtures (conftest.py) + +Use shared fixtures from `conftest.py`: + +```python +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session + +def test_example(client: TestClient, db: Session): + # client: TestClient - FastAPI test client + # db: Session - Database session + pass + +def test_with_auth(client: TestClient, superuser_token_headers: dict[str, str]): + # superuser_token_headers: Auth headers for admin + pass + +def test_normal_user(client: TestClient, normal_user_token_headers: dict[str, str]): + # normal_user_token_headers: Auth headers for regular user + pass +``` + +### API Endpoint Tests + +Test API endpoints in `backend/tests/api/routes/`: + +```python +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app import crud +from app.models import ResourceCreate, User +from tests.utils.utils import random_string + + +def test_create_resource( + client: TestClient, + superuser_token_headers: dict[str, str], + db: Session +) -> None: + """Test creating a new resource.""" + data = { + "title": random_string(), + "description": "Test description", + } + response = client.post( + "/api/v1/resources/", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + content = response.json() + assert content["title"] == data["title"] + assert "id" in content + + +def test_read_resource( + client: TestClient, + superuser_token_headers: dict[str, str], + db: Session +) -> None: + """Test reading a resource by ID.""" + # Create test resource + resource = crud.create_resource( + session=db, + resource_in=ResourceCreate(title="Test", description="Test"), + owner_id=superuser.id + ) + + # Read it back + response = client.get( + f"/api/v1/resources/{resource.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert content["id"] == str(resource.id) + + +def test_update_resource( + client: TestClient, + superuser_token_headers: dict[str, str], + db: Session +) -> None: + """Test updating a resource.""" + # Create resource + resource = crud.create_resource(...) + + # Update it + update_data = {"title": "Updated Title"} + response = client.put( + f"/api/v1/resources/{resource.id}", + headers=superuser_token_headers, + json=update_data, + ) + assert response.status_code == 200 + content = response.json() + assert content["title"] == update_data["title"] + + +def test_delete_resource( + client: TestClient, + superuser_token_headers: dict[str, str], + db: Session +) -> None: + """Test deleting a resource.""" + resource = crud.create_resource(...) + + response = client.delete( + f"/api/v1/resources/{resource.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + + # Verify deletion + response = client.get( + f"/api/v1/resources/{resource.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + + +def test_permission_check( + client: TestClient, + normal_user_token_headers: dict[str, str], + db: Session +) -> None: + """Test permission checks - normal user cannot access other's resources.""" + # Create resource owned by someone else + other_resource = crud.create_resource(...) + + # Try to access as normal user + response = client.get( + f"/api/v1/resources/{other_resource.id}", + headers=normal_user_token_headers, + ) + assert response.status_code == 403 +``` + +### CRUD Tests + +Test CRUD functions in `backend/tests/crud/`: + +```python +from sqlmodel import Session + +from app import crud +from app.models import ResourceCreate, ResourceUpdate +from tests.utils.utils import random_string + + +def test_create_resource(db: Session) -> None: + """Test creating a resource via CRUD.""" + resource_in = ResourceCreate( + title=random_string(), + description="Test description", + ) + resource = crud.create_resource( + session=db, + resource_in=resource_in, + owner_id=owner.id, + ) + assert resource.title == resource_in.title + assert resource.owner_id == owner.id + + +def test_update_resource(db: Session) -> None: + """Test updating a resource via CRUD.""" + # Create + resource = crud.create_resource(...) + + # Update + resource_in = ResourceUpdate(title="Updated") + updated = crud.update_resource( + session=db, + db_obj=resource, + resource_in=resource_in, + ) + assert updated.title == "Updated" + assert updated.id == resource.id + + +def test_get_resource_by_id(db: Session) -> None: + """Test getting a resource by ID.""" + resource = crud.create_resource(...) + + fetched = crud.get_resource(session=db, resource_id=resource.id) + assert fetched + assert fetched.id == resource.id +``` + +### Test Utilities + +Use utilities from `tests/utils/`: + +```python +from tests.utils.utils import random_string, random_email + +# Generate random test data +title = random_string() # Random lowercase string +email = random_email() # Random email address +``` + +### Best Practices for Backend Tests + +1. **Use descriptive test names**: `test_create_resource_as_admin()` +2. **Test both success and failure cases**: 404, 403, 400 errors +3. **Use fixtures for setup**: Don't repeat database setup +4. **Clean up in fixtures**: Use conftest.py for cleanup +5. **Test permissions**: Superuser vs regular user access +6. **Use random data**: Avoid hard-coded test data +7. **Assert specific values**: Check IDs, fields, status codes +8. **Test edge cases**: Empty strings, None values, large inputs + +### Running Backend Tests + +```bash +# Run all tests +cd backend +bash scripts/test.sh + +# Run specific test file +pytest tests/api/routes/test_items.py + +# Run specific test +pytest tests/api/routes/test_items.py::test_create_item + +# Run with coverage +pytest --cov=app tests/ + +# Run tests matching pattern +pytest -k "test_create" +``` + +## Frontend Tests (Playwright) + +### Test Organization + +``` +frontend/tests/ +├── auth.setup.ts # Authentication setup +├── config.ts # Test configuration +├── login.spec.ts # Login tests +├── items.spec.ts # Items feature tests +├── user-settings.spec.ts +└── utils/ + └── ... # Test utilities +``` + +### Authentication Setup + +Tests use authenticated sessions from `auth.setup.ts`: + +```typescript +import { test as setup } from '@playwright/test' + +// Setup runs before tests to create authenticated sessions +``` + +### Writing Frontend Tests + +```typescript +import { test, expect } from '@playwright/test' + +test.describe('Resources', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/resources') + }) + + test('should display resources list', async ({ page }) => { + await expect(page.locator('h1')).toContainText('Resources') + await expect(page.locator('[data-testid="resource-card"]')).toBeVisible() + }) + + test('should create new resource', async ({ page }) => { + // Click create button + await page.click('button:has-text("New Resource")') + + // Fill form + await page.fill('input[name="title"]', 'Test Resource') + await page.fill('textarea[name="description"]', 'Test description') + + // Submit + await page.click('button[type="submit"]') + + // Verify success + await expect(page.locator('text=Test Resource')).toBeVisible() + await expect(page.locator('text=created successfully')).toBeVisible() + }) + + test('should edit resource', async ({ page }) => { + // Click first resource + await page.click('[data-testid="resource-card"]:first-child') + + // Click edit button + await page.click('button:has-text("Edit")') + + // Update title + await page.fill('input[name="title"]', 'Updated Title') + await page.click('button:has-text("Save")') + + // Verify update + await expect(page.locator('text=Updated Title')).toBeVisible() + }) + + test('should delete resource', async ({ page }) => { + // Find and click delete button + await page.click('[data-testid="resource-card"]:first-child button:has-text("Delete")') + + // Confirm deletion + await page.click('button:has-text("Confirm")') + + // Verify deletion + await expect(page.locator('text=deleted successfully')).toBeVisible() + }) + + test('should handle validation errors', async ({ page }) => { + await page.click('button:has-text("New Resource")') + + // Try to submit without filling required fields + await page.click('button[type="submit"]') + + // Check for error messages + await expect(page.locator('text=Title is required')).toBeVisible() + }) + + test('should paginate results', async ({ page }) => { + // Assuming pagination exists + await page.click('button:has-text("Next")') + + // Verify URL changed + await expect(page).toHaveURL(/page=2/) + }) +}) +``` + +### Testing Patterns + +#### Form Testing +```typescript +test('should submit form correctly', async ({ page }) => { + await page.goto('/form-page') + + // Fill all fields + await page.fill('input[name="name"]', 'Test Name') + await page.selectOption('select[name="category"]', 'option1') + await page.check('input[type="checkbox"]') + + // Submit and verify + await page.click('button[type="submit"]') + await expect(page.locator('.success-message')).toBeVisible() +}) +``` + +#### Navigation Testing +```typescript +test('should navigate between pages', async ({ page }) => { + await page.goto('/') + + // Click navigation link + await page.click('a[href="/resources"]') + + // Verify navigation + await expect(page).toHaveURL('/resources') + await expect(page.locator('h1')).toContainText('Resources') +}) +``` + +#### Authentication Testing +```typescript +test('should protect authenticated routes', async ({ page }) => { + // Try to access protected page without auth + await page.goto('/admin') + + // Should redirect to login + await expect(page).toHaveURL('/login') +}) + +test('should allow access with authentication', async ({ page }) => { + // Login is handled by auth.setup.ts + await page.goto('/admin') + + // Should access the page + await expect(page.locator('h1')).toContainText('Admin') +}) +``` + +#### API Testing (via UI) +```typescript +test('should handle API errors gracefully', async ({ page }) => { + // Simulate error by providing invalid data + await page.goto('/resources') + await page.click('button:has-text("New Resource")') + + // Invalid data + await page.fill('input[name="title"]', 'x'.repeat(1000)) + await page.click('button[type="submit"]') + + // Should show error message + await expect(page.locator('.error-message')).toBeVisible() +}) +``` + +### Best Practices for Frontend Tests + +1. **Use data-testid attributes**: Add to key elements for stable selectors +2. **Wait for elements**: Use `await expect().toBeVisible()` +3. **Test user journeys**: Not just individual actions +4. **Test error states**: Invalid inputs, API failures +5. **Test accessibility**: Keyboard navigation, screen readers +6. **Use page objects**: For complex pages (optional) +7. **Keep tests independent**: Each test should work in isolation +8. **Mock external APIs**: When needed (via Playwright) + +### Running Frontend Tests + +```bash +cd frontend + +# Run all tests +bun run test:e2e + +# Run in headed mode (see browser) +bun run test:e2e --headed + +# Run specific test file +bun run test:e2e tests/items.spec.ts + +# Run in debug mode +bun run test:e2e --debug + +# Run with UI mode +bun run test:e2e --ui +``` + +## Test Data Best Practices + +### Backend +- Use `random_string()` and `random_email()` from test utils +- Create data in test, clean up in fixtures +- Don't rely on seed data +- Test with realistic data sizes + +### Frontend +- Use consistent test data +- Clean up after tests +- Don't hard-code IDs or timestamps +- Test with various screen sizes + +## Continuous Integration + +Tests run automatically on: +- Pull requests +- Pushes to main branch +- Via GitHub Actions (see `.github/workflows/`) + +Make sure all tests pass before committing! + +## Writing New Tests + +### For New Backend Feature +1. Add CRUD tests in `tests/crud/` +2. Add API tests in `tests/api/routes/` +3. Test permissions (superuser vs regular user) +4. Test error cases (404, 403, 400) +5. Run: `bash scripts/test.sh` + +### For New Frontend Feature +1. Add test file in `tests/` +2. Test main user flows +3. Test form validation +4. Test error states +5. Run: `bun run test:e2e` + +## Common Test Patterns + +### Backend: Test with Different Users +```python +def test_as_superuser(client: TestClient, superuser_token_headers: dict): + # Test admin functionality + pass + +def test_as_normal_user(client: TestClient, normal_user_token_headers: dict): + # Test regular user functionality + pass + +def test_without_auth(client: TestClient): + # Test unauthenticated access (should fail) + response = client.get("/api/v1/protected") + assert response.status_code == 401 +``` + +### Frontend: Test Loading States +```typescript +test('should show loading state', async ({ page }) => { + await page.goto('/resources') + + // Should show loading initially + await expect(page.locator('text=Loading')).toBeVisible() + + // Should show content after loading + await expect(page.locator('[data-testid="resource-card"]')).toBeVisible() +}) +``` + +### Frontend: Test Dark Mode +```typescript +test('should work in dark mode', async ({ page }) => { + await page.goto('/resources') + + // Toggle dark mode + await page.click('button[aria-label="Toggle theme"]') + + // Verify dark mode applied + await expect(page.locator('html')).toHaveClass(/dark/) +}) +``` diff --git a/.gitignore b/.gitignore index f903ab6066..4fb80ce64f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ node_modules/ /playwright-report/ /blob-report/ /playwright/.cache/ +frontend/.tanstack/ +backend/uploads/media/ +.DS_Store diff --git a/CHANGELOG_MULTILANG_ADMIN.md b/CHANGELOG_MULTILANG_ADMIN.md new file mode 100644 index 0000000000..19ca40600f --- /dev/null +++ b/CHANGELOG_MULTILANG_ADMIN.md @@ -0,0 +1,246 @@ +# Multi-Language Implementation - Changelog + +## Date: May 16, 2026 + +## Summary + +Successfully implemented comprehensive multi-language support with Vietnamese as the default language, URL-based language switching, and a complete admin translation interface for managing race content translations. + +## Changes Implemented + +### 1. Vietnamese as Default Language ✅ + +**Backend Changes:** +- Updated `backend/app/i18n.py`: + - Changed `DEFAULT_LANGUAGE = "vi"` (was "en") + - Reordered `SUPPORTED_LANGUAGES = ["vi", "en"]` (Vietnamese first) + +- Updated `backend/app/models.py`: + - Changed `default_language: str = Field(default="vi")` in RaceBase + +**Frontend Changes:** +- Updated `frontend/src/i18n/config.ts`: + - Changed `fallbackLng: "vi"` (was "en") + - Reordered `supportedLngs: ["vi", "en"]` (Vietnamese first) + +- Updated `frontend/src/components/Common/LanguageSwitcher.tsx`: + - Reordered languages array to show Vietnamese first + +### 2. Language Code in URLs ✅ + +**Implementation:** +- Language is now included as a URL search parameter: `?lang=vi` or `?lang=en` +- Created `frontend/src/hooks/useLanguageSync.ts`: + - Automatically syncs URL language parameter with i18next + - Supports `?lang=vi` and `?lang=en` query parameters + - Falls back to localStorage if no URL parameter + +- Updated `frontend/src/components/Common/LanguageSwitcher.tsx`: + - When user switches language, URL updates with `?lang={code}` parameter + - Uses TanStack Router's `navigate()` to update search params + +- Updated `frontend/src/routes/_public.tsx`: + - Added `useLanguageSync()` hook to public layout + - Ensures language from URL is applied on page load + +**How it works:** +1. User clicks Globe icon in header +2. Selects Vietnamese or English +3. URL updates to `?lang=vi` or `?lang=en` +4. Language preference saves to localStorage +5. On page reload, URL parameter takes precedence + +### 3. Admin Translation Interface ✅ + +#### Backend API Endpoints + +**New Translation Models** (`backend/app/models.py`): +- `TranslationContent` - Single language translation content +- `RaceTranslationUpdate` - Update race translations (name, description, location) +- `CategoryTranslationUpdate` - Update category translations (name, description) +- `TagTranslationUpdate` - Update tag translations (name) + +**Race Translation Endpoints** (`backend/app/api/routes/races.py`): +- `PUT /api/v1/races/{race_id}/translations` - Update race translations +- `GET /api/v1/races/{race_id}/translations` - Get all race translations +- Permissions: Race organizer or admin only + +**Category Translation Endpoints** (`backend/app/api/routes/race_categories.py`): +- `PUT /api/v1/race-categories/{category_id}/translations` - Update category translations +- `GET /api/v1/race-categories/{category_id}/translations` - Get all category translations +- Permissions: Race organizer or admin only + +**Tag Translation Endpoints** (`backend/app/api/routes/tags.py`): +- `PUT /api/v1/tags/{tag_id}/translations` - Update tag translations +- `GET /api/v1/tags/{tag_id}/translations` - Get all tag translations +- Permissions: Admin only for updates, public for viewing + +#### Frontend UI Components + +**Created Components:** + +1. **`TranslationEditor.tsx`** - Generic translation editor component + - Tab-based interface (Vietnamese / English tabs) + - Support for input and textarea fields + - Character count for fields with limits + - Save button with loading state + - Reusable across races, categories, and tags + +2. **`RaceTranslationManager.tsx`** - Race-specific translation manager + - Manages race name, description, and location translations + - Fetches current translations from API + - Saves translations for each language separately + - Integrated into race edit page + +3. **`CategoryTranslationManager.tsx`** - Category translation manager + - Manages category name and description translations + - Can be integrated into category management UI + +4. **`TagTranslationManager.tsx`** - Tag translation manager + - Manages tag name translations + - Admin-only access + - Used in dedicated tags admin page + +**Integration Points:** + +1. **Race Edit Page** (`frontend/src/components/Races/EditRace.tsx`): + - Added `` component + - Appears after "Race Categories" section + - Before "Race Media" section + +2. **New Tags Admin Page** (`frontend/src/routes/_layout.admin/tags.tsx`): + - Created dedicated admin page for managing tags + - Lists all tags with translation editors + - Each tag has its own `` component + +**UI Features:** +- ✅ Tab-based language switching (Vietnamese / English) +- ✅ Visual indicator of default language +- ✅ Character count for text fields +- ✅ Loading states during save +- ✅ Success/error toast notifications +- ✅ Auto-invalidates queries on update + +### 4. Translation Files ✅ + +**Updated Translation Keys:** +- Added `"saving": "Đang lưu..."` to Vietnamese (vi.json) +- Added `"saving": "Saving..."` to English (en.json) + +### 5. Documentation Updates ✅ + +**Updated `MULTI_LANGUAGE_IMPLEMENTATION.md`:** +- Changed default language references from English to Vietnamese +- Added section: "API Translation Endpoints" with complete endpoint documentation +- Added section: "Admin Translation Interface" with usage instructions +- Added section: "URL Language Parameter" explaining how it works +- Updated "Supported Languages" to show Vietnamese as default +- Updated all code examples to reflect Vietnamese as primary language + +## File Changes Summary + +### Backend Files Modified: +1. `backend/app/i18n.py` - Default language changed to Vietnamese +2. `backend/app/models.py` - Added translation models, default_language = "vi" +3. `backend/app/api/routes/races.py` - Added translation endpoints +4. `backend/app/api/routes/race_categories.py` - Added translation endpoints +5. `backend/app/api/routes/tags.py` - Added translation endpoints + +### Frontend Files Created: +1. `frontend/src/hooks/useLanguageSync.ts` - URL language sync hook +2. `frontend/src/components/Admin/TranslationEditor.tsx` - Generic editor +3. `frontend/src/components/Admin/RaceTranslationManager.tsx` - Race translations +4. `frontend/src/components/Admin/CategoryTranslationManager.tsx` - Category translations +5. `frontend/src/components/Admin/TagTranslationManager.tsx` - Tag translations +6. `frontend/src/routes/_layout.admin/tags.tsx` - Tags admin page + +### Frontend Files Modified: +1. `frontend/src/i18n/config.ts` - Default language to Vietnamese +2. `frontend/src/i18n/locales/en.json` - Added "saving" key +3. `frontend/src/i18n/locales/vi.json` - Added "saving" key +4. `frontend/src/components/Common/LanguageSwitcher.tsx` - URL parameter support +5. `frontend/src/routes/_public.tsx` - Added language sync hook +6. `frontend/src/components/Races/EditRace.tsx` - Added RaceTranslationManager + +### Documentation Files: +1. `MULTI_LANGUAGE_IMPLEMENTATION.md` - Comprehensive updates + +## Testing Checklist + +### Manual Testing Required: + +- [ ] **Default Language**: Open site, verify Vietnamese is default (not English) +- [ ] **URL Language Switching**: + - [ ] Click Globe icon, select English + - [ ] Verify URL changes to `?lang=en` + - [ ] Verify UI switches to English + - [ ] Refresh page, verify language persists +- [ ] **Admin Translation Interface**: + - [ ] Login as admin + - [ ] Navigate to Admin → Races → Edit Race + - [ ] Find "Race Translations" section + - [ ] Switch between Vietnamese and English tabs + - [ ] Enter translations and save + - [ ] Verify success toast appears +- [ ] **Tag Translation Management**: + - [ ] Navigate to Admin → Tags (new page) + - [ ] Edit tag translations + - [ ] Save and verify +- [ ] **API Endpoints**: + - [ ] Test GET /api/v1/races/{id}/translations + - [ ] Test PUT /api/v1/races/{id}/translations + - [ ] Verify permissions (organizer/admin only) + +## Migration Notes + +**No database migration required** - the translation columns already exist from previous implementation. Only configuration and UI changes were made. + +## Breaking Changes + +⚠️ **Default Language Change**: The default language has changed from English to Vietnamese. This may affect: +- Existing users who had English as default +- SEO if pages were indexed with English content first +- Analytics and user behavior data + +**Mitigation**: Users can still switch to English via the language switcher, and their preference is saved in localStorage. + +## Next Steps (Future Enhancements) + +1. **Path-based Language Routing**: + - Implement `/vi/races` and `/en/races` instead of `?lang=` parameter + - Better for SEO and cleaner URLs + - Requires significant routing refactor + +2. **Bulk Translation Import/Export**: + - CSV import for translating multiple races at once + - JSON export for translation services + - Translation memory/suggestions + +3. **Auto-Translation**: + - Google Translate API integration + - AI-powered translation suggestions + - Translation quality indicators + +4. **Language-Specific Meta Tags**: + - Add `hreflang` tags for SEO + - Language-specific Open Graph tags + - Localized structured data + +5. **Additional Languages**: + - Thai (th) for Thai runners + - Chinese (zh) for Chinese tourists/expats + - Korean (ko) for Korean expats + +## Support + +For questions or issues: +- Check `MULTI_LANGUAGE_IMPLEMENTATION.md` for detailed documentation +- Review API documentation at `/docs` (FastAPI Swagger UI) +- Contact: [Project maintainer] + +--- + +**Implementation Status**: ✅ Complete +**TypeScript Errors**: ✅ None +**Ready for Testing**: ✅ Yes +**Ready for Production**: ⚠️ After manual testing diff --git a/MULTI_LANGUAGE_IMPLEMENTATION.md b/MULTI_LANGUAGE_IMPLEMENTATION.md new file mode 100644 index 0000000000..5c6380f67e --- /dev/null +++ b/MULTI_LANGUAGE_IMPLEMENTATION.md @@ -0,0 +1,571 @@ +# Multi-Language Support Implementation + +## Overview + +VNRunner now supports multiple languages for both the user interface and race content. The platform currently supports Vietnamese (vi) as the default language and English (en) as a secondary language, with the ability to add more languages in the future. + +**Key Features:** +- ✅ Vietnamese (vi) as default language +- ✅ English (en) support +- ✅ Language code in URLs (e.g., `?lang=vi`) +- ✅ Admin interface for managing translations +- ✅ Automatic language detection from browser +- ✅ UI translations (200+ strings) +- ✅ Race content translations (races, categories, tags) + +## Implementation Summary + +### 1. Backend Changes + +#### Database Schema +Added translation support to race-related tables with new columns: + +**Migration**: `88968afdc9ad_add_multi_language_support.py` + +- **`race` table**: + - `translations` (JSON): Stores translations for name, description, location, etc. + - `default_language` (String): Default language for the race (default: "vi") + +- **`racecategory` table**: + - `translations` (JSON): Stores translations for name, description + +- **`racetag` table**: + - `translations` (JSON): Stores translations for name + +**Translation JSON Structure**: +```json +{ + "vi": { + "name": "Giải chạy Hà Nội", + "description": "Giải chạy marathon tại thủ đô Hà Nội..." + }, + "en": { + "name": "Hanoi Marathon", + "description": "Marathon race in Vietnam's capital city..." + } +} +``` + +#### Models (`backend/app/models.py`) + +Updated SQLModel classes to include translation fields: + +```python +class RaceBase(SQLModel): + # ... existing fields ... + default_language: str = Field(default="vi", max_length=10) + translations: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + +class RaceCategoryBase(SQLModel): + # ... existing fields ... + translations: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + +class RaceTagBase(SQLModel): + # ... existing fields ... + translations: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) +``` + +#### I18n Utilities (`backend/app/i18n.py`) + +Created comprehensive helper functions for handling translations: + +**Key Functions**: + +1. **`get_translated_field(obj, field_name, language, fallback_to_default)`** + - Get a translated field value from a database object + - Automatically falls back to default language or object's original field + +2. **`translate_object(obj, fields, language)`** + - Get multiple translated fields as a dictionary + - Useful for serializing objects with translations + +3. **`set_translation(obj, field_name, value, language)`** + - Set a single translated field value + +4. **`merge_translations(obj, translations)`** + - Merge multiple translations at once + - Format: `{"vi": {"name": "...", "description": "..."}, "en": {...}}` + +5. **`is_language_supported(language)`** + - Check if a language code is supported + +**Example Usage**: +```python +from app.i18n import get_translated_field, merge_translations + +# Get translated field +race = Race(name="Hanoi Marathon", translations={"vi": {"name": "Giải chạy Hà Nội"}}) +vietnamese_name = get_translated_field(race, "name", "vi") # Returns "Giải chạy Hà Nội" + +# Set multiple translations +merge_translations(race, { + "vi": {"name": "Giải chạy Hà Nội", "description": "..."}, + "th": {"name": "มาราธอนฮานอย"} +}) +``` + +### 2. Frontend Changes + +#### I18n Setup + +**Installed Packages**: +```json +{ + "i18next": "^23.x", + "react-i18next": "^13.x", + "i18next-browser-languagedetector": "^7.x" +} +``` + +**Configuration** (`frontend/src/i18n/config.ts`): +- Language detection from localStorage, browser navigator, HTML tag +- Caches selected language in localStorage (`i18nextLng` key) +- Fallback language: Vietnamese (vi) +- Supported languages: Vietnamese (vi), English (en) +- Language parameter in URL: `?lang=vi` or `?lang=en` + +**Initialization** (`frontend/src/main.tsx`): +```typescript +import "./i18n/config" // Initialize i18n before app renders +``` + +#### Translation Files + +**English** (`frontend/src/i18n/locales/en.json`): +- Comprehensive translations for all UI elements +- Organized by feature: `common`, `nav`, `home`, `races`, `about`, `footer`, `language` + +**Vietnamese** (`frontend/src/i18n/locales/vi.json`): +- Full Vietnamese translations +- Culturally appropriate translations (e.g., "Giải chạy" instead of literal "cuộc đua") + +**Translation Structure**: +```json +{ + "common": { + "loading": "Loading...", + "search": "Search", + "register": "Register" + }, + "home": { + "hero": { + "title": "Discover Your Next Running Challenge", + "subtitle": "Find and register for running races..." + } + }, + "races": { + "terrain": { + "road": "Road", + "trail": "Trail" + } + } +} +``` + +#### Language Switcher Component + +**File**: `frontend/src/components/Common/LanguageSwitcher.tsx` + +**Features**: +- Dropdown menu with language options +- Shows current language with checkmark +- Displays both native and English names +- Globe icon for easy recognition +- Integrates with react-i18next + +**Usage**: +```tsx +import { LanguageSwitcher } from "@/components/Common/LanguageSwitcher" + + +``` + +**Added to**: +- Public header (desktop and mobile views) +- Accessible in all public pages + +#### Updated Components + +**Public Header** (`frontend/src/components/Public/PublicHeader.tsx`): +- Navigation links use translations: `t("nav.home")`, `t("nav.races")`, `t("nav.about")` +- Auth buttons use translations: `t("common.login")`, `t("common.register")` +- Includes LanguageSwitcher in both desktop and mobile views + +**Homepage** (`frontend/src/routes/_public/index.tsx`): +- Hero section translations: `t("home.hero.title")`, `t("home.hero.subtitle")` +- Features translations: `t("home.features.discover.title")` +- Race rail titles: `t("home.trending")`, `t("home.upcoming")` +- Dynamic translation support throughout + +**Using Translations in Components**: +```tsx +import { useTranslation } from "react-i18next" + +function MyComponent() { + const { t } = useTranslation() + + return ( +
+

{t("common.title")}

+

{t("common.description")}

+
+ ) +} +``` + +## How to Use + +### For Developers + +#### Adding New UI Translations + +1. Add translation keys to both `en.json` and `vi.json`: + ```json + // en.json + { + "myFeature": { + "title": "My Feature", + "description": "Feature description" + } + } + + // vi.json + { + "myFeature": { + "title": "Tính năng của tôi", + "description": "Mô tả tính năng" + } + } + ``` + +2. Use in components: + ```tsx + const { t } = useTranslation() +

{t("myFeature.title")}

+ ``` + +#### Adding Race Content Translations + +**Via API (when creating/updating races)**: +```python +from app.models import RaceCreate, RaceUpdate +from app.i18n import merge_translations + +# Create race with translations +race_create = RaceCreate( + name="Hanoi Marathon", + description="Marathon in Hanoi...", + # ... other fields +) +race = crud.create_race(session, race_create) + +# Add translations +merge_translations(race, { + "vi": { + "name": "Giải chạy Hà Nội", + "description": "Marathon tại Hà Nội...", + "location": "Hà Nội, Việt Nam" + } +}) +session.commit() +``` + +**Via Admin UI** (future enhancement): +- Add translation fields to race creation/edit forms +- Store translations in JSON format + +#### Retrieving Translated Content + +**Backend**: +```python +from app.i18n import get_translated_field + +language = "vi" # From query parameter or user preference +race = crud.get_race(session, race_id) +translated_name = get_translated_field(race, "name", language) +``` + +**Frontend** (future enhancement): +```typescript +// When fetching race data, pass language parameter +const { data: race } = useQuery({ + queryKey: ["race", raceId, language], + queryFn: () => RacesService.readRace({ raceId, lang: language }) +}) +``` + +### For Content Creators + +#### Translating Races + +1. **Name Translation**: + - English: "Hanoi International Marathon" + - Vietnamese: "Giải Marathon Quốc tế Hà Nội" + +2. **Description Translation**: + - Keep descriptions culturally appropriate + - Use Vietnamese running terminology + - Maintain formatting (HTML preserved) + +3. **Location Translation**: + - English: "Ho Chi Minh City, Vietnam" + - Vietnamese: "Thành phố Hồ Chí Minh, Việt Nam" + +#### Best Practices + +1. **Consistency**: Use consistent terminology across translations +2. **Cultural Adaptation**: Adapt content for Vietnamese culture, not literal translation +3. **SEO**: Include Vietnamese keywords for better local search +4. **Completeness**: Translate all fields or leave in default language + +## Language Detection Flow + +1. **First Visit**: + - Checks browser language setting + - If Vietnamese browser → Sets to Vietnamese + - Otherwise → Defaults to English + - Saves preference to localStorage + +2. **Subsequent Visits**: + - Reads from localStorage (`i18nextLng` key) + - Uses saved language preference + +3. **Manual Switch**: + - User selects language from switcher + - Immediately updates UI + - Saves to localStorage + - Persists across sessions + +## API Translation Endpoints + +### Race Translation Management + +**Update Race Translations**: +```http +PUT /api/v1/races/{race_id}/translations +Content-Type: application/json + +{ + "language": "vi", + "name": "Giải chạy Hà Nội", + "description": "Giải chạy marathon tại thủ đô...", + "location": "Hà Nội, Việt Nam" +} +``` + +**Get Race Translations**: +```http +GET /api/v1/races/{race_id}/translations + +Response: +{ + "vi": { + "name": "Giải chạy Hà Nội", + "description": "...", + "location": "Hà Nội, Việt Nam" + }, + "en": { + "name": "Hanoi Marathon", + "description": "...", + "location": "Hanoi, Vietnam" + } +} +``` + +### Category Translation Management + +**Update Category Translations**: +```http +PUT /api/v1/race-categories/{category_id}/translations +Content-Type: application/json + +{ + "language": "vi", + "name": "5K", + "description": "Cự ly 5 kilometer" +} +``` + +**Get Category Translations**: +```http +GET /api/v1/race-categories/{category_id}/translations +``` + +### Tag Translation Management + +**Update Tag Translations** (Admin only): +```http +PUT /api/v1/tags/{tag_id}/translations +Content-Type: application/json + +{ + "language": "vi", + "name": "Đường mòn" +} +``` + +**Get Tag Translations**: +```http +GET /api/v1/tags/{tag_id}/translations +``` + +## Admin Translation Interface + +### Access + +Navigate to the admin panel to manage translations: +- **Race Translations**: Admin → Races → Edit Race → "Race Translations" section +- **Tag Translations**: Admin → Tags + +### Features + +**Translation Editor Component**: +- ✅ Tab-based interface for each language (Vietnamese, English) +- ✅ Side-by-side editing of name, description, location +- ✅ Character count for fields with limits +- ✅ Save button with loading state +- ✅ Automatic detection of default language + +**Race Translation Manager**: +- Edit race name, description, and location in multiple languages +- Integrated into race edit page +- Only race organizer or admin can update + +**Category Translation Manager**: +- Edit category name and description +- Appears in category management section + +**Tag Translation Manager**: +- Edit tag names in multiple languages +- Admin-only access +- Dedicated tags page: `/admin/tags` + +### Usage Example + +1. Navigate to **Admin → Races** +2. Click **Edit** on any race +3. Scroll to **"Race Translations"** section +4. Click **Vietnamese** or **English** tab +5. Enter translated content +6. Click **Save** + +## URL Language Parameter + +The language is now included in public page URLs: +- Homepage: `/?lang=vi` or `/?lang=en` +- Races: `/races?lang=vi` +- Race Detail: `/races/{id}?lang=en` + +**How it works:** +1. User clicks language switcher (Globe icon in header) +2. URL updates with `?lang={code}` parameter +3. Language preference saves to localStorage +4. On page load, URL parameter takes precedence over localStorage + +## Supported Languages + +Currently supported: +- ✅ **Vietnamese (vi)** - Default language +- ✅ **English (en)** - Secondary language + +Future languages (planned): +- 🔲 **Thai (th)** - For Thai runners in Vietnam +- 🔲 **Chinese (zh)** - For Chinese tourists/expats + +## Testing + +### Testing Language Switch + +1. Open the website +2. Click the Globe icon in the header +3. Select "Tiếng Việt" +4. Verify all UI elements change to Vietnamese +5. Navigate between pages - language persists +6. Refresh page - language preference saved +7. Switch back to English - UI updates immediately + +### Testing Translation Fallbacks + +1. Add a race with only English content (no translations) +2. Switch to Vietnamese +3. Verify race shows English content (fallback working) +4. Add Vietnamese translations +5. Verify Vietnamese content displays + +## Files Modified/Created + +### Backend +- ✅ Created: `backend/app/alembic/versions/88968afdc9ad_add_multi_language_support.py` +- ✅ Modified: `backend/app/models.py` +- ✅ Created: `backend/app/i18n.py` + +### Frontend +- ✅ Created: `frontend/src/i18n/config.ts` +- ✅ Created: `frontend/src/i18n/locales/en.json` +- ✅ Created: `frontend/src/i18n/locales/vi.json` +- ✅ Created: `frontend/src/components/Common/LanguageSwitcher.tsx` +- ✅ Modified: `frontend/src/main.tsx` +- ✅ Modified: `frontend/src/components/Public/PublicHeader.tsx` +- ✅ Modified: `frontend/src/routes/_public/index.tsx` +- ✅ Modified: `package.json` (added i18n dependencies) + +## Future Enhancements + +1. **Admin Translation Interface**: + - Add translation tabs in race creation/edit forms + - Visual editor for each language + - Translation progress indicators + +2. **Auto-Translation**: + - Integration with Google Translate API + - Suggest translations for new content + - Review and approve workflow + +3. **Language-Specific SEO**: + - Vietnamese meta tags when language is Vietnamese + - Separate sitemaps per language + - Hreflang tags for international SEO + +4. **User Language Preference**: + - Store language preference in user profile + - Auto-set language on login + - Remember preference across devices + +5. **Missing Translation Warnings**: + - Show indicators for untranslated content + - Admin dashboard for translation coverage + - Translation workflow management + +6. **RTL Language Support**: + - Prepare for Right-to-Left languages + - Adjust layout for Arabic, Hebrew, etc. + +7. **Translation Validation**: + - Check for missing translations + - Validate translation keys in JSON files + - CI/CD integration + +## Troubleshooting + +### Language not changing +- Check browser console for errors +- Verify localStorage has `i18nextLng` key +- Clear localStorage and try again +- Check i18n configuration loaded + +### Missing translations showing as keys +- Check translation key exists in JSON files +- Verify JSON syntax is valid +- Check for typos in translation keys +- Fallback to English should still work + +### Translations not persisting +- Check localStorage is enabled +- Verify i18next-browser-languagedetector is installed +- Check browser privacy settings + +## Conclusion + +VNRunner now has comprehensive multi-language support for both UI elements and race content. The implementation uses industry-standard i18next for the frontend and JSON-based translation storage in PostgreSQL for race content. The system is extensible and ready for additional languages in the future. + +**Language switcher is accessible in the header on all public pages. Users can switch between English and Vietnamese at any time, and their preference is saved automatically.** diff --git a/PUBLIC_ROUTES_IMPLEMENTATION.md b/PUBLIC_ROUTES_IMPLEMENTATION.md new file mode 100644 index 0000000000..eb020d8513 --- /dev/null +++ b/PUBLIC_ROUTES_IMPLEMENTATION.md @@ -0,0 +1,203 @@ +# Public Routes Implementation Guide + +## Overview + +Successfully implemented Option 1: Unified React Application with public and protected routes. The existing React admin portal has been extended to include public pages, eliminating the need for a separate Next.js runner site. + +## What Was Implemented + +### 1. Public Layout System +- **Created**: `frontend/src/routes/_public.tsx` - Layout wrapper for all public pages +- **Components**: + - `PublicHeader.tsx` - Navigation header with login/signup buttons + - `PublicFooter.tsx` - Footer with links and branding + +### 2. Public Pages +All public pages are accessible without authentication: + +- **Home Page** (`/`) + - Hero section with CTAs + - Features showcase + - Call-to-action for registration + +- **Races Page** (`/races`) + - Browse upcoming races + - Placeholder data (ready for API integration) + - Filter section for future enhancements + +- **About Page** (`/about`) + - Platform information + - Mission statement + - Features for runners and organizers + +### 3. Route Structure + +``` +frontend/src/routes/ +├── __root.tsx # Root layout +├── _public.tsx # Public layout (no auth required) +│ ├── index.tsx # Home page (/) +│ ├── about.tsx # About page (/about) +│ └── races.tsx # Races listing (/races) +├── _layout.tsx # Protected layout (requires auth) +│ ├── dashboard.tsx # Dashboard (/dashboard) - NEW! +│ ├── admin.tsx # Admin panel (/admin) +│ ├── items.tsx # Items management (/items) +│ └── settings.tsx # User settings (/settings) +├── login.tsx # Login page (/login) +└── signup.tsx # Signup page (/signup) +``` + +### 4. Authentication Flow + +- **Public visitors**: Can browse `/`, `/races`, `/about` +- **Already logged in**: Header shows "Dashboard" button +- **After login**: Redirects to `/dashboard` instead of `/` +- **Protected routes**: Require authentication, redirect to `/login` if not logged in + +## SEO Support + +All public pages include meta tags via TanStack Router's `head` function: + +```typescript +export const Route = createFileRoute("/_public/")({ + component: HomePage, + head: () => ({ + meta: [ + { + title: "RaceHub - Find and Register for Running Races", + description: "Discover running races near you...", + }, + ], + }), +}) +``` + +## Next Steps + +### 1. Add Race Management API + +Create race endpoints in the backend: + +```bash +# backend/app/models.py - Add Race model +# backend/app/crud.py - Add race CRUD operations +# backend/app/api/routes/races.py - Add race endpoints +``` + +### 2. Connect Races Page to API + +Update `frontend/src/routes/_public/races.tsx` to fetch real data: + +```typescript +import { useQuery } from "@tanstack/react-query" +import { RacesService } from "@/client" + +const { data: races } = useQuery({ + queryKey: ["races"], + queryFn: RacesService.listRaces, +}) +``` + +### 3. Add Race Registration Flow + +- Create race detail page: `/races/$raceId` +- Add registration form for authenticated users +- Handle payment processing + +### 4. Enhance SEO (Optional) + +For critical SEO needs, add prerendering: + +```bash +cd frontend +bun add -D vite-plugin-ssr +``` + +Configure in `vite.config.ts` to prerender static pages. + +### 5. Clean Up Runner Site (Optional) + +The `runner-site/` directory is now obsolete and can be: +- Deleted completely, or +- Kept as backup/reference during transition + +## Testing the Implementation + +1. **Start the dev server**: + ```bash + cd frontend + bun run dev + ``` + +2. **Visit public pages** (no login required): + - http://localhost:5173/ - Home page + - http://localhost:5173/races - Races listing + - http://localhost:5173/about - About page + +3. **Test authentication flow**: + - Click "Login" in header + - Log in with test credentials + - Should redirect to `/dashboard` + - Header should show "Dashboard" button + +4. **Test protected routes**: + - Try accessing `/dashboard`, `/admin`, `/items` without login + - Should redirect to `/login` + +## Benefits Achieved + +✅ **Single Codebase**: No more maintaining two separate frontends +✅ **Shared Infrastructure**: Reuse API client, components, utilities +✅ **Type Safety**: Full TypeScript + TanStack Router type safety +✅ **Consistent UX**: Same UI patterns across all pages +✅ **Auto-generated API Client**: Changes to backend API automatically update frontend +✅ **Modern SEO**: Meta tags support with potential for prerendering +✅ **Faster Development**: One build process, one deployment + +## Updated Documentation + +- ✅ RBAC.md updated to reflect unified architecture +- ✅ Removed references to separate Next.js runner site +- ✅ Updated deployment instructions + +## Files Modified + +### Created: +- `frontend/src/components/Public/PublicHeader.tsx` +- `frontend/src/components/Public/PublicFooter.tsx` +- `frontend/src/routes/_public.tsx` +- `frontend/src/routes/_public/index.tsx` +- `frontend/src/routes/_public/about.tsx` +- `frontend/src/routes/_public/races.tsx` + +### Modified: +- `frontend/src/routes/_layout/index.tsx` → `dashboard.tsx` (moved) +- `frontend/src/hooks/useAuth.ts` (redirect to /dashboard) +- `frontend/src/routes/login.tsx` (redirect to /dashboard) +- `RBAC.md` (updated architecture documentation) + +### Auto-generated: +- `frontend/src/routeTree.gen.ts` (TanStack Router route tree) + +## Troubleshooting + +### "Route not found" errors +- Route tree regenerates automatically when dev server runs +- If issues persist, restart the dev server + +### Type errors on routes +- Make sure dev server has run at least once to generate route tree +- Check `frontend/src/routeTree.gen.ts` exists + +### Styling issues +- All components use Tailwind CSS and shadcn/ui +- Dark mode supported via theme provider + +## Questions? + +Refer to: +- Main README: `/README.md` +- RBAC Documentation: `/RBAC.md` +- Frontend README: `/frontend/README.md` +- TanStack Router Docs: https://tanstack.com/router diff --git a/RBAC.md b/RBAC.md new file mode 100644 index 0000000000..5a07427e0f --- /dev/null +++ b/RBAC.md @@ -0,0 +1,416 @@ +# RBAC (Role-Based Access Control) System + +## Overview + +The system includes a comprehensive RBAC system that allows fine-grained permission control for different user types. The application uses a single React frontend that serves both public pages and role-based authenticated dashboards. + +## Roles + +The system defines four default roles: + +### 1. Admin +- **Purpose**: System administrators with full access +- **Permissions**: Full CRUD access to all resources, user management, role management +- **Access**: Protected dashboard routes with admin-only features + +### 2. Runner +- **Purpose**: Regular users who participate in races +- **Permissions**: Register for races, view own profile and race history, update own information +- **Access**: Public pages + personal dashboard when authenticated + +### 3. Organizer +- **Purpose**: Race organizers who create and manage events +- **Permissions**: Create/edit/delete races, manage race registrations, view participant lists +- **Access**: Protected dashboard with race management features + +### 4. Volunteer +- **Purpose**: Volunteers who help with race operations +- **Permissions**: Check-in runners, view race information, assist with race day operations +- **Access**: Limited dashboard for race day operations + +## Architecture + +### Database Schema + +``` +User (existing table) +├── id: UUID (PK) +├── email: String +├── hashed_password: String +├── is_active: Boolean +├── is_superuser: Boolean +└── roles: Relationship → UserRoleLink + +Role (new table) +├── id: UUID (PK) +├── name: String (unique) +├── description: String +└── users: Relationship → UserRoleLink + +UserRoleLink (new junction table) +├── user_id: UUID (FK → User) +└── role_id: UUID (FK → Role) +``` + +### Backend Implementation + +#### Models (`backend/app/models.py`) + +- **RoleEnum**: Enum defining the four role types +- **Role**: SQLModel table for roles +- **UserRoleLink**: Many-to-many relationship table +- **User**: Extended with `roles` relationship + +#### CRUD Operations (`backend/app/crud.py`) + +```python +# Role management +create_role(session, role_create) +get_role_by_name(session, name) +get_or_create_role(session, role_name, description) + +# User-role management +assign_role_to_user(session, user, role) +remove_role_from_user(session, user, role) +user_has_role(user, role_name) +user_has_any_role(user, role_names) +``` + +#### Dependencies (`backend/app/api/deps.py`) + +##### Permission Checking Functions +```python +# Factory functions for custom role requirements +require_role(required_role) # Single role +require_any_role(required_roles) # Any of multiple roles +``` + +##### Predefined Dependencies +```python +AdminUser # Requires 'admin' role +RunnerUser # Requires 'runner' role +OrganizerUser # Requires 'organizer' role +VolunteerUser # Requires 'volunteer' role +AdminOrOrganizerUser # Requires 'admin' OR 'organizer' +``` + +#### API Routes (`backend/app/api/routes/roles.py`) + +All role management endpoints are admin-only: + +- `GET /api/v1/roles/` - List all roles +- `GET /api/v1/roles/{role_id}` - Get role by ID +- `POST /api/v1/roles/` - Create new role +- `PUT /api/v1/roles/{role_id}` - Update role +- `DELETE /api/v1/roles/{role_id}` - Delete role +- `POST /api/v1/roles/{role_id}/assign/{user_id}` - Assign role to user +- `DELETE /api/v1/roles/{role_id}/remove/{user_id}` - Remove role from user + +## Using RBAC in API Endpoints + +### Method 1: Using Predefined Dependencies + +```python +from app.api.deps import AdminUser, RunnerUser, OrganizerUser + +@router.get("/admin-only") +def admin_endpoint(current_user: AdminUser) -> Any: + """Only admins can access this.""" + return {"message": "Admin access"} + +@router.get("/runner-only") +def runner_endpoint(current_user: RunnerUser) -> Any: + """Only runners can access this.""" + return {"message": "Runner access"} +``` + +### Method 2: Using Custom Role Requirements + +```python +from fastapi import Depends +from app.api.deps import require_role, require_any_role + +@router.get("/organizers", dependencies=[Depends(require_role("organizer"))]) +def organizer_endpoint() -> Any: + """Only organizers can access this.""" + return {"message": "Organizer access"} + +@router.get("/staff", dependencies=[Depends(require_any_role(["organizer", "volunteer"]))]) +def staff_endpoint() -> Any: + """Organizers or volunteers can access this.""" + return {"message": "Staff access"} +``` + +### Method 3: Manual Permission Checking + +```python +from app import crud +from app.api.deps import CurrentUser + +@router.post("/races") +def create_race(current_user: CurrentUser, race_in: RaceCreate) -> Any: + """Create a race - requires organizer or admin role.""" + if not crud.user_has_any_role(current_user, ["admin", "organizer"]): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + # Create race logic here + return race +``` + +## Superuser Override + +**Important**: Users with `is_superuser=True` bypass all role checks. They automatically have access to all endpoints regardless of role requirements. + +## Frontend Integration + +### Unified React Application (`frontend/`) + +The React frontend serves both public and authenticated users: +- **URL**: `domain.com` (production) or `http://localhost:5173` (local) +- **Technology**: React + Vite with TanStack Router & Query +- **Auto-generated API Client**: Type-safe client from OpenAPI spec + +### Route Structure + +#### Public Routes (`_public/`) +- **Access**: All visitors (no authentication required) +- **Layout**: Public header with login/signup, footer with links +- **Routes**: + - `/` - Home page with features and CTAs + - `/races` - Browse and search races + - `/about` - About the platform + - `/login` - Login page + - `/signup` - Registration page + +#### Protected Routes (`_layout/`) +- **Access**: Authenticated users only (redirects to login if not authenticated) +- **Layout**: Dashboard sidebar, user menu +- **Routes by Role**: + - **Admin**: Full access to all features + - User management (`/admin`) + - Role management + - System settings + - **Organizer**: Race management features + - Create/edit races + - Manage registrations + - View participant lists + - **Runner**: Personal dashboard + - Profile management (`/settings`) + - Race history + - Register for races + - **Volunteer**: Race day operations + - Check-in interface + - Race information + +### API Client Usage + +```typescript +import { UsersService, type UserPublic } from "@/client" +import { useQuery } from "@tanstack/react-query" +import useAuth from "@/hooks/useAuth" + +function MyComponent() { + const { user, logout } = useAuth() + + // Check user role + const isAdmin = user?.roles.some(role => role.name === 'admin') + const isRunner = user?.roles.some(role => role.name === 'runner') + + // Use auto-generated API client + const { data } = useQuery({ + queryKey: ["currentUser"], + queryFn: UsersService.readUserMe, + }) +} +``` + +### Role-Based UI Components + +```typescript +import useAuth from "@/hooks/useAuth" + +function RoleBasedComponent() { + const { user } = useAuth() + + const hasRole = (roleName: string) => + user?.roles.some(role => role.name === roleName) || user?.is_superuser + + return ( + <> + {hasRole("admin") && } + {hasRole("organizer") && } + {hasRole("runner") && } + + ) +} +``` + +## Initial Setup + +When the database is initialized (`python app/initial_data.py`): + +1. Four default roles are created +2. First superuser is created from environment variables +3. Admin role is automatically assigned to the superuser + +## Migration + +The RBAC system was added via Alembic migration: +- Migration file: `backend/app/alembic/versions/5280d245c748_add_rbac_with_roles_and_user_role_.py` +- Creates `role` and `userrolelink` tables +- Run: `alembic upgrade head` + +## Environment Variables + +Key variables (in `.env` file at project root): +- `PROJECT_NAME` - Application name +- `SECRET_KEY` - JWT signing key +- `POSTGRES_*` - Database connection details +- `FIRST_SUPERUSER*` - Initial admin user +- `SMTP_*` - Email configuration +- `FRONTEND_HOST` - Frontend URL for CORS + +## Docker Deployment + +The updated `compose.yml` includes two main services: + +1. **frontend**: React application serving both public and authenticated routes +2. **backend**: FastAPI API server + +To deploy: + +```bash +# Development with hot reload +docker compose watch + +# Production +docker compose up -d +``` + +Access points: +- Frontend: `http://localhost:5173` (development) or configured domain (production) +- Backend API: `http://localhost:8000` +- API Docs: `http://localhost:8000/docs` + +## Best Practices + +### 1. Always Use Superuser Override Pattern +```python +# Good +if not current_user.is_superuser and not crud.user_has_role(current_user, "admin"): + raise HTTPException(...) + +# Better (built into dependencies) +AdminUser = Annotated[User, Depends(require_role("admin"))] +``` + +### 2. Assign Default Roles on Registration +```python +# When creating new user +user = crud.create_user(session=session, user_create=user_in, default_role="runner") +``` + +### 3. Check Roles in Frontend +```typescript +// Hide/show UI elements based on role +{user?.roles.some(r => r.name === 'admin') && ( + +)} +``` + +### 4. Use Role-Specific Endpoints +- Create separate endpoint groups for different user types +- Use dependencies to enforce access control +- Return appropriate data based on user role + +## Testing RBAC + +### Create Test Users with Roles + +```python +# In tests +user = crud.create_user(session=db, user_create=user_data, default_role="runner") + +# Assign additional roles +admin_role = crud.get_role_by_name(session=db, name="admin") +crud.assign_role_to_user(session=db, user=user, role=admin_role) +``` + +### Test Permission Checks + +```python +def test_admin_only_endpoint(client, normal_user_token_headers): + """Test that non-admin users cannot access admin endpoints.""" + response = client.get("/api/v1/admin-endpoint", headers=normal_user_token_headers) + assert response.status_code == 403 + +def test_admin_can_access(client, superuser_token_headers): + """Test that admin users can access admin endpoints.""" + response = client.get("/api/v1/admin-endpoint", headers=superuser_token_headers) + assert response.status_code == 200 +``` + +## Future Enhancements + +Possible extensions to the RBAC system: + +1. **Granular Permissions**: Add permission-level control (e.g., `race.create`, `race.edit`) +2. **Role Hierarchy**: Implement role inheritance +3. **Dynamic Roles**: Allow creating custom roles via API +4. **Audit Logging**: Track role assignments and permission changes +5. **Time-Based Roles**: Temporary role assignments with expiration +6. **Resource-Level Permissions**: Per-resource access control (e.g., edit only owned races) + +## Troubleshooting + +### User Can't Access Endpoint +1. Check if user has required role: `SELECT * FROM userrolelink WHERE user_id = 'xxx'` +2. Verify role exists: `SELECT * FROM role WHERE name = 'runner'` +3. Check if `is_superuser` should be true +4. Review endpoint dependencies + +### Role Not Assigned on Registration +1. Verify default role exists in database +2. Check `create_user` function is called with `default_role` parameter +3. Ensure `init_db` has run to create default roles + +### Frontend Not Showing Roles +1. Verify `/api/v1/users/me` returns roles array +2. Check `roles` relationship is loaded in SQLModel query +3. Ensure API client includes roles in User type + +## API Examples + +### Assign Runner Role to New User + +```bash +# Get runner role ID +curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/v1/roles/ + +# Assign role to user +curl -X POST \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + http://localhost:8000/api/v1/roles/{role_id}/assign/{user_id} +``` + +### Check Current User's Roles + +```bash +curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/v1/users/me +``` + +Response includes `roles` array: +```json +{ + "id": "xxx", + "email": "user@example.com", + "full_name": "John Doe", + "roles": [ + { + "id": "yyy", + "name": "runner", + "description": "Regular user who can register for races" + } + ] +} +``` diff --git a/SEO_IMPLEMENTATION.md b/SEO_IMPLEMENTATION.md new file mode 100644 index 0000000000..e27e57c0c2 --- /dev/null +++ b/SEO_IMPLEMENTATION.md @@ -0,0 +1,236 @@ +# SEO and AEO Optimization Implementation + +## Overview +Comprehensive SEO (Search Engine Optimization) and AEO/AIO (AI Engine Optimization) has been implemented across all public pages of VNRunner to improve discoverability by both traditional search engines (Google, Bing) and AI-powered search tools (ChatGPT, Perplexity, Claude, etc.). + +## Implementation Summary + +### 1. SEO Utility Library (`/frontend/src/lib/seo.tsx`) + +Created a comprehensive utility library with the following functions: + +#### Meta Tag Generation +- **`generateMetaTags()`**: Generates comprehensive meta tags including: + - Basic meta tags (title, description, keywords, canonical URL) + - Open Graph tags (og:title, og:description, og:image, og:type, og:url) + - Twitter Card tags (twitter:card, twitter:title, twitter:description, twitter:image) + - Article metadata (published/modified time) + - Robots directives + +#### Structured Data (Schema.org JSON-LD) +- **`generateOrganizationSchema()`**: Organization structured data +- **`generateEventSchema()`**: SportsEvent structured data for races +- **`generateBreadcrumbSchema()`**: Breadcrumb navigation +- **`generateFAQSchema()`**: FAQ page structured data +- **`StructuredData`**: React component to inject JSON-LD scripts + +#### Helper Functions +- **`stripHtml()`**: Remove HTML tags for meta descriptions +- **`truncateText()`**: Truncate text with ellipsis + +### 2. Homepage (`/frontend/src/routes/_public/index.tsx`) + +**Enhanced with:** +- ✅ Comprehensive meta tags with keywords and canonical URL +- ✅ Organization Schema.org structured data +- ✅ Open Graph and Twitter Card tags +- ✅ Semantic HTML with microdata (`itemScope`, `itemType`) +- ✅ Optimized title: "VNRunner - Discover Vietnamese Running Races & Trail Runs | Register Online" +- ✅ Rich description targeting Vietnamese runners +- ✅ Keywords: "Vietnam running races, trail running Vietnam, marathon Vietnam, ultra running, race registration" + +### 3. Races Listing Page (`/frontend/src/routes/_public/races/index.tsx`) + +**Enhanced with:** +- ✅ SEO-optimized meta tags +- ✅ Breadcrumb Schema.org structured data (Home > Races) +- ✅ Semantic HTML (`
`, `
`) +- ✅ Optimized title: "Browse Running Races in Vietnam | VNRunner" +- ✅ Rich description with race types and features +- ✅ Keywords: "Vietnam races, running events Vietnam, trail running, road races, marathon registration" +- ✅ Enhanced heading: "Upcoming Races in Vietnam" + +### 4. Race Detail Page (`/frontend/src/routes/_public/races/$raceId.tsx`) + +**Enhanced with:** +- ✅ Dynamic meta tags based on race data (name, location, date) +- ✅ SportsEvent Schema.org structured data with: + - Event name, description, dates + - Location (name, address, geo coordinates) + - Organizer information + - Ticket/registration offers with pricing + - Availability status +- ✅ Breadcrumb structured data (Home > Races > [Race Name]) +- ✅ Semantic HTML with microdata (`
`) +- ✅ Dynamic title: "{Race Name} - {Event Date} | VNRunner" +- ✅ Auto-generated description from race content +- ✅ Published/modified time metadata +- ✅ Race-specific keywords + +### 5. About Page (`/frontend/src/routes/_public/about.tsx`) + +**Enhanced with:** +- ✅ SEO-optimized meta tags +- ✅ FAQ Schema.org structured data with 4 key Q&As +- ✅ Semantic HTML (`
`) +- ✅ Optimized title: "About VNRunner - Vietnam's Premier Running Race Platform" +- ✅ Rich description of platform features +- ✅ Keywords: "about VNRunner, running platform Vietnam, race registration platform, Vietnamese running community" + +### 6. Robots.txt (`/frontend/public/robots.txt`) + +**Created with:** +- ✅ Allow all search engines to crawl +- ✅ Sitemap reference +- ✅ Disallow admin and authentication pages +- ✅ Explicit allow for public pages + +## SEO Best Practices Implemented + +### 1. Meta Tags +- ✅ Unique, descriptive titles (50-60 characters) +- ✅ Compelling descriptions (150-160 characters) +- ✅ Relevant keywords without stuffing +- ✅ Canonical URLs to prevent duplicates +- ✅ Open Graph for social sharing +- ✅ Twitter Cards for Twitter previews + +### 2. Structured Data (Schema.org) +- ✅ Organization markup for brand identity +- ✅ SportsEvent markup for race pages +- ✅ Breadcrumbs for navigation context +- ✅ FAQ markup for AI-powered searches +- ✅ JSON-LD format (Google recommended) + +### 3. Semantic HTML +- ✅ Proper heading hierarchy (H1 → H2 → H3) +- ✅ Semantic tags (`
`, `
`, `
`) +- ✅ Microdata attributes (`itemScope`, `itemType`, `itemProp`) +- ✅ Descriptive link text +- ✅ Alt text for images (where applicable) + +### 4. Content Optimization +- ✅ Keyword-rich but natural content +- ✅ Location-specific targeting (Vietnam) +- ✅ Action-oriented CTAs +- ✅ Rich text descriptions (HTML preserved) +- ✅ Comprehensive race information + +## AEO/AIO Optimization (AI Search Engines) + +### 1. Structured Data for AI Understanding +- FAQ schema helps AI assistants answer common questions +- Event schema provides structured race information +- Organization schema establishes brand identity +- Breadcrumbs provide navigation context + +### 2. Content Structure for AI +- Clear, semantic HTML helps AI parse content +- Microdata provides explicit context +- Rich descriptions with keywords +- Comprehensive metadata + +### 3. AI-Friendly Features +- Question-answer format in FAQ schema +- Detailed event information (dates, location, pricing) +- Clear categorization (terrain, difficulty) +- Geographic specificity (Vietnam provinces/cities) + +## Search Engine Target Keywords + +### Primary Keywords +- Vietnam running races +- Trail running Vietnam +- Marathon Vietnam +- Ultra marathon Vietnam +- Race registration Vietnam + +### Secondary Keywords +- 5K Vietnam, 10K Vietnam, Half marathon Vietnam +- Road races Vietnam, Trail races Vietnam +- Running events Vietnam +- Vietnamese runners +- Race organizers Vietnam + +### Location-Specific +- Vietnam provinces (Ha Noi, Ho Chi Minh, Da Nang, etc.) +- Terrain types (road, trail, mixed) +- Difficulty levels (easy, moderate, hard, extreme) + +## Expected Impact + +### Traditional Search Engines (Google, Bing) +1. **Better Rankings**: Rich snippets and structured data improve SERP visibility +2. **Higher CTR**: Enhanced titles and descriptions attract more clicks +3. **Local SEO**: Province/city targeting improves local search results +4. **Rich Results**: Event cards, breadcrumbs, organization panels + +### AI Search Engines (ChatGPT, Perplexity, Claude) +1. **Direct Answers**: FAQ schema enables direct question answering +2. **Event Discovery**: Structured event data helps AI recommend races +3. **Context Understanding**: Semantic markup improves content comprehension +4. **Citation Likelihood**: Well-structured content more likely to be cited + +### Social Media +1. **Better Previews**: Open Graph and Twitter Cards create rich previews +2. **Higher Engagement**: Attractive cards increase click-through rates +3. **Brand Recognition**: Consistent metadata across platforms + +## Monitoring & Maintenance + +### Recommended Tools +- **Google Search Console**: Monitor search performance, indexing, errors +- **Google Rich Results Test**: Validate structured data +- **Schema.org Validator**: Test JSON-LD markup +- **Lighthouse**: Audit SEO, accessibility, performance + +### Ongoing Tasks +- [ ] Generate XML sitemap (`sitemap.xml`) +- [ ] Monitor keyword rankings +- [ ] Track organic traffic growth +- [ ] Update meta descriptions quarterly +- [ ] Add race images for og:image tags +- [ ] Test rich results appearance +- [ ] Monitor AI chatbot citations + +## Files Modified/Created + +### Created +- `/frontend/src/lib/seo.tsx` - SEO utility library +- `/frontend/public/robots.txt` - Search engine directives + +### Modified +- `/frontend/src/routes/_public/index.tsx` - Homepage SEO +- `/frontend/src/routes/_public/races/index.tsx` - Races listing SEO +- `/frontend/src/routes/_public/races/$raceId.tsx` - Race detail SEO +- `/frontend/src/routes/_public/about.tsx` - About page SEO + +## Next Steps (Future Enhancements) + +1. **Sitemap Generation**: Auto-generate XML sitemap with all races +2. **Image Optimization**: Add og:image for race pages (race photos) +3. **Additional Schema**: Review schema for organizers, user profiles +4. **Performance**: Optimize Core Web Vitals (LCP, FID, CLS) +5. **International SEO**: Multi-language support (Vietnamese, English) +6. **Analytics Integration**: Google Analytics 4, conversion tracking +7. **A/B Testing**: Test different titles/descriptions +8. **User Reviews**: Add review schema for race ratings +9. **Video Content**: Add VideoObject schema for race videos +10. **AMP Pages**: Consider AMP for mobile performance + +## Testing Checklist + +- [ ] Test all meta tags render correctly +- [ ] Validate JSON-LD with Google Rich Results Test +- [ ] Check Open Graph previews (Facebook Sharing Debugger) +- [ ] Test Twitter Card previews (Twitter Card Validator) +- [ ] Run Lighthouse SEO audit (target 95+) +- [ ] Verify robots.txt is accessible +- [ ] Check canonical URLs are correct +- [ ] Ensure no duplicate meta tags +- [ ] Test breadcrumbs in search results +- [ ] Verify event rich snippets appear + +## Conclusion + +The SEO and AEO optimization is comprehensive and follows industry best practices. The implementation targets both traditional search engines and AI-powered search tools, ensuring VNRunner is discoverable across all platforms. The semantic HTML, structured data, and rich metadata provide a strong foundation for organic growth. diff --git a/backend/app/alembic/versions/064eff5bcff1_add_country_code_province_name_and_ward_.py b/backend/app/alembic/versions/064eff5bcff1_add_country_code_province_name_and_ward_.py new file mode 100644 index 0000000000..cc8b9814c7 --- /dev/null +++ b/backend/app/alembic/versions/064eff5bcff1_add_country_code_province_name_and_ward_.py @@ -0,0 +1,33 @@ +"""Add country_code province_name and ward_name to race table + +Revision ID: 064eff5bcff1 +Revises: fbc16b106df0 +Create Date: 2026-05-16 22:08:49.118894 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '064eff5bcff1' +down_revision = 'fbc16b106df0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('race', sa.Column('country_code', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=True)) + op.add_column('race', sa.Column('province_name', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True)) + op.add_column('race', sa.Column('ward_name', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('race', 'ward_name') + op.drop_column('race', 'province_name') + op.drop_column('race', 'country_code') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/15962f3dcaee_add_race_management_schema.py b/backend/app/alembic/versions/15962f3dcaee_add_race_management_schema.py new file mode 100644 index 0000000000..9a39282786 --- /dev/null +++ b/backend/app/alembic/versions/15962f3dcaee_add_race_management_schema.py @@ -0,0 +1,162 @@ +"""Add race management schema + +Revision ID: 15962f3dcaee +Revises: 5280d245c748 +Create Date: 2026-03-28 23:41:03.309759 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '15962f3dcaee' +down_revision = '5280d245c748' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('race', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=True), + sa.Column('event_start_date', sa.DateTime(timezone=True), nullable=True), + sa.Column('event_end_date', sa.DateTime(timezone=True), nullable=True), + sa.Column('location', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('city', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True), + sa.Column('state', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True), + sa.Column('country', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column('registration_start', sa.DateTime(timezone=True), nullable=True), + sa.Column('registration_end', sa.DateTime(timezone=True), nullable=True), + sa.Column('status', sa.Enum('DRAFT', 'PUBLISHED', 'REGISTRATION_OPEN', 'REGISTRATION_CLOSED', 'COMPLETED', 'CANCELLED', name='racestatusenum'), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('base_price', sa.Float(), nullable=True), + sa.Column('currency', sqlmodel.sql.sqltypes.AutoString(length=3), nullable=False), + sa.Column('race_metadata', sa.JSON(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('organizer_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['organizer_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_race_name'), 'race', ['name'], unique=False) + op.create_table('raceattribute', + sa.Column('key', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column('value_text', sa.Text(), nullable=True), + sa.Column('attribute_type', sa.Enum('STRING', 'TEXT', 'URL', 'DATE', 'DATETIME', 'NUMBER', 'BOOLEAN', 'EMAIL', 'PHONE', name='attributetypeenum'), nullable=False), + sa.Column('label', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column('is_required', sa.Boolean(), nullable=False), + sa.Column('is_public', sa.Boolean(), nullable=False), + sa.Column('display_order', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('race_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['race_id'], ['race.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_raceattribute_key'), 'raceattribute', ['key'], unique=False) + op.create_table('racecategory', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column('distance_km', sa.Float(), nullable=False), + sa.Column('distance_unit', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=False), + sa.Column('start_time', sa.DateTime(timezone=True), nullable=True), + sa.Column('end_time', sa.DateTime(timezone=True), nullable=True), + sa.Column('cutoff_time_minutes', sa.Integer(), nullable=True), + sa.Column('registration_start', sa.DateTime(timezone=True), nullable=True), + sa.Column('registration_end', sa.DateTime(timezone=True), nullable=True), + sa.Column('price', sa.Float(), nullable=True), + sa.Column('early_bird_price', sa.Float(), nullable=True), + sa.Column('early_bird_deadline', sa.DateTime(timezone=True), nullable=True), + sa.Column('max_participants', sa.Integer(), nullable=True), + sa.Column('min_age', sa.Integer(), nullable=True), + sa.Column('max_age', sa.Integer(), nullable=True), + sa.Column('gender_restriction', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=True), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column('display_order', sa.Integer(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('race_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['race_id'], ['race.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('racecheckpoint', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column('distance_km', sa.Float(), nullable=False), + sa.Column('sequence', sa.Integer(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('race_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['race_id'], ['race.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('raceregistration', + sa.Column('bib_number', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True), + sa.Column('emergency_contact', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('emergency_phone', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True), + sa.Column('tshirt_size', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=True), + sa.Column('special_requirements', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column('registration_status', sa.Enum('PENDING', 'CONFIRMED', 'CANCELLED', 'WAITLIST', name='registrationstatusenum'), nullable=False), + sa.Column('payment_status', sa.Enum('UNPAID', 'PAID', 'REFUNDED', 'PARTIAL', name='paymentstatusenum'), nullable=False), + sa.Column('amount_paid', sa.Float(), nullable=True), + sa.Column('payment_reference', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('registration_data', sa.JSON(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('race_id', sa.Uuid(), nullable=False), + sa.Column('category_id', sa.Uuid(), nullable=False), + sa.Column('runner_id', sa.Uuid(), nullable=False), + sa.Column('registered_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['category_id'], ['racecategory.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['race_id'], ['race.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['runner_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('raceresult', + sa.Column('finish_time_seconds', sa.Integer(), nullable=True), + sa.Column('overall_position', sa.Integer(), nullable=True), + sa.Column('category_position', sa.Integer(), nullable=True), + sa.Column('gender_position', sa.Integer(), nullable=True), + sa.Column('status', sa.Enum('FINISHED', 'DNF', 'DNS', 'DQ', name='resultstatusenum'), nullable=False), + sa.Column('pace_per_km_seconds', sa.Float(), nullable=True), + sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('registration_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['registration_id'], ['raceregistration.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('registration_id') + ) + op.create_table('racesplittime', + sa.Column('time_seconds', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('registration_id', sa.Uuid(), nullable=False), + sa.Column('checkpoint_id', sa.Uuid(), nullable=False), + sa.Column('recorded_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['checkpoint_id'], ['racecheckpoint.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['registration_id'], ['raceregistration.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('racesplittime') + op.drop_table('raceresult') + op.drop_table('raceregistration') + op.drop_table('racecheckpoint') + op.drop_table('racecategory') + op.drop_index(op.f('ix_raceattribute_key'), table_name='raceattribute') + op.drop_table('raceattribute') + op.drop_index(op.f('ix_race_name'), table_name='race') + op.drop_table('race') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/2a7b0f12d4ef_add_media_assets_table.py b/backend/app/alembic/versions/2a7b0f12d4ef_add_media_assets_table.py new file mode 100644 index 0000000000..539d57edaf --- /dev/null +++ b/backend/app/alembic/versions/2a7b0f12d4ef_add_media_assets_table.py @@ -0,0 +1,53 @@ +"""Add media assets table + +Revision ID: 2a7b0f12d4ef +Revises: 15962f3dcaee +Create Date: 2026-03-29 11:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = "2a7b0f12d4ef" +down_revision = "15962f3dcaee" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "mediaasset", + sa.Column("content_type", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column("content_id", sa.Uuid(), nullable=False), + sa.Column("kind", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), + sa.Column("alt_text", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column("display_order", sa.Integer(), nullable=False), + sa.Column("is_primary", sa.Boolean(), nullable=False), + sa.Column("is_public", sa.Boolean(), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("original_filename", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column("file_name", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column("file_path", sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=False), + sa.Column("file_url", sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=False), + sa.Column("mime_type", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("uploaded_by_id", sa.Uuid(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["uploaded_by_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_mediaasset_content_type"), "mediaasset", ["content_type"], unique=False) + op.create_index(op.f("ix_mediaasset_content_id"), "mediaasset", ["content_id"], unique=False) + op.create_index(op.f("ix_mediaasset_kind"), "mediaasset", ["kind"], unique=False) + + +def downgrade(): + op.drop_index(op.f("ix_mediaasset_kind"), table_name="mediaasset") + op.drop_index(op.f("ix_mediaasset_content_id"), table_name="mediaasset") + op.drop_index(op.f("ix_mediaasset_content_type"), table_name="mediaasset") + op.drop_table("mediaasset") diff --git a/backend/app/alembic/versions/5280d245c748_add_rbac_with_roles_and_user_role_.py b/backend/app/alembic/versions/5280d245c748_add_rbac_with_roles_and_user_role_.py new file mode 100644 index 0000000000..cf6835d5d5 --- /dev/null +++ b/backend/app/alembic/versions/5280d245c748_add_rbac_with_roles_and_user_role_.py @@ -0,0 +1,45 @@ +"""Add RBAC with roles and user-role relationship + +Revision ID: 5280d245c748 +Revises: fe56fa70289e +Create Date: 2026-03-28 22:01:13.342027 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '5280d245c748' +down_revision = 'fe56fa70289e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('role', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_role_name'), 'role', ['name'], unique=True) + op.create_table('userrolelink', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('role_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['role_id'], ['role.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'role_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('userrolelink') + op.drop_index(op.f('ix_role_name'), table_name='role') + op.drop_table('role') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/88968afdc9ad_add_multi_language_support.py b/backend/app/alembic/versions/88968afdc9ad_add_multi_language_support.py new file mode 100644 index 0000000000..83f43008fe --- /dev/null +++ b/backend/app/alembic/versions/88968afdc9ad_add_multi_language_support.py @@ -0,0 +1,43 @@ +"""add_multi_language_support + +Revision ID: 88968afdc9ad +Revises: 064eff5bcff1 +Create Date: 2026-05-16 23:18:20.192695 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects.postgresql import JSON + + +# revision identifiers, used by Alembic. +revision = '88968afdc9ad' +down_revision = '064eff5bcff1' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add translations column to race table + # Stores translations as JSON: {"vi": {"name": "...", "description": "..."}, "en": {...}} + op.add_column('race', sa.Column('translations', JSON, nullable=True)) + + # Add translations column to racecategory table + # Stores translations as JSON: {"vi": {"name": "...", "description": "..."}, "en": {...}} + op.add_column('racecategory', sa.Column('translations', JSON, nullable=True)) + + # Add translations column to racetag table + # Stores translations as JSON: {"vi": {"name": "..."}, "en": {...}} + op.add_column('racetag', sa.Column('translations', JSON, nullable=True)) + + # Add default_language column to race table + op.add_column('race', sa.Column('default_language', sa.String(length=10), nullable=False, server_default='en')) + + +def downgrade(): + op.drop_column('race', 'default_language') + op.drop_column('racetag', 'translations') + op.drop_column('racecategory', 'translations') + op.drop_column('race', 'translations') + diff --git a/backend/app/alembic/versions/a1b2c3d4e5f6_phase1_race_discovery_models.py b/backend/app/alembic/versions/a1b2c3d4e5f6_phase1_race_discovery_models.py new file mode 100644 index 0000000000..288185e9a3 --- /dev/null +++ b/backend/app/alembic/versions/a1b2c3d4e5f6_phase1_race_discovery_models.py @@ -0,0 +1,210 @@ +"""Phase 1: race discovery models - geo fields, tags, user profile, interactions + +Revision ID: a1b2c3d4e5f6 +Revises: 2a7b0f12d4ef +Create Date: 2026-04-21 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects.postgresql import ENUM as PgEnum + +# revision identifiers, used by Alembic. +revision = "a1b2c3d4e5f6" +down_revision = "2a7b0f12d4ef" +branch_labels = None +depends_on = None + + +def _create_enum_if_not_exists(name: str, *values: str) -> None: + """Create a PostgreSQL enum type if it doesn't already exist.""" + vals = ", ".join(f"'{v}'" for v in values) + op.execute( + f""" + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = '{name}') THEN + CREATE TYPE {name} AS ENUM ({vals}); + END IF; + END + $$; + """ + ) + + +# Column-level type objects with create_type=False so SQLAlchemy never tries +# to emit CREATE TYPE statements automatically. +_terrain = PgEnum("road", "trail", "track", "mixed", name="terrainenum", create_type=False) +_difficulty = PgEnum("easy", "moderate", "hard", "extreme", name="difficultyenum", create_type=False) +_fitness = PgEnum("beginner", "intermediate", "advanced", "elite", name="fitnessenum", create_type=False) +_distpref = PgEnum("short", "mid", "long", "ultra", name="distanceprefenum", create_type=False) +_interaction = PgEnum( + "viewed", "saved", "unsaved", "registered", "shared", + name="interactiontypeenum", create_type=False, +) + + +def upgrade() -> None: + # ------------------------------------------------------------------ + # Create enum types idempotently via DO block + # ------------------------------------------------------------------ + _create_enum_if_not_exists("terrainenum", "road", "trail", "track", "mixed") + _create_enum_if_not_exists("difficultyenum", "easy", "moderate", "hard", "extreme") + _create_enum_if_not_exists("fitnessenum", "beginner", "intermediate", "advanced", "elite") + _create_enum_if_not_exists("distanceprefenum", "short", "mid", "long", "ultra") + _create_enum_if_not_exists("interactiontypeenum", "viewed", "saved", "unsaved", "registered", "shared") + + # ------------------------------------------------------------------ + # race table — add geo and course-characteristic columns + # ------------------------------------------------------------------ + op.add_column("race", sa.Column("latitude", sa.Float(), nullable=True)) + op.add_column("race", sa.Column("longitude", sa.Float(), nullable=True)) + op.add_column("race", sa.Column("terrain_type", _terrain, nullable=True)) + op.add_column("race", sa.Column("difficulty_level", _difficulty, nullable=True)) + op.add_column("race", sa.Column("elevation_gain_m", sa.Integer(), nullable=True)) + op.add_column( + "race", + sa.Column( + "is_certified", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) + op.add_column( + "race", + sa.Column( + "gpx_file_url", + sqlmodel.sql.sqltypes.AutoString(length=1000), + nullable=True, + ), + ) + op.add_column( + "race", + sa.Column( + "website_url", + sqlmodel.sql.sqltypes.AutoString(length=1000), + nullable=True, + ), + ) + + # ------------------------------------------------------------------ + # racetag table + # ------------------------------------------------------------------ + op.create_table( + "racetag", + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), + sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + sa.UniqueConstraint("slug"), + ) + op.create_index("ix_racetag_name", "racetag", ["name"], unique=True) + op.create_index("ix_racetag_slug", "racetag", ["slug"], unique=True) + + # ------------------------------------------------------------------ + # racetaglink junction table + # ------------------------------------------------------------------ + op.create_table( + "racetaglink", + sa.Column("race_id", sa.Uuid(), nullable=False), + sa.Column("tag_id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(["race_id"], ["race.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tag_id"], ["racetag.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("race_id", "tag_id"), + ) + + # ------------------------------------------------------------------ + # userprofile table + # ------------------------------------------------------------------ + op.create_table( + "userprofile", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("fitness_level", _fitness, nullable=True), + sa.Column("distance_preference", _distpref, nullable=True), + sa.Column("terrain_preference", _terrain, nullable=True), + sa.Column("home_latitude", sa.Float(), nullable=True), + sa.Column("home_longitude", sa.Float(), nullable=True), + sa.Column( + "home_city", + sqlmodel.sql.sqltypes.AutoString(length=100), + nullable=True, + ), + sa.Column("weekly_mileage_km", sa.Float(), nullable=True), + sa.Column("goal_race_date", sa.Date(), nullable=True), + sa.Column("bio", sa.Text(), nullable=True), + sa.Column( + "is_onboarded", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id"), + ) + op.create_index("ix_userprofile_user_id", "userprofile", ["user_id"], unique=True) + + # ------------------------------------------------------------------ + # userraceinteraction table + # ------------------------------------------------------------------ + op.create_table( + "userraceinteraction", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("race_id", sa.Uuid(), nullable=False), + sa.Column("action", _interaction, nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint(["race_id"], ["race.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_userraceinteraction_user_id", "userraceinteraction", ["user_id"] + ) + op.create_index( + "ix_userraceinteraction_race_id", "userraceinteraction", ["race_id"] + ) + + +def downgrade() -> None: + op.drop_table("userraceinteraction") + op.drop_table("userprofile") + op.drop_table("racetaglink") + op.drop_table("racetag") + + op.drop_column("race", "website_url") + op.drop_column("race", "gpx_file_url") + op.drop_column("race", "is_certified") + op.drop_column("race", "elevation_gain_m") + op.drop_column("race", "difficulty_level") + op.drop_column("race", "terrain_type") + op.drop_column("race", "longitude") + op.drop_column("race", "latitude") + + op.execute("DROP TYPE IF EXISTS interactiontypeenum") + op.execute("DROP TYPE IF EXISTS distanceprefenum") + op.execute("DROP TYPE IF EXISTS fitnessenum") + op.execute("DROP TYPE IF EXISTS difficultyenum") + op.execute("DROP TYPE IF EXISTS terrainenum") diff --git a/backend/app/alembic/versions/b2c3d4e5f6a7_phase2_race_fts_search_vector.py b/backend/app/alembic/versions/b2c3d4e5f6a7_phase2_race_fts_search_vector.py new file mode 100644 index 0000000000..99b9f846d7 --- /dev/null +++ b/backend/app/alembic/versions/b2c3d4e5f6a7_phase2_race_fts_search_vector.py @@ -0,0 +1,41 @@ +"""Phase 2: add full-text search vector column and GIN index to race table + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-04-21 01:00:00.000000 + +""" + +from alembic import op + +revision = "b2c3d4e5f6a7" +down_revision = "a1b2c3d4e5f6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Generated stored tsvector column combining searchable text fields. + # The GIN index makes full-text queries fast even on large tables. + op.execute(""" + ALTER TABLE race + ADD COLUMN search_vector tsvector + GENERATED ALWAYS AS ( + to_tsvector( + 'english', + coalesce(name, '') || ' ' || + coalesce(description, '') || ' ' || + coalesce(city, '') || ' ' || + coalesce(state, '') || ' ' || + coalesce(country, '') + ) + ) STORED + """) + op.execute( + "CREATE INDEX race_search_vector_idx ON race USING GIN(search_vector)" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS race_search_vector_idx") + op.execute("ALTER TABLE race DROP COLUMN IF EXISTS search_vector") diff --git a/backend/app/alembic/versions/c3d4e5f6a7b8_phase3_pgvector_extension.py b/backend/app/alembic/versions/c3d4e5f6a7b8_phase3_pgvector_extension.py new file mode 100644 index 0000000000..9cbc48e479 --- /dev/null +++ b/backend/app/alembic/versions/c3d4e5f6a7b8_phase3_pgvector_extension.py @@ -0,0 +1,22 @@ +"""Phase 3: enable pgvector extension + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-04-23 00:00:00.000000 + +""" + +from alembic import op + +revision = "c3d4e5f6a7b8" +down_revision = "b2c3d4e5f6a7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("CREATE EXTENSION IF NOT EXISTS vector;") + + +def downgrade() -> None: + op.execute("DROP EXTENSION IF EXISTS vector;") diff --git a/backend/app/alembic/versions/d4e5f6a7b8c9_phase3_race_embedding_column.py b/backend/app/alembic/versions/d4e5f6a7b8c9_phase3_race_embedding_column.py new file mode 100644 index 0000000000..9f339aab84 --- /dev/null +++ b/backend/app/alembic/versions/d4e5f6a7b8c9_phase3_race_embedding_column.py @@ -0,0 +1,31 @@ +"""Phase 3: add embedding vector column to race table + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-04-23 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + +revision = "d4e5f6a7b8c9" +down_revision = "c3d4e5f6a7b8" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # pgvector extension must already exist (applied in c3d4e5f6a7b8) + op.execute("ALTER TABLE race ADD COLUMN IF NOT EXISTS embedding vector(1536);") + # IVFFlat index for approximate nearest-neighbour search (cosine distance) + # lists=100 is a reasonable default for up to ~1M rows + op.execute( + "CREATE INDEX IF NOT EXISTS ix_race_embedding_ivfflat " + "ON race USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_race_embedding_ivfflat;") + op.execute("ALTER TABLE race DROP COLUMN IF EXISTS embedding;") diff --git a/backend/app/alembic/versions/e3b62a02ffd7_add_vietnamese_administrative_tables.py b/backend/app/alembic/versions/e3b62a02ffd7_add_vietnamese_administrative_tables.py new file mode 100644 index 0000000000..00c9025407 --- /dev/null +++ b/backend/app/alembic/versions/e3b62a02ffd7_add_vietnamese_administrative_tables.py @@ -0,0 +1,115 @@ +"""Add vietnamese administrative tables + +Revision ID: e3b62a02ffd7 +Revises: f6a7b8c9d0e1 +Create Date: 2026-05-16 21:18:03.060707 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'e3b62a02ffd7' +down_revision = 'f6a7b8c9d0e1' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('administrative_regions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('name_en', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('code_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('code_name_en', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('administrative_units', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('full_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('full_name_en', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('short_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('short_name_en', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('code_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('code_name_en', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('provinces', + sa.Column('code', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('name_en', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('full_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('full_name_en', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('code_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('administrative_unit_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['administrative_unit_id'], ['administrative_units.id'], ), + sa.PrimaryKeyConstraint('code') + ) + op.create_index(op.f('ix_provinces_administrative_unit_id'), 'provinces', ['administrative_unit_id'], unique=False) + op.create_table('wards', + sa.Column('code', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('name_en', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('full_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('full_name_en', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('code_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('province_code', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('administrative_unit_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['administrative_unit_id'], ['administrative_units.id'], ), + sa.ForeignKeyConstraint(['province_code'], ['provinces.code'], ), + sa.PrimaryKeyConstraint('code') + ) + op.create_index(op.f('ix_wards_administrative_unit_id'), 'wards', ['administrative_unit_id'], unique=False) + op.create_index(op.f('ix_wards_province_code'), 'wards', ['province_code'], unique=False) + op.drop_index(op.f('ix_race_embedding_ivfflat'), table_name='race', postgresql_ops={'embedding': 'vector_cosine_ops'}, postgresql_with={'lists': '100'}, postgresql_using='ivfflat') + op.drop_index(op.f('race_search_vector_idx'), table_name='race', postgresql_using='gin') + op.drop_column('race', 'search_vector') + op.drop_constraint(op.f('racetag_name_key'), 'racetag', type_='unique') + op.drop_constraint(op.f('racetag_slug_key'), 'racetag', type_='unique') + op.alter_column('userprofile', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + op.alter_column('userprofile', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + op.drop_constraint(op.f('userprofile_user_id_key'), 'userprofile', type_='unique') + op.alter_column('userraceinteraction', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('userraceinteraction', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.create_unique_constraint(op.f('userprofile_user_id_key'), 'userprofile', ['user_id'], postgresql_nulls_not_distinct=False) + op.alter_column('userprofile', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.alter_column('userprofile', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.create_unique_constraint(op.f('racetag_slug_key'), 'racetag', ['slug'], postgresql_nulls_not_distinct=False) + op.create_unique_constraint(op.f('racetag_name_key'), 'racetag', ['name'], postgresql_nulls_not_distinct=False) + op.add_column('race', sa.Column('search_vector', postgresql.TSVECTOR(), sa.Computed("to_tsvector('english'::regconfig, (((((((((COALESCE(name, ''::character varying))::text || ' '::text) || (COALESCE(description, ''::character varying))::text) || ' '::text) || (COALESCE(city, ''::character varying))::text) || ' '::text) || (COALESCE(state, ''::character varying))::text) || ' '::text) || (COALESCE(country, ''::character varying))::text))", persisted=True), autoincrement=False, nullable=True)) + op.create_index(op.f('race_search_vector_idx'), 'race', ['search_vector'], unique=False, postgresql_using='gin') + op.create_index(op.f('ix_race_embedding_ivfflat'), 'race', ['embedding'], unique=False, postgresql_ops={'embedding': 'vector_cosine_ops'}, postgresql_with={'lists': '100'}, postgresql_using='ivfflat') + op.drop_index(op.f('ix_wards_province_code'), table_name='wards') + op.drop_index(op.f('ix_wards_administrative_unit_id'), table_name='wards') + op.drop_table('wards') + op.drop_index(op.f('ix_provinces_administrative_unit_id'), table_name='provinces') + op.drop_table('provinces') + op.drop_table('administrative_units') + op.drop_table('administrative_regions') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/e5f6a7b8c9d0_convert_enum_columns_to_varchar.py b/backend/app/alembic/versions/e5f6a7b8c9d0_convert_enum_columns_to_varchar.py new file mode 100644 index 0000000000..63db6b1de5 --- /dev/null +++ b/backend/app/alembic/versions/e5f6a7b8c9d0_convert_enum_columns_to_varchar.py @@ -0,0 +1,133 @@ +"""convert enum columns to varchar + +Revision ID: e5f6a7b8c9d0 +Revises: d4e5f6a7b8c9 +Create Date: 2026-04-24 00:00:00.000000 + +SQLModel with AutoString sends character varying, but earlier migrations created +these columns as PostgreSQL native enum types. This migration converts them all to +VARCHAR and (for race.status which stored uppercase names) lowercases existing data. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "e5f6a7b8c9d0" +down_revision = "d4e5f6a7b8c9" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Columns that stored uppercase enum names — convert to lowercase varchar in one step + # via USING clause (the only way to change type and transform data simultaneously) + op.execute( + "ALTER TABLE race ALTER COLUMN status TYPE VARCHAR USING LOWER(status::text)" + ) + op.execute( + "ALTER TABLE raceregistration ALTER COLUMN registration_status TYPE VARCHAR USING LOWER(registration_status::text)" + ) + op.execute( + "ALTER TABLE raceregistration ALTER COLUMN payment_status TYPE VARCHAR USING LOWER(payment_status::text)" + ) + op.execute( + "ALTER TABLE raceresult ALTER COLUMN status TYPE VARCHAR USING LOWER(status::text)" + ) + + # Columns that already stored lowercase values — just retype + op.execute( + "ALTER TABLE race ALTER COLUMN terrain_type TYPE VARCHAR USING terrain_type::text" + ) + op.execute( + "ALTER TABLE race ALTER COLUMN difficulty_level TYPE VARCHAR USING difficulty_level::text" + ) + op.execute( + "ALTER TABLE userprofile ALTER COLUMN fitness_level TYPE VARCHAR USING fitness_level::text" + ) + op.execute( + "ALTER TABLE userprofile ALTER COLUMN distance_preference TYPE VARCHAR USING distance_preference::text" + ) + op.execute( + "ALTER TABLE userprofile ALTER COLUMN terrain_preference TYPE VARCHAR USING terrain_preference::text" + ) + op.execute( + "ALTER TABLE userraceinteraction ALTER COLUMN action TYPE VARCHAR USING action::text" + ) + + # Drop the now-unused PostgreSQL enum types + op.execute("DROP TYPE IF EXISTS racestatusenum") + op.execute("DROP TYPE IF EXISTS registrationstatusenum") + op.execute("DROP TYPE IF EXISTS paymentstatusenum") + op.execute("DROP TYPE IF EXISTS resultstatusenum") + op.execute("DROP TYPE IF EXISTS terrainenum") + op.execute("DROP TYPE IF EXISTS difficultyenum") + op.execute("DROP TYPE IF EXISTS fitnessenum") + op.execute("DROP TYPE IF EXISTS distanceprefenum") + op.execute("DROP TYPE IF EXISTS interactiontypeenum") + + +def downgrade() -> None: + # Recreate enum types and cast back — data stays lowercase which matches enum values + op.execute( + "CREATE TYPE racestatusenum AS ENUM ('draft','published','registration_open','registration_closed','completed','cancelled')" + ) + op.execute( + "ALTER TABLE race ALTER COLUMN status TYPE racestatusenum USING status::racestatusenum" + ) + + op.execute( + "CREATE TYPE registrationstatusenum AS ENUM ('pending','confirmed','cancelled','waitlist')" + ) + op.execute( + "ALTER TABLE raceregistration ALTER COLUMN registration_status TYPE registrationstatusenum USING registration_status::registrationstatusenum" + ) + + op.execute( + "CREATE TYPE paymentstatusenum AS ENUM ('unpaid','paid','refunded','partial')" + ) + op.execute( + "ALTER TABLE raceregistration ALTER COLUMN payment_status TYPE paymentstatusenum USING payment_status::paymentstatusenum" + ) + + op.execute( + "CREATE TYPE resultstatusenum AS ENUM ('finished','dnf','dns','dq')" + ) + op.execute( + "ALTER TABLE raceresult ALTER COLUMN status TYPE resultstatusenum USING status::resultstatusenum" + ) + + op.execute( + "CREATE TYPE terrainenum AS ENUM ('road','trail','track','mixed')" + ) + op.execute( + "ALTER TABLE race ALTER COLUMN terrain_type TYPE terrainenum USING terrain_type::terrainenum" + ) + op.execute( + "ALTER TABLE race ALTER COLUMN difficulty_level TYPE difficultyenum USING difficulty_level::difficultyenum" + ) + + op.execute( + "CREATE TYPE difficultyenum AS ENUM ('easy','moderate','hard','extreme')" + ) + op.execute( + "CREATE TYPE fitnessenum AS ENUM ('beginner','intermediate','advanced','elite')" + ) + op.execute( + "ALTER TABLE userprofile ALTER COLUMN fitness_level TYPE fitnessenum USING fitness_level::fitnessenum" + ) + op.execute( + "CREATE TYPE distanceprefenum AS ENUM ('short','mid','long','ultra')" + ) + op.execute( + "ALTER TABLE userprofile ALTER COLUMN distance_preference TYPE distanceprefenum USING distance_preference::distanceprefenum" + ) + op.execute( + "ALTER TABLE userprofile ALTER COLUMN terrain_preference TYPE terrainenum USING terrain_preference::terrainenum" + ) + + op.execute( + "CREATE TYPE interactiontypeenum AS ENUM ('viewed','saved','unsaved','registered','shared')" + ) + op.execute( + "ALTER TABLE userraceinteraction ALTER COLUMN action TYPE interactiontypeenum USING action::interactiontypeenum" + ) diff --git a/backend/app/alembic/versions/f6a7b8c9d0e1_make_racecategory_start_time_nullable.py b/backend/app/alembic/versions/f6a7b8c9d0e1_make_racecategory_start_time_nullable.py new file mode 100644 index 0000000000..582e139414 --- /dev/null +++ b/backend/app/alembic/versions/f6a7b8c9d0e1_make_racecategory_start_time_nullable.py @@ -0,0 +1,22 @@ +"""make racecategory start_time nullable + +Revision ID: f6a7b8c9d0e1 +Revises: e5f6a7b8c9d0 +Create Date: 2026-04-24 00:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +revision = "f6a7b8c9d0e1" +down_revision = "e5f6a7b8c9d0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column("racecategory", "start_time", nullable=True) + + +def downgrade() -> None: + op.execute("UPDATE racecategory SET start_time = NOW() WHERE start_time IS NULL") + op.alter_column("racecategory", "start_time", nullable=False) diff --git a/backend/app/alembic/versions/fbc16b106df0_add_province_and_ward_foreign_keys_to_.py b/backend/app/alembic/versions/fbc16b106df0_add_province_and_ward_foreign_keys_to_.py new file mode 100644 index 0000000000..a863e1f61b --- /dev/null +++ b/backend/app/alembic/versions/fbc16b106df0_add_province_and_ward_foreign_keys_to_.py @@ -0,0 +1,35 @@ +"""Add province and ward foreign keys to race table + +Revision ID: fbc16b106df0 +Revises: e3b62a02ffd7 +Create Date: 2026-05-16 21:25:27.146698 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'fbc16b106df0' +down_revision = 'e3b62a02ffd7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('race', sa.Column('province_code', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=True)) + op.add_column('race', sa.Column('ward_code', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=True)) + op.create_foreign_key(None, 'race', 'provinces', ['province_code'], ['code']) + op.create_foreign_key(None, 'race', 'wards', ['ward_code'], ['code']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'race', type_='foreignkey') + op.drop_constraint(None, 'race', type_='foreignkey') + op.drop_column('race', 'ward_code') + op.drop_column('race', 'province_code') + # ### end Alembic commands ### diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c2b83c841d..a1fa96cb79 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -8,6 +8,7 @@ from pydantic import ValidationError from sqlmodel import Session +from app import crud from app.core import security from app.core.config import settings from app.core.db import engine @@ -55,3 +56,38 @@ def get_current_active_superuser(current_user: CurrentUser) -> User: status_code=403, detail="The user doesn't have enough privileges" ) return current_user + + +# Role-based permission checking +def require_role(required_role: str): + """Dependency factory to require a specific role.""" + def check_role(current_user: CurrentUser) -> User: + if not crud.user_has_role(current_user, required_role) and not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User does not have required role: {required_role}" + ) + return current_user + return check_role + + +def require_any_role(required_roles: list[str]): + """Dependency factory to require any of the specified roles.""" + def check_roles(current_user: CurrentUser) -> User: + if not crud.user_has_any_role(current_user, required_roles) and not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User does not have any of the required roles: {', '.join(required_roles)}" + ) + return current_user + return check_roles + + +# Predefined role dependencies for common use cases +AdminUser = Annotated[User, Depends(require_role("admin"))] +RunnerUser = Annotated[User, Depends(require_role("runner"))] +OrganizerUser = Annotated[User, Depends(require_role("organizer"))] +VolunteerUser = Annotated[User, Depends(require_role("volunteer"))] + +# Combined role dependencies +AdminOrOrganizerUser = Annotated[User, Depends(require_any_role(["admin", "organizer"]))] diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..e0c75612da 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,13 +1,47 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import ( + items, + login, + media, + private, + profiles, + provinces, + race_attributes, + race_categories, + race_registrations, + race_results, + races, + roles, + tags, + users, + utils, +) +from app.api.routes.races import admin_router as races_admin_router from app.core.config import settings api_router = APIRouter() api_router.include_router(login.router) api_router.include_router(users.router) +api_router.include_router(roles.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(media.router) + +# Race management routes +api_router.include_router(races.router) +api_router.include_router(race_categories.router) +api_router.include_router(race_registrations.router) +api_router.include_router(race_results.router) +api_router.include_router(race_attributes.router) + +# Discovery & personalization routes +api_router.include_router(tags.router) +api_router.include_router(profiles.router) +api_router.include_router(provinces.router) + +# Admin utilities +api_router.include_router(races_admin_router) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/rate_limit.py b/backend/app/api/rate_limit.py new file mode 100644 index 0000000000..ce42ecebab --- /dev/null +++ b/backend/app/api/rate_limit.py @@ -0,0 +1,53 @@ +"""Redis-backed sliding-window rate limiter as a FastAPI dependency.""" + +from __future__ import annotations + +import time +from typing import Callable + +from fastapi import Depends, HTTPException, Request + + +def RateLimiter(max_calls: int, window_seconds: int) -> Callable: + """Return a FastAPI dependency that enforces a per-IP rate limit via Redis. + + Falls back to in-memory limiting when Redis is unavailable. + """ + _memory_store: dict[str, list[float]] = {} + + async def _limit(request: Request) -> None: + client_ip = request.client.host if request.client else "unknown" + key = f"rl:{max_calls}:{window_seconds}:{client_ip}" + + try: + from app.services.cache import _client + + redis = _client() + now = time.time() + window_start = now - window_seconds + + pipe = redis.pipeline() + pipe.zremrangebyscore(key, 0, window_start) + pipe.zadd(key, {str(now): now}) + pipe.zcard(key) + pipe.expire(key, window_seconds + 1) + results = await pipe.execute() + count: int = results[2] + + except Exception: + # Fallback: in-process sliding window + now = time.time() + window_start = now - window_seconds + hits = _memory_store.get(client_ip, []) + hits = [t for t in hits if t > window_start] + hits.append(now) + _memory_store[client_ip] = hits + count = len(hits) + + if count > max_calls: + raise HTTPException( + status_code=429, + detail=f"Rate limit exceeded: {max_calls} requests per {window_seconds}s", + ) + + return Depends(_limit) diff --git a/backend/app/api/routes/media.py b/backend/app/api/routes/media.py new file mode 100644 index 0000000000..403c2695a9 --- /dev/null +++ b/backend/app/api/routes/media.py @@ -0,0 +1,242 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi.responses import FileResponse + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.core.config import settings +from app.models import ( + MediaAssetCreate, + MediaAssetPublic, + MediaAssetsPublic, + MediaAssetUpdate, + Message, +) +from app.services.media_storage import ( + delete_media_file, + resolve_media_path, + save_uploaded_media, +) + +router = APIRouter(prefix="/media", tags=["media"]) + +ALLOWED_IMAGE_MIME_TYPES = { + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + "image/avif", +} + + +def _can_manage_content( + *, + session: SessionDep, + current_user: CurrentUser, + content_type: str, + content_id: uuid.UUID, +) -> bool: + if current_user.is_superuser: + return True + + if content_type == "race": + race = crud.get_race(session=session, race_id=content_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + return race.organizer_id == current_user.id + + return False + + +@router.get("/", response_model=MediaAssetsPublic) +def read_media_assets( + session: SessionDep, + content_type: str | None = None, + content_id: uuid.UUID | None = None, + kind: str | None = None, + is_public: bool = True, + skip: int = 0, + limit: int = 200, +) -> Any: + """List media assets for any content type.""" + assets = crud.get_media_assets( + session=session, + content_type=content_type, + content_id=content_id, + kind=kind, + is_public=is_public, + skip=skip, + limit=limit, + ) + count = crud.get_media_assets_count( + session=session, + content_type=content_type, + content_id=content_id, + kind=kind, + is_public=is_public, + ) + assets_public = [MediaAssetPublic.model_validate(asset) for asset in assets] + return MediaAssetsPublic(data=assets_public, count=count) + + +@router.post("/upload", response_model=MediaAssetPublic) +def upload_media_asset( + *, + session: SessionDep, + current_user: CurrentUser, + file: UploadFile = File(...), + content_type: str = Form(...), + content_id: uuid.UUID = Form(...), + kind: str = Form("gallery"), + alt_text: str | None = Form(None), + display_order: int = Form(0), + is_primary: bool = Form(False), + is_public: bool = Form(True), +) -> Any: + """Upload media for any content type (currently race-aware for permissions).""" + normalized_content_type = content_type.strip().lower() + + can_manage = _can_manage_content( + session=session, + current_user=current_user, + content_type=normalized_content_type, + content_id=content_id, + ) + if not can_manage: + raise HTTPException(status_code=403, detail="Not enough permissions") + + if not file.content_type or file.content_type not in ALLOWED_IMAGE_MIME_TYPES: + raise HTTPException(status_code=400, detail="Only image uploads are allowed") + + stored_filename, relative_path, size_bytes = save_uploaded_media( + file=file, + content_type=normalized_content_type, + content_id=content_id, + ) + + max_size_bytes = settings.MEDIA_MAX_FILE_SIZE_MB * 1024 * 1024 + if size_bytes > max_size_bytes: + delete_media_file(relative_path) + raise HTTPException( + status_code=400, + detail=f"File exceeds max size of {settings.MEDIA_MAX_FILE_SIZE_MB}MB", + ) + + if is_primary: + crud.clear_primary_media( + session=session, + content_type=normalized_content_type, + content_id=content_id, + kind=kind, + ) + + media_in = MediaAssetCreate( + content_type=normalized_content_type, + content_id=content_id, + kind=kind, + alt_text=alt_text, + display_order=display_order, + is_primary=is_primary, + is_public=is_public, + original_filename=file.filename or stored_filename, + file_name=stored_filename, + file_path=relative_path, + file_url="", + mime_type=file.content_type, + size_bytes=size_bytes, + uploaded_by_id=current_user.id, + ) + + db_media = crud.create_media_asset(session=session, media_in=media_in) + db_media.file_url = f"{settings.API_V1_STR}/media/{db_media.id}/file" + session.add(db_media) + session.commit() + session.refresh(db_media) + + return db_media + + +@router.get("/{media_id}/file") +def read_media_file(session: SessionDep, media_id: uuid.UUID) -> Any: + """Serve a media file by media id.""" + media = crud.get_media_asset(session=session, media_id=media_id) + if not media: + raise HTTPException(status_code=404, detail="Media not found") + + file_path = resolve_media_path(media.file_path) + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + return FileResponse( + path=file_path, + media_type=media.mime_type, + filename=media.original_filename, + ) + + +@router.put("/{media_id}", response_model=MediaAssetPublic) +def update_media_asset( + *, + session: SessionDep, + current_user: CurrentUser, + media_id: uuid.UUID, + media_in: MediaAssetUpdate, +) -> Any: + """Update media metadata.""" + media = crud.get_media_asset(session=session, media_id=media_id) + if not media: + raise HTTPException(status_code=404, detail="Media not found") + + can_manage = _can_manage_content( + session=session, + current_user=current_user, + content_type=media.content_type, + content_id=media.content_id, + ) + if not can_manage: + raise HTTPException(status_code=403, detail="Not enough permissions") + + incoming = media_in.model_dump(exclude_unset=True) + next_kind = incoming.get("kind", media.kind) + next_is_primary = incoming.get("is_primary", media.is_primary) + + if next_is_primary: + crud.clear_primary_media( + session=session, + content_type=media.content_type, + content_id=media.content_id, + kind=next_kind, + exclude_id=media.id, + ) + + updated = crud.update_media_asset( + session=session, + db_media=media, + media_in=media_in, + ) + return updated + + +@router.delete("/{media_id}", response_model=Message) +def delete_media_asset( + *, session: SessionDep, current_user: CurrentUser, media_id: uuid.UUID +) -> Any: + """Delete a media asset and its file.""" + media = crud.get_media_asset(session=session, media_id=media_id) + if not media: + raise HTTPException(status_code=404, detail="Media not found") + + can_manage = _can_manage_content( + session=session, + current_user=current_user, + content_type=media.content_type, + content_id=media.content_id, + ) + if not can_manage: + raise HTTPException(status_code=403, detail="Not enough permissions") + + delete_media_file(media.file_path) + crud.delete_media_asset(session=session, media_id=media_id) + return Message(message="Media deleted successfully") diff --git a/backend/app/api/routes/profiles.py b/backend/app/api/routes/profiles.py new file mode 100644 index 0000000000..65b698102d --- /dev/null +++ b/backend/app/api/routes/profiles.py @@ -0,0 +1,149 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + InteractionTypeEnum, + RacePublic, + RacesPublic, + UserProfileCreate, + UserProfilePublic, + UserProfileUpdate, + UserRaceInteractionPublic, +) + +router = APIRouter(tags=["profiles"]) + + +# --------------------------------------------------------------------------- +# User profile +# --------------------------------------------------------------------------- + + +@router.get("/users/me/profile", response_model=UserProfilePublic) +def get_my_profile(session: SessionDep, current_user: CurrentUser) -> Any: + """Return the current user's running profile.""" + profile = crud.get_user_profile(session=session, user_id=current_user.id) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + return UserProfilePublic.model_validate(profile) + + +@router.post("/users/me/profile", response_model=UserProfilePublic) +def upsert_my_profile( + *, session: SessionDep, current_user: CurrentUser, profile_in: UserProfileCreate +) -> Any: + """Create or replace the current user's running profile.""" + profile = crud.upsert_user_profile( + session=session, user_id=current_user.id, profile_in=profile_in + ) + return UserProfilePublic.model_validate(profile) + + +@router.patch("/users/me/profile", response_model=UserProfilePublic) +def update_my_profile( + *, session: SessionDep, current_user: CurrentUser, profile_in: UserProfileUpdate +) -> Any: + """Partially update the current user's running profile.""" + profile = crud.get_user_profile(session=session, user_id=current_user.id) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found. Use POST to create one.") + profile = crud.update_user_profile( + session=session, db_profile=profile, profile_in=profile_in + ) + return UserProfilePublic.model_validate(profile) + + +@router.delete("/users/me/profile") +def delete_my_profile(session: SessionDep, current_user: CurrentUser) -> Any: + """Delete the current user's running profile and reset onboarding state.""" + deleted = crud.delete_user_profile(session=session, user_id=current_user.id) + if not deleted: + raise HTTPException(status_code=404, detail="Profile not found") + return {"message": "Profile deleted"} + + +# --------------------------------------------------------------------------- +# Saved races +# --------------------------------------------------------------------------- + + +@router.get("/users/me/saved-races", response_model=RacesPublic) +def get_my_saved_races(session: SessionDep, current_user: CurrentUser) -> Any: + """Return all races the current user has saved.""" + races = crud.get_user_saved_races(session=session, user_id=current_user.id) + return RacesPublic( + data=[RacePublic.model_validate(r) for r in races], count=len(races) + ) + + +@router.post("/races/{race_id}/save", response_model=UserRaceInteractionPublic) +def save_race( + *, session: SessionDep, current_user: CurrentUser, race_id: uuid.UUID +) -> Any: + """Save a race to the current user's wishlist.""" + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + existing = crud.get_user_interaction( + session=session, + user_id=current_user.id, + race_id=race_id, + action=InteractionTypeEnum.SAVED, + ) + if existing: + return UserRaceInteractionPublic.model_validate(existing) + interaction = crud.record_interaction( + session=session, + user_id=current_user.id, + race_id=race_id, + action=InteractionTypeEnum.SAVED, + ) + return UserRaceInteractionPublic.model_validate(interaction) + + +@router.delete("/races/{race_id}/save") +def unsave_race( + *, session: SessionDep, current_user: CurrentUser, race_id: uuid.UUID +) -> Any: + """Remove a race from the current user's wishlist.""" + existing = crud.get_user_interaction( + session=session, + user_id=current_user.id, + race_id=race_id, + action=InteractionTypeEnum.SAVED, + ) + if not existing: + raise HTTPException(status_code=404, detail="Race not in saved list") + crud.record_interaction( + session=session, + user_id=current_user.id, + race_id=race_id, + action=InteractionTypeEnum.UNSAVED, + ) + return {"message": "Race removed from saved list"} + + +# --------------------------------------------------------------------------- +# Interaction tracking +# --------------------------------------------------------------------------- + + +@router.post("/races/{race_id}/view", response_model=UserRaceInteractionPublic) +def track_race_view( + *, session: SessionDep, current_user: CurrentUser, race_id: uuid.UUID +) -> Any: + """Record that the current user viewed a race detail page.""" + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + interaction = crud.record_interaction( + session=session, + user_id=current_user.id, + race_id=race_id, + action=InteractionTypeEnum.VIEWED, + ) + return UserRaceInteractionPublic.model_validate(interaction) diff --git a/backend/app/api/routes/provinces.py b/backend/app/api/routes/provinces.py new file mode 100644 index 0000000000..14d46606fb --- /dev/null +++ b/backend/app/api/routes/provinces.py @@ -0,0 +1,131 @@ +"""API routes for Vietnamese administrative data (provinces, wards).""" +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from sqlmodel import select + +from app.api.deps import SessionDep +from app.models import ( + AdministrativeRegion, + AdministrativeRegionPublic, + AdministrativeUnit, + AdministrativeUnitPublic, + Province, + ProvincePublic, + ProvincePublicWithDetails, + ProvincesPublic, + Ward, + WardPublic, + WardPublicWithDetails, + WardsPublic, +) + +router = APIRouter(prefix="/provinces", tags=["provinces"]) + + +# ============================================================================= +# Administrative Regions +# ============================================================================= + + +@router.get("/regions", response_model=list[AdministrativeRegionPublic]) +def read_administrative_regions(session: SessionDep) -> Any: + """Get all administrative regions.""" + statement = select(AdministrativeRegion) + regions = session.exec(statement).all() + return regions + + +# ============================================================================= +# Administrative Units +# ============================================================================= + + +@router.get("/units", response_model=list[AdministrativeUnitPublic]) +def read_administrative_units(session: SessionDep) -> Any: + """Get all administrative units.""" + statement = select(AdministrativeUnit) + units = session.exec(statement).all() + return units + + +# ============================================================================= +# Provinces +# ============================================================================= + + +@router.get("/", response_model=ProvincesPublic) +def read_provinces( + session: SessionDep, + skip: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=1000), +) -> Any: + """Get all provinces with pagination.""" + statement = select(Province).offset(skip).limit(limit) + provinces = session.exec(statement).all() + + count_statement = select(Province) + total_count = len(session.exec(count_statement).all()) + + return ProvincesPublic(data=provinces, count=total_count) + + +@router.get("/{province_code}", response_model=ProvincePublicWithDetails) +def read_province(session: SessionDep, province_code: str) -> Any: + """Get a specific province by code with administrative unit details.""" + statement = select(Province).where(Province.code == province_code) + province = session.exec(statement).first() + + if not province: + raise HTTPException(status_code=404, detail="Province not found") + + return province + + +# ============================================================================= +# Wards (Districts/Communes) +# ============================================================================= + + +@router.get("/{province_code}/wards", response_model=WardsPublic) +def read_wards_by_province( + session: SessionDep, + province_code: str, + skip: int = Query(default=0, ge=0), + limit: int = Query(default=500, ge=1, le=1000), +) -> Any: + """Get all wards for a specific province.""" + # First check if province exists + province_statement = select(Province).where(Province.code == province_code) + province = session.exec(province_statement).first() + + if not province: + raise HTTPException(status_code=404, detail="Province not found") + + # Get wards for this province + statement = ( + select(Ward) + .where(Ward.province_code == province_code) + .offset(skip) + .limit(limit) + ) + wards = session.exec(statement).all() + + # Count total wards for this province + count_statement = select(Ward).where(Ward.province_code == province_code) + total_count = len(session.exec(count_statement).all()) + + return WardsPublic(data=wards, count=total_count) + + +@router.get("/wards/{ward_code}", response_model=WardPublicWithDetails) +def read_ward(session: SessionDep, ward_code: str) -> Any: + """Get a specific ward by code with details.""" + statement = select(Ward).where(Ward.code == ward_code) + ward = session.exec(statement).first() + + if not ward: + raise HTTPException(status_code=404, detail="Ward not found") + + return ward diff --git a/backend/app/api/routes/race_attributes.py b/backend/app/api/routes/race_attributes.py new file mode 100644 index 0000000000..ec6cf77b6f --- /dev/null +++ b/backend/app/api/routes/race_attributes.py @@ -0,0 +1,126 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Message, + RaceAttributeCreate, + RaceAttributePublic, + RaceAttributesPublic, + RaceAttributeUpdate, +) + +router = APIRouter(prefix="/race-attributes", tags=["race-attributes"]) + + +@router.get("/", response_model=RaceAttributesPublic) +def read_race_attributes( + session: SessionDep, + race_id: uuid.UUID, + is_public: bool | None = None, +) -> Any: + """ + Retrieve race attributes for a specific race. + Public endpoint - filters by is_public by default unless authenticated organizer/admin. + """ + attributes = crud.get_race_attributes( + session=session, race_id=race_id, is_public=is_public + ) + attributes_public = [ + RaceAttributePublic.model_validate(attr) for attr in attributes + ] + return RaceAttributesPublic(data=attributes_public, count=len(attributes_public)) + + +@router.get("/{attribute_id}", response_model=RaceAttributePublic) +def read_race_attribute(session: SessionDep, attribute_id: uuid.UUID) -> Any: + """ + Get race attribute by ID. + """ + attribute = crud.get_race_attribute(session=session, attribute_id=attribute_id) + if not attribute: + raise HTTPException(status_code=404, detail="Race attribute not found") + return attribute + + +@router.post("/", response_model=RaceAttributePublic) +def create_race_attribute( + *, + session: SessionDep, + current_user: CurrentUser, + attribute_in: RaceAttributeCreate, +) -> Any: + """ + Create new race attribute. + Only the race organizer or admin can create attributes. + """ + # Get the race to check permissions + race = crud.get_race(session=session, race_id=attribute_in.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + attribute = crud.create_race_attribute(session=session, attribute_in=attribute_in) + return attribute + + +@router.put("/{attribute_id}", response_model=RaceAttributePublic) +def update_race_attribute( + *, + session: SessionDep, + current_user: CurrentUser, + attribute_id: uuid.UUID, + attribute_in: RaceAttributeUpdate, +) -> Any: + """ + Update a race attribute. + Only the race organizer or admin can update. + """ + attribute = crud.get_race_attribute(session=session, attribute_id=attribute_id) + if not attribute: + raise HTTPException(status_code=404, detail="Race attribute not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=attribute.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + attribute = crud.update_race_attribute( + session=session, db_attribute=attribute, attribute_in=attribute_in + ) + return attribute + + +@router.delete("/{attribute_id}", response_model=Message) +def delete_race_attribute( + *, session: SessionDep, current_user: CurrentUser, attribute_id: uuid.UUID +) -> Any: + """ + Delete a race attribute. + Only the race organizer or admin can delete. + """ + attribute = crud.get_race_attribute(session=session, attribute_id=attribute_id) + if not attribute: + raise HTTPException(status_code=404, detail="Race attribute not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=attribute.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + crud.delete_race_attribute(session=session, attribute_id=attribute_id) + return Message(message="Race attribute deleted successfully") diff --git a/backend/app/api/routes/race_categories.py b/backend/app/api/routes/race_categories.py new file mode 100644 index 0000000000..01ca093f8c --- /dev/null +++ b/backend/app/api/routes/race_categories.py @@ -0,0 +1,248 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + CategoryTranslationUpdate, + Message, + RaceCategoriesPublic, + RaceCategoryCreate, + RaceCategoryPublic, + RaceCategoryPublicWithDetails, + RaceCategoryUpdate, +) + +router = APIRouter(prefix="/race-categories", tags=["race-categories"]) + + +@router.get("/", response_model=RaceCategoriesPublic) +def read_race_categories( + session: SessionDep, + race_id: uuid.UUID, + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Retrieve race categories for a specific race. Public endpoint. + """ + categories = crud.get_race_categories( + session=session, race_id=race_id, skip=skip, limit=limit + ) + + # Add computed fields for public response + enriched_categories = [] + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + for category in categories: + registration_count = crud.get_category_registration_count( + session=session, category_id=category.id + ) + available_spots = crud.get_category_available_spots( + session=session, + category_id=category.id, + max_participants=category.max_participants, + ) + is_registration_open = crud.is_category_registration_open( + category=category, race=race, session=session + ) + current_price = crud.get_category_current_price(category=category, race=race) + + enriched_category = RaceCategoryPublicWithDetails( + **category.model_dump(), + registration_count=registration_count, + available_spots=available_spots, + is_registration_open=is_registration_open, + current_price=current_price, + ) + enriched_categories.append(enriched_category) + + return RaceCategoriesPublic( + data=enriched_categories, count=len(enriched_categories) + ) + + +@router.get("/{category_id}", response_model=RaceCategoryPublicWithDetails) +def read_race_category(session: SessionDep, category_id: uuid.UUID) -> Any: + """ + Get race category by ID with details. Public endpoint. + """ + category = crud.get_race_category(session=session, category_id=category_id) + if not category: + raise HTTPException(status_code=404, detail="Race category not found") + + race = crud.get_race(session=session, race_id=category.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Add computed fields + registration_count = crud.get_category_registration_count( + session=session, category_id=category.id + ) + available_spots = crud.get_category_available_spots( + session=session, + category_id=category.id, + max_participants=category.max_participants, + ) + is_registration_open = crud.is_category_registration_open( + category=category, race=race, session=session + ) + current_price = crud.get_category_current_price(category=category, race=race) + + return RaceCategoryPublicWithDetails( + **category.model_dump(), + registration_count=registration_count, + available_spots=available_spots, + is_registration_open=is_registration_open, + current_price=current_price, + ) + + +@router.post("/", response_model=RaceCategoryPublic) +def create_race_category( + *, session: SessionDep, current_user: CurrentUser, category_in: RaceCategoryCreate +) -> Any: + """ + Create new race category. + Only the race organizer or admin can create categories. + """ + # Get the race to check permissions + race = crud.get_race(session=session, race_id=category_in.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + category = crud.create_race_category(session=session, category_in=category_in) + return category + + +@router.put("/{category_id}", response_model=RaceCategoryPublic) +def update_race_category( + *, + session: SessionDep, + current_user: CurrentUser, + category_id: uuid.UUID, + category_in: RaceCategoryUpdate, +) -> Any: + """ + Update a race category. + Only the race organizer or admin can update. + """ + category = crud.get_race_category(session=session, category_id=category_id) + if not category: + raise HTTPException(status_code=404, detail="Race category not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=category.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + category = crud.update_race_category( + session=session, db_category=category, category_in=category_in + ) + return category + + +@router.delete("/{category_id}", response_model=Message) +def delete_race_category( + *, session: SessionDep, current_user: CurrentUser, category_id: uuid.UUID +) -> Any: + """ + Delete a race category. + Only the race organizer or admin can delete. + """ + category = crud.get_race_category(session=session, category_id=category_id) + if not category: + raise HTTPException(status_code=404, detail="Race category not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=category.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + crud.delete_race_category(session=session, category_id=category_id) + return Message(message="Race category deleted successfully") + + +@router.put("/{category_id}/translations", response_model=RaceCategoryPublic) +def update_category_translations( + *, + session: SessionDep, + current_user: CurrentUser, + category_id: uuid.UUID, + translation: CategoryTranslationUpdate, +) -> Any: + """ + Update translations for a race category. + Only race organizer or admin can update translations. + """ + from app.i18n import is_language_supported, set_translation + + # Get the category + category = crud.get_race_category(session=session, category_id=category_id) + if not category: + raise HTTPException(status_code=404, detail="Race category not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=category.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions - only organizer or admin + if race.organizer_id != current_user.id and not current_user.is_superuser: + raise HTTPException( + status_code=403, + detail="Only race organizer or admin can update translations" + ) + + # Validate language + if not is_language_supported(translation.language): + raise HTTPException( + status_code=400, + detail=f"Language '{translation.language}' is not supported" + ) + + # Update translations + if translation.name: + set_translation(category, "name", translation.name, translation.language) + if translation.description: + set_translation(category, "description", translation.description, translation.language) + + session.add(category) + session.commit() + session.refresh(category) + + return category + + +@router.get("/{category_id}/translations", response_model=dict[str, Any]) +def get_category_translations( + *, + session: SessionDep, + category_id: uuid.UUID, +) -> Any: + """ + Get all translations for a race category. + Public endpoint - anyone can view translations. + """ + category = crud.get_race_category(session=session, category_id=category_id) + if not category: + raise HTTPException(status_code=404, detail="Race category not found") + + return category.translations or {} + diff --git a/backend/app/api/routes/race_registrations.py b/backend/app/api/routes/race_registrations.py new file mode 100644 index 0000000000..00fe685b5d --- /dev/null +++ b/backend/app/api/routes/race_registrations.py @@ -0,0 +1,294 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Message, + RaceCategoryPublic, + RaceRegistrationCreate, + RaceRegistrationPublic, + RaceRegistrationPublicWithDetails, + RaceRegistrationsPublic, + RaceRegistrationUpdate, + User, + UserPublic, +) + +router = APIRouter(prefix="/race-registrations", tags=["race-registrations"]) + + +@router.get("/", response_model=RaceRegistrationsPublic) +def read_race_registrations( + session: SessionDep, + current_user: CurrentUser, + race_id: uuid.UUID | None = None, + category_id: uuid.UUID | None = None, + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Retrieve race registrations. + - Runners see only their own registrations + - Organizers see registrations for their races + - Admins see all registrations + """ + if current_user.is_superuser: + # Admin sees all + registrations = crud.get_race_registrations( + session=session, + race_id=race_id, + category_id=category_id, + skip=skip, + limit=limit, + ) + count = crud.get_race_registrations_count( + session=session, race_id=race_id, category_id=category_id + ) + elif crud.user_has_any_role(current_user, ["organizer"]): + # Organizer sees registrations for their races + if race_id: + race = crud.get_race(session=session, race_id=race_id) + if not race or race.organizer_id != current_user.id: + raise HTTPException( + status_code=403, + detail="You can only view registrations for your own races", + ) + registrations = crud.get_race_registrations( + session=session, + race_id=race_id, + category_id=category_id, + skip=skip, + limit=limit, + ) + count = crud.get_race_registrations_count( + session=session, race_id=race_id, category_id=category_id + ) + else: + # Runner sees only their own registrations + registrations = crud.get_race_registrations( + session=session, + race_id=race_id, + runner_id=current_user.id, + category_id=category_id, + skip=skip, + limit=limit, + ) + count = crud.get_race_registrations_count( + session=session, + race_id=race_id, + runner_id=current_user.id, + category_id=category_id, + ) + + registrations_public = [ + RaceRegistrationPublic.model_validate(reg) for reg in registrations + ] + return RaceRegistrationsPublic(data=registrations_public, count=count) + + +@router.get("/my", response_model=RaceRegistrationsPublic) +def read_my_registrations( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve current user's race registrations. + """ + registrations = crud.get_race_registrations( + session=session, runner_id=current_user.id, skip=skip, limit=limit + ) + count = crud.get_race_registrations_count( + session=session, runner_id=current_user.id + ) + registrations_public = [ + RaceRegistrationPublic.model_validate(reg) for reg in registrations + ] + return RaceRegistrationsPublic(data=registrations_public, count=count) + + +@router.get("/{registration_id}", response_model=RaceRegistrationPublicWithDetails) +def read_race_registration( + session: SessionDep, current_user: CurrentUser, registration_id: uuid.UUID +) -> Any: + """ + Get race registration by ID with details. + """ + registration = crud.get_race_registration( + session=session, registration_id=registration_id + ) + if not registration: + raise HTTPException(status_code=404, detail="Registration not found") + + # Check permissions + race = crud.get_race(session=session, race_id=registration.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + if ( + not current_user.is_superuser + and registration.runner_id != current_user.id + and race.organizer_id != current_user.id + ): + raise HTTPException(status_code=403, detail="Not enough permissions") + + # Get related data + runner = session.get(User, registration.runner_id) + if not runner: + raise HTTPException(status_code=404, detail="Runner not found") + + category = crud.get_race_category( + session=session, category_id=registration.category_id + ) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + + return RaceRegistrationPublicWithDetails( + **registration.model_dump(), + runner=UserPublic.model_validate(runner), + category=RaceCategoryPublic.model_validate(category), + ) + + +@router.post("/", response_model=RaceRegistrationPublic) +def create_race_registration( + *, + session: SessionDep, + current_user: CurrentUser, + registration_in: RaceRegistrationCreate, +) -> Any: + """ + Create new race registration. + Runners can register themselves for races. + """ + # Check if race and category exist + race = crud.get_race(session=session, race_id=registration_in.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + category = crud.get_race_category( + session=session, category_id=registration_in.category_id + ) + if not category: + raise HTTPException(status_code=404, detail="Race category not found") + + # Verify category belongs to race + if category.race_id != race.id: + raise HTTPException( + status_code=400, detail="Category does not belong to this race" + ) + + # Check if already registered + existing_registration = crud.check_existing_registration( + session=session, runner_id=current_user.id, race_id=race.id + ) + if existing_registration: + raise HTTPException( + status_code=400, detail="You are already registered for this race" + ) + + # Check if registration is open + if not crud.is_category_registration_open( + category=category, race=race, session=session + ): + raise HTTPException( + status_code=400, detail="Registration is not open for this category" + ) + + # Create registration + registration = crud.create_race_registration( + session=session, registration_in=registration_in, runner_id=current_user.id + ) + return registration + + +@router.put("/{registration_id}", response_model=RaceRegistrationPublic) +def update_race_registration( + *, + session: SessionDep, + current_user: CurrentUser, + registration_id: uuid.UUID, + registration_in: RaceRegistrationUpdate, +) -> Any: + """ + Update a race registration. + - Runners can update their own registration details + - Organizers can update any field for their races + - Admins can update anything + """ + registration = crud.get_race_registration( + session=session, registration_id=registration_id + ) + if not registration: + raise HTTPException(status_code=404, detail="Registration not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=registration.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + is_own_registration = registration.runner_id == current_user.id + is_race_organizer = race.organizer_id == current_user.id + + if ( + not current_user.is_superuser + and not is_own_registration + and not is_race_organizer + ): + raise HTTPException(status_code=403, detail="Not enough permissions") + + # Runners can only update certain fields + if is_own_registration and not is_race_organizer and not current_user.is_superuser: + # Restrict what runners can update + restricted_fields = { + "emergency_contact", + "emergency_phone", + "tshirt_size", + "special_requirements", + } + update_data = registration_in.model_dump(exclude_unset=True) + if any(field not in restricted_fields for field in update_data.keys()): + raise HTTPException( + status_code=403, + detail="You can only update your personal information", + ) + + registration = crud.update_race_registration( + session=session, db_registration=registration, registration_in=registration_in + ) + return registration + + +@router.delete("/{registration_id}", response_model=Message) +def delete_race_registration( + *, session: SessionDep, current_user: CurrentUser, registration_id: uuid.UUID +) -> Any: + """ + Delete/Cancel a race registration. + - Runners can cancel their own registrations + - Organizers can cancel registrations for their races + - Admins can cancel any registration + """ + registration = crud.get_race_registration( + session=session, registration_id=registration_id + ) + if not registration: + raise HTTPException(status_code=404, detail="Registration not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=registration.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if ( + not current_user.is_superuser + and registration.runner_id != current_user.id + and race.organizer_id != current_user.id + ): + raise HTTPException(status_code=403, detail="Not enough permissions") + + crud.delete_race_registration(session=session, registration_id=registration_id) + return Message(message="Registration cancelled successfully") diff --git a/backend/app/api/routes/race_results.py b/backend/app/api/routes/race_results.py new file mode 100644 index 0000000000..fac560b9a3 --- /dev/null +++ b/backend/app/api/routes/race_results.py @@ -0,0 +1,178 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Message, + RaceResultCreate, + RaceResultPublic, + RaceResultsPublic, + RaceResultUpdate, +) + +router = APIRouter(prefix="/race-results", tags=["race-results"]) + + +@router.get("/", response_model=RaceResultsPublic) +def read_race_results( + session: SessionDep, + race_id: uuid.UUID, + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Retrieve race results for a specific race. Public endpoint. + """ + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + results = crud.get_race_results( + session=session, race_id=race_id, skip=skip, limit=limit + ) + results_public = [RaceResultPublic.model_validate(result) for result in results] + return RaceResultsPublic(data=results_public, count=len(results_public)) + + +@router.get("/{result_id}", response_model=RaceResultPublic) +def read_race_result(session: SessionDep, result_id: uuid.UUID) -> Any: + """ + Get race result by ID. Public endpoint. + """ + result = crud.get_race_result(session=session, result_id=result_id) + if not result: + raise HTTPException(status_code=404, detail="Race result not found") + return result + + +@router.get("/registration/{registration_id}", response_model=RaceResultPublic) +def read_race_result_by_registration( + session: SessionDep, registration_id: uuid.UUID +) -> Any: + """ + Get race result by registration ID. Public endpoint. + """ + result = crud.get_race_result_by_registration( + session=session, registration_id=registration_id + ) + if not result: + raise HTTPException(status_code=404, detail="Race result not found") + return result + + +@router.post("/", response_model=RaceResultPublic) +def create_race_result( + *, session: SessionDep, current_user: CurrentUser, result_in: RaceResultCreate +) -> Any: + """ + Create new race result. + Only race organizers, volunteers, or admins can create results. + """ + # Get registration to verify race + registration = crud.get_race_registration( + session=session, registration_id=result_in.registration_id + ) + if not registration: + raise HTTPException(status_code=404, detail="Registration not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=registration.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions - only organizer, volunteer, or admin + if not current_user.is_superuser and race.organizer_id != current_user.id: + if not crud.user_has_any_role(current_user, ["volunteer"]): + raise HTTPException( + status_code=403, + detail="Only race organizers, volunteers, or admins can create results", + ) + + # Check if result already exists + existing_result = crud.get_race_result_by_registration( + session=session, registration_id=result_in.registration_id + ) + if existing_result: + raise HTTPException( + status_code=400, detail="Result already exists for this registration" + ) + + result = crud.create_race_result(session=session, result_in=result_in) + return result + + +@router.put("/{result_id}", response_model=RaceResultPublic) +def update_race_result( + *, + session: SessionDep, + current_user: CurrentUser, + result_id: uuid.UUID, + result_in: RaceResultUpdate, +) -> Any: + """ + Update a race result. + Only race organizers, volunteers, or admins can update results. + """ + result = crud.get_race_result(session=session, result_id=result_id) + if not result: + raise HTTPException(status_code=404, detail="Race result not found") + + # Get registration to verify race + registration = crud.get_race_registration( + session=session, registration_id=result.registration_id + ) + if not registration: + raise HTTPException(status_code=404, detail="Registration not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=registration.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions - only organizer, volunteer, or admin + if not current_user.is_superuser and race.organizer_id != current_user.id: + if not crud.user_has_any_role(current_user, ["volunteer"]): + raise HTTPException( + status_code=403, + detail="Only race organizers, volunteers, or admins can update results", + ) + + result = crud.update_race_result( + session=session, db_result=result, result_in=result_in + ) + return result + + +@router.delete("/{result_id}", response_model=Message) +def delete_race_result( + *, session: SessionDep, current_user: CurrentUser, result_id: uuid.UUID +) -> Any: + """ + Delete a race result. + Only race organizers or admins can delete results. + """ + result = crud.get_race_result(session=session, result_id=result_id) + if not result: + raise HTTPException(status_code=404, detail="Race result not found") + + # Get registration to verify race + registration = crud.get_race_registration( + session=session, registration_id=result.registration_id + ) + if not registration: + raise HTTPException(status_code=404, detail="Registration not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=registration.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions - only organizer or admin + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + crud.delete_race_result(session=session, result_id=result_id) + return Message(message="Race result deleted successfully") diff --git a/backend/app/api/routes/races.py b/backend/app/api/routes/races.py new file mode 100644 index 0000000000..1c58c5aeb3 --- /dev/null +++ b/backend/app/api/routes/races.py @@ -0,0 +1,620 @@ +import asyncio +import logging +import uuid +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Request +from sqlmodel import SQLModel +from app.api.rate_limit import RateLimiter + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + DifficultyEnum, + Message, + RaceCategoryPublic, + RaceCreate, + RacePublic, + RacePublicWithDetails, + RacePublicWithDistance, + RacePublicWithExplanation, + RacesPublic, + RacesPublicWithDistance, + RacesPublicWithExplanation, + RaceStatusEnum, + RaceTranslationUpdate, + RaceUpdate, + TerrainEnum, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/races", tags=["races"]) + + +async def _invalidate_race_caches() -> None: + """Invalidate cache for race-related keys.""" + from app.services.cache import cache_delete_pattern + + await cache_delete_pattern("trending:*") + await cache_delete_pattern("search:*") + await cache_delete_pattern("tags:*") + + +def _schedule_embedding(race_id: uuid.UUID) -> None: + """Fire-and-forget: compute and persist the race embedding asynchronously.""" + from app.core.db import engine + from app.services.ai import embed_race + from sqlmodel import Session + + async def _run() -> None: + try: + with Session(engine) as session: + race = crud.get_race(session=session, race_id=race_id) + if race is None: + return + vector = await embed_race(race) + crud.update_race_embedding( + session=session, race_id=race_id, embedding=vector + ) + except Exception: + logger.exception("Failed to embed race %s", race_id) + + asyncio.create_task(_run()) + + +# --------------------------------------------------------------------------- +# Discovery / search endpoints (must come before /{race_id}) +# --------------------------------------------------------------------------- + + +@router.get("/search", response_model=RacesPublic) +async def search_races( + session: SessionDep, + q: str | None = Query(default=None, description="Full-text search query"), + lat: float | None = Query(default=None, ge=-90, le=90), + lon: float | None = Query(default=None, ge=-180, le=180), + radius_km: float | None = Query(default=None, gt=0), + distance_min_km: float | None = Query(default=None, gt=0), + distance_max_km: float | None = Query(default=None, gt=0), + terrain: TerrainEnum | None = None, + difficulty: DifficultyEnum | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + tag_slugs: list[str] | None = Query(default=None), + status: RaceStatusEnum | None = None, + province_code: str | None = Query(default=None, description="Filter by province code"), + ward_code: str | None = Query(default=None, description="Filter by ward code"), + sort: str = Query(default="date", pattern="^(date|distance|popularity)$"), + skip: int = Query(default=0, ge=0), + limit: int = Query(default=20, ge=1, le=100), +) -> Any: + """Search races with full-text + semantic vector search (RRF fusion), geo, and filters.""" + from app.services.ai import embed_text + from app.core.config import settings + + # When a text query is provided and embeddings are configured, run semantic search + # in parallel with FTS and merge via Reciprocal Rank Fusion. + vec_ids: list[tuple[uuid.UUID, int]] = [] + if q and settings.OPENAI_API_KEY: + try: + query_embedding = await embed_text(q) + vec_ids = crud.semantic_search_races( + session=session, query_embedding=query_embedding, limit=limit * 2 + ) + except Exception: + logger.warning("Semantic search failed; falling back to FTS only") + + # FTS + filter query + fts_races = crud.search_races( + session=session, + q=q, + lat=lat, + lon=lon, + radius_km=radius_km, + distance_min_km=distance_min_km, + distance_max_km=distance_max_km, + terrain=terrain, + difficulty=difficulty, + date_from=date_from, + date_to=date_to, + tag_slugs=tag_slugs, + status=status, + province_code=province_code, + ward_code=ward_code, + sort=sort, + skip=0, + limit=limit * 2, + ) + + if q and vec_ids: + # RRF fusion: merge semantic and FTS ranked lists + fts_ids = [r.id for r in fts_races] + merged_ids = crud.rrf_merge_race_ids(fts_ids, vec_ids, limit=limit + skip) + merged_ids_page = merged_ids[skip : skip + limit] + + # Preserve order from merge result + id_to_race = {r.id: r for r in fts_races} + # Fetch any vector results not in FTS results + remaining = [rid for rid in merged_ids_page if rid not in id_to_race] + if remaining: + extra = crud.get_races_by_ids(session=session, race_ids=remaining) + id_to_race.update({r.id: r for r in extra}) + + races = [id_to_race[rid] for rid in merged_ids_page if rid in id_to_race] + count = len(merged_ids) + else: + races = fts_races[skip : skip + limit] + count = crud.search_races_count( + session=session, + q=q, + lat=lat, + lon=lon, + radius_km=radius_km, + distance_min_km=distance_min_km, + distance_max_km=distance_max_km, + terrain=terrain, + difficulty=difficulty, + date_from=date_from, + date_to=date_to, + tag_slugs=tag_slugs, + status=status, + province_code=province_code, + ward_code=ward_code, + ) + + return RacesPublic(data=[RacePublic.model_validate(r) for r in races], count=count) + + +@router.get("/nearby", response_model=RacesPublicWithDistance) +def get_nearby_races( + session: SessionDep, + lat: float = Query(..., ge=-90, le=90), + lon: float = Query(..., ge=-180, le=180), + radius_km: float = Query(default=100.0, gt=0), + limit: int = Query(default=20, ge=1, le=100), +) -> Any: + """Return races within radius_km of the given coordinates, sorted by distance.""" + pairs = crud.get_nearby_races( + session=session, lat=lat, lon=lon, radius_km=radius_km, limit=limit + ) + data = [ + RacePublicWithDistance(**race.model_dump(), distance_km=dist_km) + for race, dist_km in pairs + ] + return RacesPublicWithDistance(data=data, count=len(data)) + + +@router.get("/trending", response_model=RacesPublic) +async def get_trending_races( + session: SessionDep, + days: int = Query(default=7, ge=1, le=90), + limit: int = Query(default=10, ge=1, le=50), +) -> Any: + """Return trending races based on interaction count over the last N days.""" + from app.services.cache import cache_get, cache_set + + cache_key = f"trending:{days}:{limit}" + cached = await cache_get(cache_key) + if cached is not None: + return cached + + races = crud.get_trending_races(session=session, days=days, limit=limit) + result = RacesPublic( + data=[RacePublic.model_validate(r) for r in races], count=len(races) + ) + await cache_set(cache_key, result.model_dump(), ttl=300) # 5 min TTL + return result + + +@router.get( + "/recommended", + response_model=RacesPublicWithExplanation, + dependencies=[RateLimiter(max_calls=30, window_seconds=60)], +) +async def get_recommended_races( + session: SessionDep, + current_user: CurrentUser, + limit: int = Query(default=10, ge=1, le=50), +) -> Any: + """Return personalized race recommendations with AI-generated explanations.""" + from app.services.ai import generate_race_recommendation_explanation + from app.core.config import settings + + races = crud.get_recommended_races( + session=session, user_id=current_user.id, limit=limit + ) + profile = crud.get_user_profile(session=session, user_id=current_user.id) + + results: list[RacePublicWithExplanation] = [] + for race in races: + explanation: str | None = None + if settings.ANTHROPIC_API_KEY: + try: + explanation = await generate_race_recommendation_explanation( + race=race, profile=profile + ) + except Exception: + logger.warning("Failed to generate explanation for race %s", race.id) + results.append( + RacePublicWithExplanation(**race.model_dump(), ai_explanation=explanation) + ) + + return RacesPublicWithExplanation(data=results, count=len(results)) + + +@router.get("/my/organized", response_model=RacesPublic) +def read_my_organized_races( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve races organized by the current user. + Requires organizer or admin role. + """ + if not current_user.is_superuser and not crud.user_has_any_role( + current_user, ["organizer", "admin"] + ): + raise HTTPException( + status_code=403, + detail="Only organizers and admins can access this endpoint", + ) + + races = crud.get_races( + session=session, skip=skip, limit=limit, organizer_id=current_user.id + ) + count = crud.get_races_count(session=session, organizer_id=current_user.id) + races_public = [RacePublic.model_validate(race) for race in races] + return RacesPublic(data=races_public, count=count) + + +# --------------------------------------------------------------------------- +# Collection + item endpoints +# --------------------------------------------------------------------------- + + +@router.get("/", response_model=RacesPublic) +def read_races( + session: SessionDep, + skip: int = 0, + limit: int = 100, + organizer_id: uuid.UUID | None = None, +) -> Any: + """ + Retrieve races. Public endpoint - anyone can view races. + Optionally filter by organizer_id. + """ + races = crud.get_races( + session=session, skip=skip, limit=limit, organizer_id=organizer_id + ) + count = crud.get_races_count(session=session, organizer_id=organizer_id) + races_public = [RacePublic.model_validate(race) for race in races] + return RacesPublic(data=races_public, count=count) + + +# AI Assistant endpoint +class RaceNameInput(SQLModel): + name: str + + +class AIRaceSuggestion(SQLModel): + description: str | None = None + location: str | None = None + terrain_type: str | None = None + difficulty_level: str | None = None + elevation_gain_m: str | None = None + + +@router.post("/ai-assist", response_model=AIRaceSuggestion) +async def generate_race_details( + *, + current_user: CurrentUser, + race_name_input: RaceNameInput, +) -> Any: + """ + Use AI to generate race details from a race name. + Requires authentication. + """ + from app.services.ai import generate_race_from_name + from app.core.config import settings + + if not settings.OPENAI_API_KEY: + raise HTTPException( + status_code=503, + detail="AI assistance is not configured. Please set OPENAI_API_KEY.", + ) + + details = await generate_race_from_name(race_name_input.name) + return AIRaceSuggestion(**details) + + +@router.post("/", response_model=RacePublic) +def create_race( + *, + session: SessionDep, + current_user: CurrentUser, + race_in: RaceCreate, + background_tasks: BackgroundTasks, +) -> Any: + """ + Create new race. + Requires organizer or admin role. + """ + if not current_user.is_superuser and not crud.user_has_any_role( + current_user, ["organizer", "admin"] + ): + raise HTTPException( + status_code=403, detail="Only organizers and admins can create races" + ) + + race = crud.create_race( + session=session, race_in=race_in, organizer_id=current_user.id + ) + background_tasks.add_task(_schedule_embedding, race.id) + background_tasks.add_task(_invalidate_race_caches) + return race + + +@router.get("/{race_id}", response_model=RacePublicWithDetails) +def read_race(session: SessionDep, race_id: uuid.UUID) -> Any: + """ + Get race by ID with details. Public endpoint. + """ + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + categories = crud.get_race_categories(session=session, race_id=race_id) + categories_public = [RaceCategoryPublic.model_validate(cat) for cat in categories] + registration_count = crud.get_race_registrations_count( + session=session, race_id=race_id + ) + tags = crud.get_race_tags(session=session, race_id=race_id) + + return RacePublicWithDetails( + **race.model_dump(), + categories=categories_public, + registration_count=registration_count, + tags=tags, + ) + + +@router.get("/{race_id}/similar", response_model=RacesPublic) +def get_similar_races( + session: SessionDep, + race_id: uuid.UUID, + limit: int = Query(default=6, ge=1, le=20), +) -> Any: + """Return races similar to the given race.""" + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + races = crud.get_similar_races(session=session, race=race, limit=limit) + return RacesPublic( + data=[RacePublic.model_validate(r) for r in races], count=len(races) + ) + + +@router.put("/{race_id}", response_model=RacePublic) +def update_race( + *, + session: SessionDep, + current_user: CurrentUser, + race_id: uuid.UUID, + race_in: RaceUpdate, + background_tasks: BackgroundTasks, +) -> Any: + """ + Update a race. + Only the organizer or admin can update. + """ + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + race = crud.update_race(session=session, db_race=race, race_in=race_in) + background_tasks.add_task(_schedule_embedding, race.id) + background_tasks.add_task(_invalidate_race_caches) + return race + + +@router.delete("/{race_id}", response_model=Message) +def delete_race( + *, + session: SessionDep, + current_user: CurrentUser, + race_id: uuid.UUID, + background_tasks: BackgroundTasks, +) -> Any: + """ + Delete a race. + Only the organizer or admin can delete. + """ + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + crud.delete_race(session=session, race_id=race_id) + background_tasks.add_task(_invalidate_race_caches) + return Message(message="Race deleted successfully") + + +# --------------------------------------------------------------------------- +# AI endpoints +# --------------------------------------------------------------------------- + + +class TagSuggestion(SQLModel): + tags: list[str] + + +class DescriptionSuggestion(SQLModel): + description: str + + +class RaceAnswer(SQLModel): + answer: str + + +class AskRequest(SQLModel): + question: str + + +@router.post("/{race_id}/auto-tag", response_model=TagSuggestion) +async def auto_tag_race(session: SessionDep, race_id: uuid.UUID) -> Any: + """Suggest tags for a race using AI (does not save — returns suggestions only).""" + from app.services.ai import suggest_race_tags + + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + tags = await suggest_race_tags(race) + return TagSuggestion(tags=tags) + + +@router.post( + "/{race_id}/enhance-description", + response_model=DescriptionSuggestion, + dependencies=[RateLimiter(max_calls=5, window_seconds=60)], +) +async def enhance_race_description(session: SessionDep, race_id: uuid.UUID) -> Any: + """Suggest an improved description using AI (does not save — returns suggestion only).""" + from app.services.ai import enhance_race_description as _enhance + + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + description = await _enhance(race) + return DescriptionSuggestion(description=description) + + + +@router.post( + "/{race_id}/ask", + response_model=RaceAnswer, + dependencies=[RateLimiter(max_calls=10, window_seconds=60)], +) +async def ask_race_question( + session: SessionDep, + race_id: uuid.UUID, + body: AskRequest, +) -> Any: + """Answer a question about a specific race using AI. Rate limited to 10 req/min per IP.""" + from app.services.ai import answer_race_question + + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + answer = await answer_race_question(race=race, question=body.question) + return RaceAnswer(answer=answer) + + +@router.put("/{race_id}/translations", response_model=RacePublic) +def update_race_translations( + *, + session: SessionDep, + current_user: CurrentUser, + race_id: uuid.UUID, + translation: RaceTranslationUpdate, + background_tasks: BackgroundTasks, +) -> Any: + """ + Update translations for a race. + Only race organizer or admin can update translations. + """ + from app.i18n import is_language_supported, set_translation + + # Get the race + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions - only organizer or admin + if race.organizer_id != current_user.id and not current_user.is_superuser: + raise HTTPException( + status_code=403, + detail="Only race organizer or admin can update translations" + ) + + # Validate language + if not is_language_supported(translation.language): + raise HTTPException( + status_code=400, + detail=f"Language '{translation.language}' is not supported" + ) + + # Update translations + if translation.name: + set_translation(race, "name", translation.name, translation.language) + if translation.description: + set_translation(race, "description", translation.description, translation.language) + if translation.location: + set_translation(race, "location", translation.location, translation.language) + + session.add(race) + session.commit() + session.refresh(race) + + background_tasks.add_task(_invalidate_race_caches) + + return race + + +@router.get("/{race_id}/translations", response_model=dict[str, Any]) +def get_race_translations( + *, + session: SessionDep, + race_id: uuid.UUID, +) -> Any: + """ + Get all translations for a race. + Public endpoint - anyone can view translations. + """ + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + return race.translations or {} + + +# --------------------------------------------------------------------------- +# Admin utilities +# --------------------------------------------------------------------------- + +admin_router = APIRouter(prefix="/admin/races", tags=["admin"]) + + +@admin_router.post("/reindex", response_model=Message) +def reindex_race_embeddings( + *, + session: SessionDep, + current_user: CurrentUser, + background_tasks: BackgroundTasks, + batch_size: int = Query(default=50, ge=1, le=200), +) -> Any: + """ + Queue embedding generation for all races that lack an embedding. + Admin only. + """ + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Admin access required") + + races_to_index = crud.get_races_without_embedding( + session=session, limit=batch_size + ) + for race in races_to_index: + background_tasks.add_task(_schedule_embedding, race.id) + + return Message( + message=f"Queued {len(races_to_index)} race(s) for embedding generation" + ) diff --git a/backend/app/api/routes/roles.py b/backend/app/api/routes/roles.py new file mode 100644 index 0000000000..68a66922db --- /dev/null +++ b/backend/app/api/routes/roles.py @@ -0,0 +1,182 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import func, select + +from app import crud +from app.api.deps import SessionDep, get_current_active_superuser +from app.models import ( + Message, + Role, + RoleCreate, + RolePublic, + RolesPublic, + RoleUpdate, + User, + UserPublic, +) + +router = APIRouter(prefix="/roles", tags=["roles"]) + + +@router.get( + "/", + dependencies=[Depends(get_current_active_superuser)], + response_model=RolesPublic, +) +def read_roles( + session: SessionDep, + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Retrieve roles. Admin only. + """ + count_statement = select(func.count()).select_from(Role) + count = session.exec(count_statement).one() + + statement = select(Role).offset(skip).limit(limit) + roles = session.exec(statement).all() + + return RolesPublic( + data=[RolePublic.model_validate(role) for role in roles], count=count + ) + + +@router.get( + "/{role_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=RolePublic, +) +def read_role(session: SessionDep, role_id: uuid.UUID) -> Any: + """ + Get role by ID. Admin only. + """ + role = session.get(Role, role_id) + if not role: + raise HTTPException(status_code=404, detail="Role not found") + return role + + +@router.post( + "/", + dependencies=[Depends(get_current_active_superuser)], + response_model=RolePublic, +) +def create_role(*, session: SessionDep, role_in: RoleCreate) -> Any: + """ + Create new role. Admin only. + """ + # Check if role already exists + existing_role = crud.get_role_by_name(session=session, name=role_in.name) + if existing_role: + raise HTTPException( + status_code=400, + detail="A role with this name already exists", + ) + + role = crud.create_role(session=session, role_create=role_in) + return role + + +@router.put( + "/{role_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=RolePublic, +) +def update_role( + *, + session: SessionDep, + role_id: uuid.UUID, + role_in: RoleUpdate, +) -> Any: + """ + Update a role. Admin only. + """ + role = session.get(Role, role_id) + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + # Check if new name conflicts with existing role + if role_in.name and role_in.name != role.name: + existing_role = crud.get_role_by_name(session=session, name=role_in.name) + if existing_role: + raise HTTPException( + status_code=400, + detail="A role with this name already exists", + ) + + update_dict = role_in.model_dump(exclude_unset=True) + role.sqlmodel_update(update_dict) + session.add(role) + session.commit() + session.refresh(role) + return role + + +@router.delete( + "/{role_id}", + dependencies=[Depends(get_current_active_superuser)], +) +def delete_role(session: SessionDep, role_id: uuid.UUID) -> Message: + """ + Delete a role. Admin only. + """ + role = session.get(Role, role_id) + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + session.delete(role) + session.commit() + return Message(message="Role deleted successfully") + + +@router.post( + "/{role_id}/assign/{user_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=UserPublic, +) +def assign_role_to_user( + session: SessionDep, + role_id: uuid.UUID, + user_id: uuid.UUID, +) -> Any: + """ + Assign a role to a user. Admin only. + """ + role = session.get(Role, role_id) + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user = crud.assign_role_to_user(session=session, user=user, role=role) + return user + + +@router.delete( + "/{role_id}/remove/{user_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=UserPublic, +) +def remove_role_from_user( + session: SessionDep, + role_id: uuid.UUID, + user_id: uuid.UUID, +) -> Any: + """ + Remove a role from a user. Admin only. + """ + role = session.get(Role, role_id) + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user = crud.remove_role_from_user(session=session, user=user, role=role) + return user diff --git a/backend/app/api/routes/tags.py b/backend/app/api/routes/tags.py new file mode 100644 index 0000000000..fb8651b07d --- /dev/null +++ b/backend/app/api/routes/tags.py @@ -0,0 +1,117 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import RaceTagCreate, TagPublic, TagsPublic, TagTranslationUpdate + +router = APIRouter(prefix="/tags", tags=["tags"]) + + +@router.get("/", response_model=TagsPublic) +async def list_tags(session: SessionDep) -> Any: + """List all available race tags. Public endpoint. Cached 10 minutes.""" + from app.services.cache import cache_get, cache_set + + cache_key = "tags:all" + cached = await cache_get(cache_key) + if cached is not None: + return cached + + tags = crud.get_all_tags(session=session) + count = crud.get_all_tags_count(session=session) + result = TagsPublic(data=[TagPublic.model_validate(t) for t in tags], count=count) + await cache_set(cache_key, result.model_dump(), ttl=600) # 10 min TTL + return result + + +@router.post("/", response_model=TagPublic) +def create_tag( + *, session: SessionDep, current_user: CurrentUser, tag_in: RaceTagCreate +) -> Any: + """Create a new tag. Admin only.""" + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Admin access required") + existing = crud.get_tag_by_slug(session=session, slug=tag_in.slug) + if existing: + raise HTTPException(status_code=409, detail="Tag with this slug already exists") + tag = crud.get_or_create_tag(session=session, tag_in=tag_in) + return TagPublic.model_validate(tag) + + +@router.post("/{race_id}/tags", response_model=list[TagPublic]) +def set_tags_for_race( + *, + session: SessionDep, + current_user: CurrentUser, + race_id: uuid.UUID, + tag_ids: list[uuid.UUID], +) -> Any: + """Replace the full tag list on a race. Organizer or admin only.""" + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + race = crud.set_race_tags(session=session, race=race, tag_ids=tag_ids) + return [TagPublic.model_validate(t) for t in race.tags] + + +@router.put("/{tag_id}/translations", response_model=TagPublic) +def update_tag_translations( + *, + session: SessionDep, + current_user: CurrentUser, + tag_id: uuid.UUID, + translation: TagTranslationUpdate, +) -> Any: + """ + Update translations for a tag. + Admin only. + """ + from app.i18n import is_language_supported, set_translation + + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Admin access required") + + # Get the tag + tag = crud.get_tag(session=session, tag_id=tag_id) + if not tag: + raise HTTPException(status_code=404, detail="Tag not found") + + # Validate language + if not is_language_supported(translation.language): + raise HTTPException( + status_code=400, + detail=f"Language '{translation.language}' is not supported" + ) + + # Update translations + if translation.name: + set_translation(tag, "name", translation.name, translation.language) + + session.add(tag) + session.commit() + session.refresh(tag) + + return TagPublic.model_validate(tag) + + +@router.get("/{tag_id}/translations", response_model=dict[str, Any]) +def get_tag_translations( + *, + session: SessionDep, + tag_id: uuid.UUID, +) -> Any: + """ + Get all translations for a tag. + Public endpoint - anyone can view translations. + """ + tag = crud.get_tag(session=session, tag_id=tag_id) + if not tag: + raise HTTPException(status_code=404, detail="Tag not found") + + return tag.translations or {} + diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 650b9f7910..de31b9ee2f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -94,6 +94,19 @@ def emails_enabled(self) -> bool: FIRST_SUPERUSER: EmailStr FIRST_SUPERUSER_PASSWORD: str + # Media uploads + MEDIA_UPLOAD_DIR: str = "uploads/media" + MEDIA_MAX_FILE_SIZE_MB: int = 15 + + # AI / embeddings + ANTHROPIC_API_KEY: str | None = None + OPENAI_API_KEY: str | None = None + EMBEDDING_MODEL: str = "text-embedding-3-small" + EMBEDDING_DIMENSIONS: int = 1536 + + # Redis + REDIS_URL: str = "redis://localhost:6379" + def _check_default_secret(self, var_name: str, value: str | None) -> None: if value == "changethis": message = ( diff --git a/backend/app/core/db.py b/backend/app/core/db.py index ba991fb36d..9c9564b2b1 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -2,7 +2,7 @@ from app import crud from app.core.config import settings -from app.models import User, UserCreate +from app.models import RoleEnum, User, UserCreate engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) @@ -21,6 +21,22 @@ def init_db(session: Session) -> None: # This works because the models are already imported and registered from app.models # SQLModel.metadata.create_all(engine) + # Create default roles + roles_to_create = [ + (RoleEnum.ADMIN, "System administrator with full access"), + (RoleEnum.RUNNER, "Regular user who can register for races"), + (RoleEnum.ORGANIZER, "Race organizer who can create and manage races"), + (RoleEnum.VOLUNTEER, "Volunteer who can help with race operations"), + ] + + for role_name, description in roles_to_create: + crud.get_or_create_role( + session=session, + role_name=role_name.value, + description=description + ) + + # Create first superuser user = session.exec( select(User).where(User.email == settings.FIRST_SUPERUSER) ).first() @@ -30,4 +46,9 @@ def init_db(session: Session) -> None: password=settings.FIRST_SUPERUSER_PASSWORD, is_superuser=True, ) - user = crud.create_user(session=session, user_create=user_in) + user = crud.create_user(session=session, user_create=user_in, default_role=None) + + # Assign admin role to superuser + admin_role = crud.get_role_by_name(session=session, name=RoleEnum.ADMIN.value) + if admin_role: + crud.assign_role_to_user(session=session, user=user, role=admin_role) diff --git a/backend/app/crud.py b/backend/app/crud.py index a8ceba6444..d62c0edfde 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,19 +1,135 @@ import uuid +from datetime import datetime, timedelta, timezone from typing import Any -from sqlmodel import Session, select +from sqlalchemy import or_ +from sqlmodel import Session, col, func, select, text from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, User, UserCreate, UserUpdate +from app.models import ( + DifficultyEnum, + DistancePrefEnum, + InteractionTypeEnum, + Item, + ItemCreate, + MediaAsset, + MediaAssetCreate, + MediaAssetUpdate, + Province, + Race, + RaceAttribute, + RaceAttributeCreate, + RaceAttributeUpdate, + RaceCategory, + RaceCategoryCreate, + RaceCategoryUpdate, + RaceCheckpoint, + RaceCheckpointCreate, + RaceCheckpointUpdate, + RaceCreate, + RaceRegistration, + RaceRegistrationCreate, + RaceRegistrationUpdate, + RaceResult, + RaceResultCreate, + RaceResultUpdate, + RaceSplitTime, + RaceSplitTimeCreate, + RaceStatusEnum, + RaceTag, + RaceTagCreate, + RaceTagLink, + RaceUpdate, + Role, + RoleCreate, + TerrainEnum, + User, + UserCreate, + UserProfile, + UserProfileCreate, + UserProfileUpdate, + UserRaceInteraction, + UserUpdate, + Ward, +) -def create_user(*, session: Session, user_create: UserCreate) -> User: +# Role CRUD operations +def create_role(*, session: Session, role_create: RoleCreate) -> Role: + db_obj = Role.model_validate(role_create) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + +def get_role_by_name(*, session: Session, name: str) -> Role | None: + statement = select(Role).where(Role.name == name) + return session.exec(statement).first() + + +def get_or_create_role( + *, session: Session, role_name: str, description: str | None = None +) -> Role: + """Get existing role or create it if it doesn't exist.""" + role = get_role_by_name(session=session, name=role_name) + if not role: + role = create_role( + session=session, + role_create=RoleCreate(name=role_name, description=description), + ) + return role + + +def assign_role_to_user(*, session: Session, user: User, role: Role) -> User: + """Assign a role to a user.""" + if role not in user.roles: + user.roles.append(role) + session.add(user) + session.commit() + session.refresh(user) + return user + + +def remove_role_from_user(*, session: Session, user: User, role: Role) -> User: + """Remove a role from a user.""" + if role in user.roles: + user.roles.remove(role) + session.add(user) + session.commit() + session.refresh(user) + return user + + +def user_has_role(user: User, role_name: str) -> bool: + """Check if user has a specific role.""" + return any(role.name == role_name for role in user.roles) + + +def user_has_any_role(user: User, role_names: list[str]) -> bool: + """Check if user has any of the specified roles.""" + return any(role.name in role_names for role in user.roles) + + +# User CRUD operations + + +def create_user( + *, session: Session, user_create: UserCreate, default_role: str | None = "runner" +) -> User: db_obj = User.model_validate( user_create, update={"hashed_password": get_password_hash(user_create.password)} ) session.add(db_obj) session.commit() session.refresh(db_obj) + + # Assign default role if specified + if default_role: + role = get_role_by_name(session=session, name=default_role) + if role: + assign_role_to_user(session=session, user=db_obj, role=role) + return db_obj @@ -66,3 +182,1253 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) - session.commit() session.refresh(db_item) return db_item + + +# ============================================================================= +# Race CRUD operations +# ============================================================================= + + +def create_race( + *, session: Session, race_in: RaceCreate, organizer_id: uuid.UUID +) -> Race: + """Create a new race.""" + race_data = race_in.model_dump() + + # Populate province_name if province_code is provided + if race_data.get("province_code"): + province = session.get(Province, race_data["province_code"]) + if province: + race_data["province_name"] = province.name + + # Populate ward_name if ward_code is provided + if race_data.get("ward_code"): + ward = session.get(Ward, race_data["ward_code"]) + if ward: + race_data["ward_name"] = ward.name + + # Set country_code for Vietnam races + if race_data.get("country") == "Vietnam" or not race_data.get("country_code"): + race_data["country_code"] = "VN" + + db_race = Race.model_validate(race_data, update={"organizer_id": organizer_id}) + session.add(db_race) + session.commit() + session.refresh(db_race) + return db_race + + +def get_race(*, session: Session, race_id: uuid.UUID) -> Race | None: + """Get a race by ID.""" + return session.get(Race, race_id) + + +def get_races( + *, + session: Session, + skip: int = 0, + limit: int = 100, + organizer_id: uuid.UUID | None = None, +) -> list[Race]: + """Get races with pagination, optionally filtered by organizer.""" + statement = select(Race).offset(skip).limit(limit) + if organizer_id: + statement = statement.where(Race.organizer_id == organizer_id) + statement = statement.order_by(col(Race.event_start_date).desc()) + return list(session.exec(statement).all()) + + +def get_races_count(*, session: Session, organizer_id: uuid.UUID | None = None) -> int: + """Get total count of races.""" + statement = select(func.count(Race.id)) + if organizer_id: + statement = statement.where(Race.organizer_id == organizer_id) + return session.exec(statement).one() + + +def update_race(*, session: Session, db_race: Race, race_in: RaceUpdate) -> Race: + """Update a race.""" + race_data = race_in.model_dump(exclude_unset=True) + tag_ids = race_data.pop("tag_ids", None) + + # Populate province_name if province_code is being updated + if "province_code" in race_data and race_data["province_code"]: + province = session.get(Province, race_data["province_code"]) + if province: + race_data["province_name"] = province.name + elif "province_code" in race_data and race_data["province_code"] is None: + # Clear province_name if province_code is being cleared + race_data["province_name"] = None + + # Populate ward_name if ward_code is being updated + if "ward_code" in race_data and race_data["ward_code"]: + ward = session.get(Ward, race_data["ward_code"]) + if ward: + race_data["ward_name"] = ward.name + elif "ward_code" in race_data and race_data["ward_code"] is None: + # Clear ward_name if ward_code is being cleared + race_data["ward_name"] = None + + # Update country_code if country is being updated to Vietnam + if "country" in race_data: + if race_data["country"] == "Vietnam": + race_data["country_code"] = "VN" + elif race_data["country"] is None: + race_data["country_code"] = None + + race_data["updated_at"] = datetime.now(timezone.utc) + db_race.sqlmodel_update(race_data) + session.add(db_race) + session.commit() + session.refresh(db_race) + if tag_ids is not None: + set_race_tags(session=session, race=db_race, tag_ids=tag_ids) + return db_race + + +def delete_race(*, session: Session, race_id: uuid.UUID) -> bool: + """Delete a race.""" + race = session.get(Race, race_id) + if race: + session.delete(race) + session.commit() + return True + return False + + +def update_race_embedding( + *, session: Session, race_id: uuid.UUID, embedding: list[float] +) -> None: + """Persist a pre-computed embedding vector on a race row.""" + race = session.get(Race, race_id) + if race: + race.embedding = embedding + session.add(race) + session.commit() + + +def get_races_without_embedding( + *, session: Session, limit: int = 100 +) -> list[Race]: + """Return races that have no embedding yet (for batch reindexing).""" + return list( + session.exec(select(Race).where(Race.embedding == None).limit(limit)).all() # noqa: E711 + ) + + +# ============================================================================= +# MediaAsset CRUD operations +# ============================================================================= + + +def create_media_asset(*, session: Session, media_in: MediaAssetCreate) -> MediaAsset: + """Create a media asset.""" + db_media = MediaAsset.model_validate(media_in) + session.add(db_media) + session.commit() + session.refresh(db_media) + return db_media + + +def get_media_asset(*, session: Session, media_id: uuid.UUID) -> MediaAsset | None: + """Get a media asset by ID.""" + return session.get(MediaAsset, media_id) + + +def get_media_assets( + *, + session: Session, + content_type: str | None = None, + content_id: uuid.UUID | None = None, + kind: str | None = None, + is_public: bool | None = None, + skip: int = 0, + limit: int = 200, +) -> list[MediaAsset]: + """Get media assets with optional filtering.""" + statement = select(MediaAsset) + if content_type: + statement = statement.where(MediaAsset.content_type == content_type) + if content_id: + statement = statement.where(MediaAsset.content_id == content_id) + if kind: + statement = statement.where(MediaAsset.kind == kind) + if is_public is not None: + statement = statement.where(MediaAsset.is_public == is_public) + + statement = ( + statement.order_by( + col(MediaAsset.display_order), + col(MediaAsset.created_at).desc(), + ) + .offset(skip) + .limit(limit) + ) + return list(session.exec(statement).all()) + + +def get_media_assets_count( + *, + session: Session, + content_type: str | None = None, + content_id: uuid.UUID | None = None, + kind: str | None = None, + is_public: bool | None = None, +) -> int: + """Get count of media assets with optional filtering.""" + statement = select(func.count(MediaAsset.id)) + if content_type: + statement = statement.where(MediaAsset.content_type == content_type) + if content_id: + statement = statement.where(MediaAsset.content_id == content_id) + if kind: + statement = statement.where(MediaAsset.kind == kind) + if is_public is not None: + statement = statement.where(MediaAsset.is_public == is_public) + return session.exec(statement).one() + + +def clear_primary_media( + *, + session: Session, + content_type: str, + content_id: uuid.UUID, + kind: str, + exclude_id: uuid.UUID | None = None, +) -> None: + """Ensure only one media item is primary for a given content_type/content_id/kind.""" + statement = select(MediaAsset).where( + MediaAsset.content_type == content_type, + MediaAsset.content_id == content_id, + MediaAsset.kind == kind, + MediaAsset.is_primary, + ) + if exclude_id: + statement = statement.where(MediaAsset.id != exclude_id) + + existing_primary = list(session.exec(statement).all()) + for media in existing_primary: + media.is_primary = False + media.updated_at = datetime.now(timezone.utc) + session.add(media) + if existing_primary: + session.commit() + + +def update_media_asset( + *, session: Session, db_media: MediaAsset, media_in: MediaAssetUpdate +) -> MediaAsset: + """Update a media asset.""" + media_data = media_in.model_dump(exclude_unset=True) + media_data["updated_at"] = datetime.now(timezone.utc) + db_media.sqlmodel_update(media_data) + session.add(db_media) + session.commit() + session.refresh(db_media) + return db_media + + +def delete_media_asset(*, session: Session, media_id: uuid.UUID) -> bool: + """Delete a media asset.""" + media = session.get(MediaAsset, media_id) + if media: + session.delete(media) + session.commit() + return True + return False + + +# ============================================================================= +# RaceCategory CRUD operations +# ============================================================================= + + +def create_race_category( + *, session: Session, category_in: RaceCategoryCreate +) -> RaceCategory: + """Create a new race category.""" + db_category = RaceCategory.model_validate(category_in) + session.add(db_category) + session.commit() + session.refresh(db_category) + return db_category + + +def get_race_category( + *, session: Session, category_id: uuid.UUID +) -> RaceCategory | None: + """Get a race category by ID.""" + return session.get(RaceCategory, category_id) + + +def get_race_categories( + *, session: Session, race_id: uuid.UUID, skip: int = 0, limit: int = 100 +) -> list[RaceCategory]: + """Get all categories for a race.""" + statement = ( + select(RaceCategory) + .where(RaceCategory.race_id == race_id) + .order_by(col(RaceCategory.display_order), col(RaceCategory.distance_km)) + .offset(skip) + .limit(limit) + ) + return list(session.exec(statement).all()) + + +def update_race_category( + *, session: Session, db_category: RaceCategory, category_in: RaceCategoryUpdate +) -> RaceCategory: + """Update a race category.""" + category_data = category_in.model_dump(exclude_unset=True) + category_data["updated_at"] = datetime.now(timezone.utc) + db_category.sqlmodel_update(category_data) + session.add(db_category) + session.commit() + session.refresh(db_category) + return db_category + + +def delete_race_category(*, session: Session, category_id: uuid.UUID) -> bool: + """Delete a race category.""" + category = session.get(RaceCategory, category_id) + if category: + session.delete(category) + session.commit() + return True + return False + + +def get_category_registration_count(*, session: Session, category_id: uuid.UUID) -> int: + """Get registration count for a category.""" + statement = select(func.count(RaceRegistration.id)).where( + RaceRegistration.category_id == category_id + ) + return session.exec(statement).one() + + +def get_category_registration_window( + category: RaceCategory, race: Race +) -> tuple[datetime | None, datetime | None]: + """ + Get effective registration start/end for a category. + Category-specific dates override race defaults. + """ + start = category.registration_start or race.registration_start + end = category.registration_end or race.registration_end + return start, end + + +def is_category_registration_open( + category: RaceCategory, + race: Race, + session: Session, + check_time: datetime | None = None, +) -> bool: + """Check if registration is currently open for a category.""" + if check_time is None: + check_time = datetime.now(timezone.utc) + + start, end = get_category_registration_window(category, race) + + # Check if within registration window + if start and check_time < start: + return False + if end and check_time > end: + return False + + # Check if category is full + if category.max_participants: + count = get_category_registration_count( + session=session, category_id=category.id + ) + if count >= category.max_participants: + return False + + # Check if category and race are active + if not category.is_active or not race.is_active: + return False + + return True + + +def get_category_current_price( + category: RaceCategory, race: Race, check_time: datetime | None = None +) -> float | None: + """ + Get current price for a category. + Returns early bird price if applicable, otherwise regular price. + """ + if check_time is None: + check_time = datetime.now(timezone.utc) + + # Check early bird pricing + if ( + category.early_bird_price is not None + and category.early_bird_deadline + and check_time <= category.early_bird_deadline + ): + return category.early_bird_price + + # Return category price or race default + return category.price or race.base_price + + +def get_category_available_spots( + *, session: Session, category_id: uuid.UUID, max_participants: int | None +) -> int | None: + """Get number of available spots for a category.""" + if max_participants is None: + return None + + count = get_category_registration_count(session=session, category_id=category_id) + return max(0, max_participants - count) + + +# ============================================================================= +# RaceRegistration CRUD operations +# ============================================================================= + + +def create_race_registration( + *, + session: Session, + registration_in: RaceRegistrationCreate, + runner_id: uuid.UUID, +) -> RaceRegistration: + """Create a new race registration.""" + db_registration = RaceRegistration.model_validate( + registration_in, update={"runner_id": runner_id} + ) + session.add(db_registration) + session.commit() + session.refresh(db_registration) + return db_registration + + +def get_race_registration( + *, session: Session, registration_id: uuid.UUID +) -> RaceRegistration | None: + """Get a race registration by ID.""" + return session.get(RaceRegistration, registration_id) + + +def get_race_registrations( + *, + session: Session, + race_id: uuid.UUID | None = None, + runner_id: uuid.UUID | None = None, + category_id: uuid.UUID | None = None, + skip: int = 0, + limit: int = 100, +) -> list[RaceRegistration]: + """Get race registrations with filters.""" + statement = select(RaceRegistration).offset(skip).limit(limit) + + if race_id: + statement = statement.where(RaceRegistration.race_id == race_id) + if runner_id: + statement = statement.where(RaceRegistration.runner_id == runner_id) + if category_id: + statement = statement.where(RaceRegistration.category_id == category_id) + + statement = statement.order_by(col(RaceRegistration.registered_at).desc()) + return list(session.exec(statement).all()) + + +def get_race_registrations_count( + *, + session: Session, + race_id: uuid.UUID | None = None, + runner_id: uuid.UUID | None = None, + category_id: uuid.UUID | None = None, +) -> int: + """Get count of race registrations with filters.""" + statement = select(func.count(RaceRegistration.id)) + + if race_id: + statement = statement.where(RaceRegistration.race_id == race_id) + if runner_id: + statement = statement.where(RaceRegistration.runner_id == runner_id) + if category_id: + statement = statement.where(RaceRegistration.category_id == category_id) + + return session.exec(statement).one() + + +def update_race_registration( + *, + session: Session, + db_registration: RaceRegistration, + registration_in: RaceRegistrationUpdate, +) -> RaceRegistration: + """Update a race registration.""" + registration_data = registration_in.model_dump(exclude_unset=True) + registration_data["updated_at"] = datetime.now(timezone.utc) + db_registration.sqlmodel_update(registration_data) + session.add(db_registration) + session.commit() + session.refresh(db_registration) + return db_registration + + +def delete_race_registration(*, session: Session, registration_id: uuid.UUID) -> bool: + """Delete a race registration.""" + registration = session.get(RaceRegistration, registration_id) + if registration: + session.delete(registration) + session.commit() + return True + return False + + +def check_existing_registration( + *, session: Session, runner_id: uuid.UUID, race_id: uuid.UUID +) -> RaceRegistration | None: + """Check if a runner is already registered for a race.""" + statement = select(RaceRegistration).where( + RaceRegistration.runner_id == runner_id, + RaceRegistration.race_id == race_id, + ) + return session.exec(statement).first() + + +# ============================================================================= +# RaceResult CRUD operations +# ============================================================================= + + +def create_race_result(*, session: Session, result_in: RaceResultCreate) -> RaceResult: + """Create a new race result.""" + db_result = RaceResult.model_validate(result_in) + session.add(db_result) + session.commit() + session.refresh(db_result) + return db_result + + +def get_race_result(*, session: Session, result_id: uuid.UUID) -> RaceResult | None: + """Get a race result by ID.""" + return session.get(RaceResult, result_id) + + +def get_race_result_by_registration( + *, session: Session, registration_id: uuid.UUID +) -> RaceResult | None: + """Get race result by registration ID.""" + statement = select(RaceResult).where(RaceResult.registration_id == registration_id) + return session.exec(statement).first() + + +def get_race_results( + *, session: Session, race_id: uuid.UUID, skip: int = 0, limit: int = 100 +) -> list[RaceResult]: + """Get all results for a race.""" + statement = ( + select(RaceResult) + .join(RaceRegistration) + .where(RaceRegistration.race_id == race_id) + .order_by(col(RaceResult.overall_position)) + .offset(skip) + .limit(limit) + ) + return list(session.exec(statement).all()) + + +def update_race_result( + *, session: Session, db_result: RaceResult, result_in: RaceResultUpdate +) -> RaceResult: + """Update a race result.""" + result_data = result_in.model_dump(exclude_unset=True) + result_data["updated_at"] = datetime.now(timezone.utc) + db_result.sqlmodel_update(result_data) + session.add(db_result) + session.commit() + session.refresh(db_result) + return db_result + + +def delete_race_result(*, session: Session, result_id: uuid.UUID) -> bool: + """Delete a race result.""" + result = session.get(RaceResult, result_id) + if result: + session.delete(result) + session.commit() + return True + return False + + +# ============================================================================= +# RaceAttribute CRUD operations +# ============================================================================= + + +def create_race_attribute( + *, session: Session, attribute_in: RaceAttributeCreate +) -> RaceAttribute: + """Create a new race attribute.""" + db_attribute = RaceAttribute.model_validate(attribute_in) + session.add(db_attribute) + session.commit() + session.refresh(db_attribute) + return db_attribute + + +def get_race_attribute( + *, session: Session, attribute_id: uuid.UUID +) -> RaceAttribute | None: + """Get a race attribute by ID.""" + return session.get(RaceAttribute, attribute_id) + + +def get_race_attributes( + *, session: Session, race_id: uuid.UUID, is_public: bool | None = None +) -> list[RaceAttribute]: + """Get all attributes for a race.""" + statement = select(RaceAttribute).where(RaceAttribute.race_id == race_id) + + if is_public is not None: + statement = statement.where(RaceAttribute.is_public == is_public) + + statement = statement.order_by( + col(RaceAttribute.display_order), col(RaceAttribute.key) + ) + return list(session.exec(statement).all()) + + +def update_race_attribute( + *, session: Session, db_attribute: RaceAttribute, attribute_in: RaceAttributeUpdate +) -> RaceAttribute: + """Update a race attribute.""" + attribute_data = attribute_in.model_dump(exclude_unset=True) + attribute_data["updated_at"] = datetime.now(timezone.utc) + db_attribute.sqlmodel_update(attribute_data) + session.add(db_attribute) + session.commit() + session.refresh(db_attribute) + return db_attribute + + +def delete_race_attribute(*, session: Session, attribute_id: uuid.UUID) -> bool: + """Delete a race attribute.""" + attribute = session.get(RaceAttribute, attribute_id) + if attribute: + session.delete(attribute) + session.commit() + return True + return False + + +# ============================================================================= +# RaceCheckpoint and SplitTime CRUD operations +# ============================================================================= + + +def create_race_checkpoint( + *, session: Session, checkpoint_in: RaceCheckpointCreate +) -> RaceCheckpoint: + """Create a new race checkpoint.""" + db_checkpoint = RaceCheckpoint.model_validate(checkpoint_in) + session.add(db_checkpoint) + session.commit() + session.refresh(db_checkpoint) + return db_checkpoint + + +def get_race_checkpoint( + *, session: Session, checkpoint_id: uuid.UUID +) -> RaceCheckpoint | None: + """Get a race checkpoint by ID.""" + return session.get(RaceCheckpoint, checkpoint_id) + + +def get_race_checkpoints( + *, session: Session, race_id: uuid.UUID +) -> list[RaceCheckpoint]: + """Get all checkpoints for a race.""" + statement = ( + select(RaceCheckpoint) + .where(RaceCheckpoint.race_id == race_id) + .order_by(col(RaceCheckpoint.sequence)) + ) + return list(session.exec(statement).all()) + + +def update_race_checkpoint( + *, + session: Session, + db_checkpoint: RaceCheckpoint, + checkpoint_in: RaceCheckpointUpdate, +) -> RaceCheckpoint: + """Update a race checkpoint.""" + checkpoint_data = checkpoint_in.model_dump(exclude_unset=True) + db_checkpoint.sqlmodel_update(checkpoint_data) + session.add(db_checkpoint) + session.commit() + session.refresh(db_checkpoint) + return db_checkpoint + + +def delete_race_checkpoint(*, session: Session, checkpoint_id: uuid.UUID) -> bool: + """Delete a race checkpoint.""" + checkpoint = session.get(RaceCheckpoint, checkpoint_id) + if checkpoint: + session.delete(checkpoint) + session.commit() + return True + return False + + +def create_race_split_time( + *, session: Session, split_time_in: RaceSplitTimeCreate +) -> RaceSplitTime: + """Create a new split time.""" + db_split_time = RaceSplitTime.model_validate(split_time_in) + session.add(db_split_time) + session.commit() + session.refresh(db_split_time) + return db_split_time + + +def get_registration_split_times( + *, session: Session, registration_id: uuid.UUID +) -> list[RaceSplitTime]: + """Get all split times for a registration.""" + statement = ( + select(RaceSplitTime) + .where(RaceSplitTime.registration_id == registration_id) + .order_by(col(RaceSplitTime.recorded_at)) + ) + return list(session.exec(statement).all()) + + +# ============================================================================= +# RaceTag CRUD operations +# ============================================================================= + + +def get_tag_by_slug(*, session: Session, slug: str) -> RaceTag | None: + return session.exec(select(RaceTag).where(RaceTag.slug == slug)).first() + + +def get_all_tags(*, session: Session) -> list[RaceTag]: + return list(session.exec(select(RaceTag).order_by(col(RaceTag.name))).all()) + + +def get_all_tags_count(*, session: Session) -> int: + return session.exec(select(func.count(RaceTag.id))).one() + + +def get_or_create_tag(*, session: Session, tag_in: RaceTagCreate) -> RaceTag: + tag = get_tag_by_slug(session=session, slug=tag_in.slug) + if not tag: + tag = RaceTag.model_validate(tag_in) + session.add(tag) + session.commit() + session.refresh(tag) + return tag + + +def get_race_tags(*, session: Session, race_id: uuid.UUID) -> list[RaceTag]: + linked_ids = select(RaceTagLink.tag_id).where(RaceTagLink.race_id == race_id) + statement = ( + select(RaceTag) + .where(col(RaceTag.id).in_(linked_ids)) + .order_by(col(RaceTag.name)) + ) + return list(session.exec(statement).all()) + + +def set_race_tags( + *, session: Session, race: Race, tag_ids: list[uuid.UUID] +) -> Race: + """Replace the full tag set for a race.""" + existing = session.exec( + select(RaceTagLink).where(RaceTagLink.race_id == race.id) + ).all() + for link in existing: + session.delete(link) + session.flush() + + # Add new links + for tag_id in tag_ids: + session.add(RaceTagLink(race_id=race.id, tag_id=tag_id)) + session.commit() + session.refresh(race) + return race + + +# ============================================================================= +# UserProfile CRUD operations +# ============================================================================= + + +def get_user_profile(*, session: Session, user_id: uuid.UUID) -> UserProfile | None: + return session.exec( + select(UserProfile).where(UserProfile.user_id == user_id) + ).first() + + +def upsert_user_profile( + *, session: Session, user_id: uuid.UUID, profile_in: UserProfileCreate +) -> UserProfile: + """Create or fully replace the profile for a user.""" + profile = get_user_profile(session=session, user_id=user_id) + if profile: + profile_data = profile_in.model_dump(exclude_unset=True) + profile_data["updated_at"] = datetime.now(timezone.utc) + profile.sqlmodel_update(profile_data) + else: + profile = UserProfile.model_validate(profile_in, update={"user_id": user_id}) + session.add(profile) + session.commit() + session.refresh(profile) + return profile + + +def update_user_profile( + *, session: Session, db_profile: UserProfile, profile_in: UserProfileUpdate +) -> UserProfile: + profile_data = profile_in.model_dump(exclude_unset=True) + profile_data["updated_at"] = datetime.now(timezone.utc) + db_profile.sqlmodel_update(profile_data) + session.add(db_profile) + session.commit() + session.refresh(db_profile) + return db_profile + + +def delete_user_profile(*, session: Session, user_id: uuid.UUID) -> bool: + profile = get_user_profile(session=session, user_id=user_id) + if profile: + session.delete(profile) + session.commit() + return True + return False + + +# ============================================================================= +# UserRaceInteraction CRUD operations +# ============================================================================= + + +def record_interaction( + *, + session: Session, + user_id: uuid.UUID, + race_id: uuid.UUID, + action: InteractionTypeEnum, +) -> UserRaceInteraction: + interaction = UserRaceInteraction( + user_id=user_id, race_id=race_id, action=action + ) + session.add(interaction) + session.commit() + session.refresh(interaction) + return interaction + + +def get_user_interaction( + *, session: Session, user_id: uuid.UUID, race_id: uuid.UUID, action: InteractionTypeEnum +) -> UserRaceInteraction | None: + return session.exec( + select(UserRaceInteraction).where( + UserRaceInteraction.user_id == user_id, + UserRaceInteraction.race_id == race_id, + UserRaceInteraction.action == action, + ) + ).first() + + +def get_user_saved_races(*, session: Session, user_id: uuid.UUID) -> list[Race]: + """Return races the user has saved (most recent first).""" + saved_ids = ( + select(UserRaceInteraction.race_id) + .where( + UserRaceInteraction.user_id == user_id, + UserRaceInteraction.action == InteractionTypeEnum.SAVED, + ) + ) + statement = ( + select(Race) + .where(col(Race.id).in_(saved_ids)) + .order_by(col(Race.event_start_date).desc()) + ) + return list(session.exec(statement).all()) + + +def get_interaction_counts( + *, session: Session, race_id: uuid.UUID +) -> dict[str, int]: + """Return interaction counts per action type for a race.""" + rows = session.exec( + select(UserRaceInteraction.action, func.count(UserRaceInteraction.id)) + .where(UserRaceInteraction.race_id == race_id) + .group_by(UserRaceInteraction.action) + ).all() + return {action.value: count for action, count in rows} + + +# ============================================================================= +# Search & Discovery CRUD operations +# ============================================================================= + + +def search_races( + *, + session: Session, + q: str | None = None, + lat: float | None = None, + lon: float | None = None, + radius_km: float | None = None, + distance_min_km: float | None = None, + distance_max_km: float | None = None, + terrain: TerrainEnum | None = None, + difficulty: DifficultyEnum | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + tag_slugs: list[str] | None = None, + status: RaceStatusEnum | None = None, + province_code: str | None = None, + ward_code: str | None = None, + sort: str = "date", + skip: int = 0, + limit: int = 20, +) -> list[Race]: + """Full-featured search with FTS, geo, and filters.""" + from app.utils import haversine_sql_expr + + statement = select(Race) + + # Full-text search via tsvector + if q: + statement = statement.where( + text("race.search_vector @@ plainto_tsquery('english', :q)").bindparams(q=q) + ) + + # Geo radius filter + if lat is not None and lon is not None and radius_km is not None: + dist_expr = haversine_sql_expr(lat, lon) + statement = statement.where( + text(f"{dist_expr} <= :radius_km").bindparams(radius_km=radius_km) + ) + + # Category distance filter — race must have at least one category in range + if distance_min_km is not None or distance_max_km is not None: + cat_stmt = select(RaceCategory.race_id).where(RaceCategory.race_id == Race.id) + if distance_min_km is not None: + cat_stmt = cat_stmt.where(RaceCategory.distance_km >= distance_min_km) + if distance_max_km is not None: + cat_stmt = cat_stmt.where(RaceCategory.distance_km <= distance_max_km) + statement = statement.where(col(Race.id).in_(cat_stmt)) + + # Terrain & difficulty + if terrain is not None: + statement = statement.where(Race.terrain_type == terrain) + if difficulty is not None: + statement = statement.where(Race.difficulty_level == difficulty) + + # Date range + if date_from is not None: + statement = statement.where(col(Race.event_start_date) >= date_from) + if date_to is not None: + statement = statement.where(col(Race.event_start_date) <= date_to) + + # Tag filter — race must have ALL specified tags + if tag_slugs: + for slug in tag_slugs: + tag_sub = ( + select(RaceTagLink.race_id) + .join(RaceTag, RaceTagLink.tag_id == RaceTag.id) + .where(RaceTag.slug == slug) + ) + statement = statement.where(col(Race.id).in_(tag_sub)) + + # Status + if status is not None: + statement = statement.where(Race.status == status) + + # Location filters (Vietnamese administrative data) + if province_code is not None: + statement = statement.where(Race.province_code == province_code) + if ward_code is not None: + statement = statement.where(Race.ward_code == ward_code) + + # Sorting + if sort == "popularity": + pop_sub = ( + select( + UserRaceInteraction.race_id, + func.count(UserRaceInteraction.id).label("interaction_count"), + ) + .group_by(UserRaceInteraction.race_id) + .subquery() + ) + statement = ( + statement.outerjoin(pop_sub, Race.id == pop_sub.c.race_id) + .order_by(col(pop_sub.c.interaction_count).desc().nulls_last()) + ) + elif sort == "distance" and lat is not None and lon is not None: + dist_expr = haversine_sql_expr(lat, lon) + statement = statement.order_by(text(dist_expr)) + else: + statement = statement.order_by(col(Race.event_start_date).asc()) + + statement = statement.offset(skip).limit(limit) + return list(session.exec(statement).all()) + + +def search_races_count( + *, + session: Session, + q: str | None = None, + lat: float | None = None, + lon: float | None = None, + radius_km: float | None = None, + distance_min_km: float | None = None, + distance_max_km: float | None = None, + terrain: TerrainEnum | None = None, + difficulty: DifficultyEnum | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + tag_slugs: list[str] | None = None, + status: RaceStatusEnum | None = None, + province_code: str | None = None, + ward_code: str | None = None, +) -> int: + """Count matching races for search (mirrors search_races filters).""" + from app.utils import haversine_sql_expr + + statement = select(func.count(Race.id)) + + if q: + statement = statement.where( + text("race.search_vector @@ plainto_tsquery('english', :q)").bindparams(q=q) + ) + if lat is not None and lon is not None and radius_km is not None: + dist_expr = haversine_sql_expr(lat, lon) + statement = statement.where( + text(f"{dist_expr} <= :radius_km").bindparams(radius_km=radius_km) + ) + if distance_min_km is not None or distance_max_km is not None: + cat_stmt = select(RaceCategory.race_id).where(RaceCategory.race_id == Race.id) + if distance_min_km is not None: + cat_stmt = cat_stmt.where(RaceCategory.distance_km >= distance_min_km) + if distance_max_km is not None: + cat_stmt = cat_stmt.where(RaceCategory.distance_km <= distance_max_km) + statement = statement.where(col(Race.id).in_(cat_stmt)) + if terrain is not None: + statement = statement.where(Race.terrain_type == terrain) + if difficulty is not None: + statement = statement.where(Race.difficulty_level == difficulty) + if date_from is not None: + statement = statement.where(col(Race.event_start_date) >= date_from) + if date_to is not None: + statement = statement.where(col(Race.event_start_date) <= date_to) + if tag_slugs: + for slug in tag_slugs: + tag_sub = ( + select(RaceTagLink.race_id) + .join(RaceTag, RaceTagLink.tag_id == RaceTag.id) + .where(RaceTag.slug == slug) + ) + statement = statement.where(col(Race.id).in_(tag_sub)) + if status is not None: + statement = statement.where(Race.status == status) + if province_code is not None: + statement = statement.where(Race.province_code == province_code) + if ward_code is not None: + statement = statement.where(Race.ward_code == ward_code) + + return session.exec(statement).one() + + +def get_races_by_ids( + *, session: Session, race_ids: list[uuid.UUID] +) -> list[Race]: + """Fetch races by a list of IDs, preserving order.""" + if not race_ids: + return [] + rows = list(session.exec(select(Race).where(col(Race.id).in_(race_ids))).all()) + id_to_race = {r.id: r for r in rows} + return [id_to_race[rid] for rid in race_ids if rid in id_to_race] + + +def semantic_search_races( + *, + session: Session, + query_embedding: list[float], + limit: int = 40, +) -> list[tuple[uuid.UUID, int]]: + """Return (race_id, rank) pairs ordered by cosine similarity to query_embedding. + + Uses pgvector <=> (cosine distance) operator. Returns a ranked list of IDs + suitable for RRF fusion with FTS results. + """ + embedding_literal = "[" + ",".join(str(v) for v in query_embedding) + "]" + sql = text( + f"SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> '{embedding_literal}'::vector) AS rank " + "FROM race WHERE embedding IS NOT NULL " + f"LIMIT {limit}" + ) + rows = session.execute(sql).fetchall() + return [(row[0], int(row[1])) for row in rows] + + +def rrf_merge_race_ids( + fts_ids: list[uuid.UUID], + vec_ids: list[tuple[uuid.UUID, int]], + k: int = 60, + limit: int = 20, +) -> list[uuid.UUID]: + """Reciprocal Rank Fusion of FTS and vector search results.""" + scores: dict[uuid.UUID, float] = {} + for rank, race_id in enumerate(fts_ids, start=1): + scores[race_id] = scores.get(race_id, 0.0) + 1.0 / (k + rank) + for race_id, rank in vec_ids: + scores[race_id] = scores.get(race_id, 0.0) + 1.0 / (k + rank) + sorted_ids = sorted(scores.keys(), key=lambda rid: scores[rid], reverse=True) + return sorted_ids[:limit] + + +def get_nearby_races( + *, + session: Session, + lat: float, + lon: float, + radius_km: float = 100.0, + limit: int = 20, +) -> list[tuple[Race, float]]: + """Return (race, distance_km) tuples sorted by distance ascending.""" + from app.utils import haversine_distance_km, haversine_sql_expr + + dist_expr = haversine_sql_expr(lat, lon) + statement = ( + select(Race) + .where(Race.latitude.isnot(None)) # type: ignore[union-attr] + .where(Race.longitude.isnot(None)) # type: ignore[union-attr] + .where(text(f"{dist_expr} <= :radius_km").bindparams(radius_km=radius_km)) + .order_by(text(dist_expr)) + .limit(limit) + ) + races = list(session.exec(statement).all()) + return [ + (race, haversine_distance_km(lat, lon, race.latitude, race.longitude)) # type: ignore[arg-type] + for race in races + ] + + +def get_trending_races( + *, + session: Session, + days: int = 7, + limit: int = 10, +) -> list[Race]: + """Return races ranked by interaction count in the last N days.""" + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + pop_sub = ( + select( + UserRaceInteraction.race_id, + func.count(UserRaceInteraction.id).label("cnt"), + ) + .where(col(UserRaceInteraction.created_at) >= cutoff) + .group_by(UserRaceInteraction.race_id) + .subquery() + ) + statement = ( + select(Race) + .outerjoin(pop_sub, Race.id == pop_sub.c.race_id) + .order_by(col(pop_sub.c.cnt).desc().nulls_last()) + .limit(limit) + ) + return list(session.exec(statement).all()) + + +def get_similar_races( + *, + session: Session, + race: Race, + limit: int = 6, +) -> list[Race]: + """Return races similar to the given race by terrain, difficulty, and region.""" + statement = select(Race).where(Race.id != race.id) + + filters = [] + if race.terrain_type is not None: + filters.append(Race.terrain_type == race.terrain_type) + if race.difficulty_level is not None: + filters.append(Race.difficulty_level == race.difficulty_level) + if race.country: + filters.append(Race.country == race.country) + + if filters: + statement = statement.where(or_(*filters)) + + statement = statement.order_by(col(Race.event_start_date).asc()).limit(limit) + return list(session.exec(statement).all()) + + +def get_recommended_races( + *, + session: Session, + user_id: uuid.UUID, + limit: int = 10, +) -> list[Race]: + """ + Return personalized race recommendations. + Falls back to trending if user has no profile or sparse preferences. + """ + profile = get_user_profile(session=session, user_id=user_id) + + if profile is None: + return get_trending_races(session=session, limit=limit) + + statement = select(Race) + filters = [] + + if profile.terrain_preference is not None: + filters.append(Race.terrain_type == profile.terrain_preference) + + if profile.distance_preference is not None: + dist_ranges: dict[str, tuple[float, float]] = { + DistancePrefEnum.SHORT.value: (0, 10), + DistancePrefEnum.MID.value: (10, 30), + DistancePrefEnum.LONG.value: (30, 60), + DistancePrefEnum.ULTRA.value: (60, 9999), + } + range_key = profile.distance_preference.value if hasattr(profile.distance_preference, "value") else str(profile.distance_preference) + if range_key in dist_ranges: + lo, hi = dist_ranges[range_key] + cat_sub = ( + select(RaceCategory.race_id) + .where(RaceCategory.distance_km >= lo, RaceCategory.distance_km <= hi) + ) + filters.append(col(Race.id).in_(cat_sub)) + + if filters: + statement = statement.where(or_(*filters)) + + # Exclude already-saved races + saved_ids = ( + select(UserRaceInteraction.race_id) + .where( + UserRaceInteraction.user_id == user_id, + UserRaceInteraction.action == InteractionTypeEnum.SAVED, + ) + ) + statement = statement.where(col(Race.id).notin_(saved_ids)) + + statement = statement.order_by(col(Race.event_start_date).asc()).limit(limit) + results = list(session.exec(statement).all()) + + # Pad with trending if not enough results + if len(results) < limit: + existing_ids = {r.id for r in results} + trending = get_trending_races(session=session, limit=limit) + for r in trending: + if r.id not in existing_ids: + results.append(r) + existing_ids.add(r.id) + if len(results) >= limit: + break + + return results diff --git a/backend/app/i18n.py b/backend/app/i18n.py new file mode 100644 index 0000000000..3f71521b9c --- /dev/null +++ b/backend/app/i18n.py @@ -0,0 +1,183 @@ +""" +Internationalization (i18n) utilities for multi-language support. +""" +from typing import Any + + +SUPPORTED_LANGUAGES = ["vi", "en"] +DEFAULT_LANGUAGE = "vi" + + +def get_translated_field( + obj: Any, + field_name: str, + language: str = DEFAULT_LANGUAGE, + fallback_to_default: bool = True, +) -> str | None: + """ + Get a translated field value from an object. + + Args: + obj: The database object (Race, RaceCategory, RaceTag, etc.) + field_name: The field to translate (e.g., "name", "description") + language: The target language code (e.g., "en", "vi") + fallback_to_default: If True, fallback to default field value if translation not found + + Returns: + The translated value or None + + Example: + race = Race(name="Hanoi Marathon", translations={"vi": {"name": "Giải chạy Hà Nội"}}) + get_translated_field(race, "name", "vi") # Returns "Giải chạy Hà Nội" + get_translated_field(race, "name", "en") # Returns "Hanoi Marathon" (default) + """ + # Normalize language code + language = language.lower() if language else DEFAULT_LANGUAGE + + # Check if translations exist + if hasattr(obj, "translations") and obj.translations: + # Try to get translation for requested language + if language in obj.translations: + lang_translations = obj.translations[language] + if isinstance(lang_translations, dict) and field_name in lang_translations: + value = lang_translations[field_name] + if value: # Return if value is not None or empty + return value + + # Fallback to default language in translations + if fallback_to_default and hasattr(obj, "default_language"): + default_lang = obj.default_language or DEFAULT_LANGUAGE + if default_lang in obj.translations: + lang_translations = obj.translations[default_lang] + if isinstance(lang_translations, dict) and field_name in lang_translations: + value = lang_translations[field_name] + if value: + return value + + # Fallback to object's default field value + if fallback_to_default and hasattr(obj, field_name): + return getattr(obj, field_name) + + return None + + +def translate_object( + obj: Any, + fields: list[str], + language: str = DEFAULT_LANGUAGE, +) -> dict[str, Any]: + """ + Get translated fields from an object as a dictionary. + + Args: + obj: The database object + fields: List of field names to translate + language: Target language code + + Returns: + Dictionary with translated field values + + Example: + race = Race(name="Hanoi Marathon", description="...", translations={...}) + translate_object(race, ["name", "description"], "vi") + # Returns {"name": "Giải chạy Hà Nội", "description": "..."} + """ + result = {} + for field in fields: + value = get_translated_field(obj, field, language) + if value is not None: + result[field] = value + return result + + +def set_translation( + obj: Any, + field_name: str, + value: str, + language: str, +) -> None: + """ + Set a translated field value on an object. + + Args: + obj: The database object to modify + field_name: The field to translate + value: The translated value + language: The language code + + Example: + race = Race(name="Hanoi Marathon") + set_translation(race, "name", "Giải chạy Hà Nội", "vi") + """ + # Normalize language code + language = language.lower() + + # Initialize translations if needed - create a NEW dict to trigger SQLAlchemy change detection + if not hasattr(obj, "translations") or obj.translations is None: + obj.translations = {} + else: + # Create a copy to trigger SQLAlchemy change detection + obj.translations = dict(obj.translations) + + # Ensure language key exists + if language not in obj.translations: + obj.translations[language] = {} + else: + # Create a copy of the language dict + obj.translations[language] = dict(obj.translations[language]) + + # Set the translation + obj.translations[language][field_name] = value + + +def merge_translations( + obj: Any, + translations: dict[str, dict[str, str]], +) -> None: + """ + Merge multiple translations into an object. + + Args: + obj: The database object to modify + translations: Nested dict of translations + Format: {"vi": {"name": "...", "description": "..."}, "en": {...}} + + Example: + race = Race(name="Hanoi Marathon") + merge_translations(race, { + "vi": {"name": "Giải chạy Hà Nội", "description": "..."}, + "th": {"name": "มาราธอนฮานอย"} + }) + """ + if not translations: + return + + # Initialize translations if needed - create a NEW dict to trigger SQLAlchemy change detection + if not hasattr(obj, "translations") or obj.translations is None: + obj.translations = {} + else: + # Create a copy to trigger SQLAlchemy change detection + obj.translations = dict(obj.translations) + + # Merge each language + for lang_code, lang_translations in translations.items(): + lang_code = lang_code.lower() + + if lang_code not in obj.translations: + obj.translations[lang_code] = {} + else: + # Create a copy of the language dict + obj.translations[lang_code] = dict(obj.translations[lang_code]) + + # Merge fields + obj.translations[lang_code].update(lang_translations) + + +def get_supported_languages() -> list[str]: + """Get list of supported language codes.""" + return SUPPORTED_LANGUAGES.copy() + + +def is_language_supported(language: str) -> bool: + """Check if a language is supported.""" + return language.lower() in SUPPORTED_LANGUAGES diff --git a/backend/app/main.py b/backend/app/main.py index 9a95801e74..344f9e7b9a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,11 +1,55 @@ +import json +import logging +import time +from typing import Any, Callable + +import jwt import sentry_sdk -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import Response from fastapi.routing import APIRoute +from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.cors import CORSMiddleware from app.api.main import api_router from app.core.config import settings +logger = logging.getLogger("app.access") + +ALGORITHM = "HS256" + + +class StructuredLoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: Callable) -> Response: # type: ignore[override] + start = time.monotonic() + response = await call_next(request) + elapsed_ms = round((time.monotonic() - start) * 1000, 2) + + user_id: str | None = None + try: + auth = request.headers.get("authorization", "") + if auth.startswith("Bearer "): + payload = jwt.decode( + auth[7:], + settings.SECRET_KEY, + algorithms=[ALGORITHM], + options={"verify_exp": False}, + ) + user_id = payload.get("sub") + except Exception: + pass + + logger.info( + json.dumps({ + "method": request.method, + "path": request.url.path, + "status": response.status_code, + "duration_ms": elapsed_ms, + "user_id": user_id, + }) + ) + return response + def custom_generate_unique_id(route: APIRoute) -> str: return f"{route.tags[0]}-{route.name}" @@ -20,6 +64,9 @@ def custom_generate_unique_id(route: APIRoute) -> str: generate_unique_id_function=custom_generate_unique_id, ) +# Structured request logging (add before CORS so it wraps everything) +app.add_middleware(StructuredLoggingMiddleware) + # Set all CORS enabled origins if settings.all_cors_origins: app.add_middleware( diff --git a/backend/app/models.py b/backend/app/models.py index 0ae3cf6574..bdb82e59bf 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,15 +1,163 @@ import uuid -from datetime import datetime, timezone +from datetime import date, datetime, timezone +from enum import Enum +from typing import Any, Optional from pydantic import EmailStr -from sqlalchemy import DateTime +from sqlalchemy import JSON, Column, DateTime, Text +from sqlalchemy import types as sa_types from sqlmodel import Field, Relationship, SQLModel +from sqlmodel.sql.sqltypes import AutoString + +try: + from pgvector.sqlalchemy import Vector as _Vector + + _EMBEDDING_COLUMN_TYPE: sa_types.TypeEngine = _Vector(1536) # type: ignore[assignment] +except ImportError: + # pgvector not installed yet; use Text as placeholder so the app starts. + # The actual column type is defined in the manual migration. + _EMBEDDING_COLUMN_TYPE = Text() def get_datetime_utc() -> datetime: return datetime.now(timezone.utc) +# Enum for predefined roles +class RoleEnum(str, Enum): + ADMIN = "admin" + RUNNER = "runner" + ORGANIZER = "organizer" + VOLUNTEER = "volunteer" + + +# Enum for race status +class RaceStatusEnum(str, Enum): + DRAFT = "draft" + PUBLISHED = "published" + REGISTRATION_OPEN = "registration_open" + REGISTRATION_CLOSED = "registration_closed" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +# Enum for registration status +class RegistrationStatusEnum(str, Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + CANCELLED = "cancelled" + WAITLIST = "waitlist" + + +# Enum for payment status +class PaymentStatusEnum(str, Enum): + UNPAID = "unpaid" + PAID = "paid" + REFUNDED = "refunded" + PARTIAL = "partial" + + +# Enum for race result status +class ResultStatusEnum(str, Enum): + FINISHED = "finished" + DNF = "dnf" # Did Not Finish + DNS = "dns" # Did Not Start + DQ = "dq" # Disqualified + + +# Enum for flexible attribute types +class AttributeTypeEnum(str, Enum): + STRING = "string" + TEXT = "text" + URL = "url" + DATE = "date" + DATETIME = "datetime" + NUMBER = "number" + BOOLEAN = "boolean" + EMAIL = "email" + PHONE = "phone" + + +class TerrainEnum(str, Enum): + ROAD = "road" + TRAIL = "trail" + TRACK = "track" + MIXED = "mixed" + + +class DifficultyEnum(str, Enum): + EASY = "easy" + MODERATE = "moderate" + HARD = "hard" + EXTREME = "extreme" + + +class FitnessEnum(str, Enum): + BEGINNER = "beginner" + INTERMEDIATE = "intermediate" + ADVANCED = "advanced" + ELITE = "elite" + + +class DistancePrefEnum(str, Enum): + SHORT = "short" # 5K and under + MID = "mid" # 10K–half marathon + LONG = "long" # full marathon + ULTRA = "ultra" # 50K+ + + +class InteractionTypeEnum(str, Enum): + VIEWED = "viewed" + SAVED = "saved" + UNSAVED = "unsaved" + REGISTERED = "registered" + SHARED = "shared" + + +# Link table for many-to-many relationship between User and Role +class UserRoleLink(SQLModel, table=True): + user_id: uuid.UUID = Field( + foreign_key="user.id", primary_key=True, ondelete="CASCADE" + ) + role_id: uuid.UUID = Field( + foreign_key="role.id", primary_key=True, ondelete="CASCADE" + ) + + +# Role model +class RoleBase(SQLModel): + name: str = Field(unique=True, index=True, max_length=50) + description: str | None = Field(default=None, max_length=255) + + +class RoleCreate(RoleBase): + pass + + +class RoleUpdate(SQLModel): + name: str | None = Field(default=None, max_length=50) + description: str | None = Field(default=None, max_length=255) + + +class Role(RoleBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_column=Column(DateTime(timezone=True)), + ) + users: list["User"] = Relationship(back_populates="roles", link_model=UserRoleLink) + + +class RolePublic(RoleBase): + id: uuid.UUID + created_at: datetime | None = None + + +class RolesPublic(SQLModel): + data: list[RolePublic] + count: int + + # Shared properties class UserBase(SQLModel): email: EmailStr = Field(unique=True, index=True, max_length=255) @@ -51,15 +199,28 @@ class User(UserBase, table=True): hashed_password: str created_at: datetime | None = Field( default_factory=get_datetime_utc, - sa_type=DateTime(timezone=True), # type: ignore + sa_column=Column(DateTime(timezone=True)), ) items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) + roles: list[Role] = Relationship(back_populates="users", link_model=UserRoleLink) + # Race relationships + organized_races: list["Race"] = Relationship( + back_populates="organizer", cascade_delete=True + ) + race_registrations: list["RaceRegistration"] = Relationship( + back_populates="runner", cascade_delete=True + ) + profile: Optional["UserProfile"] = Relationship( + back_populates="user", + sa_relationship_kwargs={"uselist": False}, + ) # Properties to return via API, id is always required class UserPublic(UserBase): id: uuid.UUID created_at: datetime | None = None + roles: list[RolePublic] = [] class UsersPublic(SQLModel): @@ -88,7 +249,7 @@ class Item(ItemBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) created_at: datetime | None = Field( default_factory=get_datetime_utc, - sa_type=DateTime(timezone=True), # type: ignore + sa_column=Column(DateTime(timezone=True)), ) owner_id: uuid.UUID = Field( foreign_key="user.id", nullable=False, ondelete="CASCADE" @@ -108,6 +269,970 @@ class ItemsPublic(SQLModel): count: int +# ============================================================================= +# MediaAsset - Reusable media for any content type (race, article, etc.) +# ============================================================================= + + +class MediaAssetBase(SQLModel): + content_type: str = Field(max_length=100, index=True) + content_id: uuid.UUID = Field(index=True) + kind: str = Field(default="gallery", max_length=50, index=True) + + alt_text: str | None = Field(default=None, max_length=255) + display_order: int = Field(default=0) + is_primary: bool = False + is_public: bool = True + + +class MediaAssetCreate(MediaAssetBase): + original_filename: str = Field(max_length=255) + file_name: str = Field(max_length=255) + file_path: str = Field(max_length=1000) + file_url: str = Field(max_length=1000) + mime_type: str = Field(max_length=100) + size_bytes: int = Field(ge=0) + uploaded_by_id: uuid.UUID | None = None + + +class MediaAssetUpdate(SQLModel): + kind: str | None = Field(default=None, max_length=50) + alt_text: str | None = Field(default=None, max_length=255) + display_order: int | None = None + is_primary: bool | None = None + is_public: bool | None = None + + +class MediaAsset(MediaAssetBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + original_filename: str = Field(max_length=255) + file_name: str = Field(max_length=255) + file_path: str = Field(max_length=1000) + file_url: str = Field(max_length=1000) + mime_type: str = Field(max_length=100) + size_bytes: int = Field(ge=0) + uploaded_by_id: uuid.UUID | None = Field(default=None, foreign_key="user.id") + created_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + +class MediaAssetPublic(MediaAssetBase): + id: uuid.UUID + original_filename: str + file_name: str + file_url: str + mime_type: str + size_bytes: int + uploaded_by_id: uuid.UUID | None = None + created_at: datetime + updated_at: datetime + + +class MediaAssetsPublic(SQLModel): + data: list[MediaAssetPublic] + count: int + + +# ============================================================================= +# Race Models +# ============================================================================= + + +# Junction table for many-to-many Race ↔ RaceTag +class RaceTagLink(SQLModel, table=True): + race_id: uuid.UUID = Field( + foreign_key="race.id", primary_key=True, ondelete="CASCADE" + ) + tag_id: uuid.UUID = Field( + foreign_key="racetag.id", primary_key=True, ondelete="CASCADE" + ) + + +class RaceTagBase(SQLModel): + name: str = Field(max_length=50, unique=True, index=True) + slug: str = Field(max_length=50, unique=True, index=True) + translations: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + + +class RaceTagCreate(RaceTagBase): + pass + + +class RaceTag(RaceTagBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + races: list["Race"] = Relationship( + back_populates="tags", link_model=RaceTagLink + ) + + +class TagPublic(RaceTagBase): + id: uuid.UUID + + +class TagsPublic(SQLModel): + data: list[TagPublic] + count: int + + +# Race - Main race event +class RaceBase(SQLModel): + name: str = Field(min_length=1, max_length=255, index=True) + description: str | None = Field(default=None, max_length=2000) + + # Overall event period (for multi-day events) + event_start_date: datetime = Field(sa_column=Column(DateTime(timezone=True))) + event_end_date: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + + # Location + location: str = Field(max_length=255) + city: str | None = Field(default=None, max_length=100) + state: str | None = Field(default=None, max_length=100) + country: str = Field(default="Vietnam", max_length=100) + + # Vietnamese administrative location (new structured fields) + province_code: str | None = Field(default=None, max_length=20) + ward_code: str | None = Field(default=None, max_length=20) + country_code: str | None = Field(default=None, max_length=10) + province_name: str | None = Field(default=None, max_length=100) + ward_name: str | None = Field(default=None, max_length=100) + + # Overall registration (can be overridden per category) + registration_start: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + registration_end: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + + # Status — use AutoString so SQLAlchemy stores the enum .value ("draft"), not .name ("DRAFT") + status: RaceStatusEnum = Field( + default=RaceStatusEnum.DRAFT, + sa_column=Column(AutoString(), nullable=False), + ) + is_active: bool = True + + # Default pricing (can be overridden per category) + base_price: float | None = Field(default=None, ge=0) + currency: str = Field(default="USD", max_length=3) + + # Flexible metadata stored as JSON + race_metadata: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + + # Geographic coordinates + latitude: float | None = Field(default=None, ge=-90, le=90) + longitude: float | None = Field(default=None, ge=-180, le=180) + + # Course characteristics — AutoString stores .value ("trail"), not .name ("TRAIL") + terrain_type: TerrainEnum | None = Field( + default=None, + sa_column=Column(AutoString(), nullable=True), + ) + difficulty_level: DifficultyEnum | None = Field( + default=None, + sa_column=Column(AutoString(), nullable=True), + ) + elevation_gain_m: int | None = Field(default=None, ge=0) + is_certified: bool = Field(default=False) + gpx_file_url: str | None = Field(default=None, max_length=1000) + website_url: str | None = Field(default=None, max_length=1000) + + # Multi-language support + default_language: str = Field(default="vi", max_length=10) + translations: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + + +class RaceCreate(RaceBase): + pass + + +class RaceUpdate(SQLModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + event_start_date: datetime | None = None + event_end_date: datetime | None = None + location: str | None = None + city: str | None = None + state: str | None = None + country: str | None = None + province_code: str | None = None + ward_code: str | None = None + country_code: str | None = None + province_name: str | None = None + ward_name: str | None = None + registration_start: datetime | None = None + registration_end: datetime | None = None + status: RaceStatusEnum | None = None + is_active: bool | None = None + base_price: float | None = None + currency: str | None = None + race_metadata: dict[str, Any] | None = None + latitude: float | None = None + longitude: float | None = None + terrain_type: TerrainEnum | None = None + difficulty_level: DifficultyEnum | None = None + elevation_gain_m: int | None = None + is_certified: bool | None = None + gpx_file_url: str | None = None + website_url: str | None = None + tag_ids: list[uuid.UUID] | None = None + + +class Race(RaceBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + # Foreign keys + organizer_id: uuid.UUID = Field(foreign_key="user.id", nullable=False) + province_code: str | None = Field( + default=None, foreign_key="provinces.code", max_length=20 + ) + ward_code: str | None = Field( + default=None, foreign_key="wards.code", max_length=20 + ) + + # Embedding vector for semantic search (1536-dim, text-embedding-3-small) + embedding: list[float] | None = Field( + default=None, + sa_column=Column(_EMBEDDING_COLUMN_TYPE, nullable=True), + ) + + # Relationships + organizer: User = Relationship(back_populates="organized_races") + province: Optional["Province"] = Relationship() + ward: Optional["Ward"] = Relationship() + categories: list["RaceCategory"] = Relationship( + back_populates="race", cascade_delete=True + ) + registrations: list["RaceRegistration"] = Relationship( + back_populates="race", cascade_delete=True + ) + attributes: list["RaceAttribute"] = Relationship( + back_populates="race", cascade_delete=True + ) + checkpoints: list["RaceCheckpoint"] = Relationship( + back_populates="race", cascade_delete=True + ) + tags: list[RaceTag] = Relationship( + back_populates="races", link_model=RaceTagLink + ) + + +class RacePublic(RaceBase): + id: uuid.UUID + created_at: datetime + updated_at: datetime + organizer_id: uuid.UUID + + +class RacePublicWithDetails(RacePublic): + categories: list["RaceCategoryPublic"] = [] + tags: list[TagPublic] = [] + registration_count: int = 0 + province: "ProvincePublic | None" = None + ward: "WardPublic | None" = None + + +class RacePublicWithDistance(RacePublic): + distance_km: float + + +class RacePublicWithExplanation(RacePublic): + ai_explanation: str | None = None + + +class RacesPublicWithExplanation(SQLModel): + data: list[RacePublicWithExplanation] + count: int + + +class RacesPublic(SQLModel): + data: list[RacePublic] + count: int + + +class RacesPublicWithDistance(SQLModel): + data: list[RacePublicWithDistance] + count: int + + +# Translation models +class TranslationContent(SQLModel): + """Single language translation content""" + name: str | None = None + description: str | None = None + + +class RaceTranslationUpdate(SQLModel): + """Update translations for a race""" + language: str = Field(min_length=2, max_length=10) + name: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, max_length=2000) + location: str | None = Field(default=None, max_length=255) + + +class CategoryTranslationUpdate(SQLModel): + """Update translations for a race category""" + language: str = Field(min_length=2, max_length=10) + name: str | None = Field(default=None, max_length=100) + description: str | None = None + + +class TagTranslationUpdate(SQLModel): + """Update translations for a tag""" + language: str = Field(min_length=2, max_length=10) + name: str | None = Field(default=None, max_length=50) + + +# RaceCategory - Distance/Type variations +class RaceCategoryBase(SQLModel): + name: str = Field(max_length=100) + distance_km: float = Field(gt=0) + distance_unit: str = Field(default="km", max_length=10) + + # Category-specific start and end times + start_time: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + end_time: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + + # Time limits + cutoff_time_minutes: int | None = Field(default=None, ge=0) + + # Category-specific registration window (overrides race defaults) + registration_start: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + registration_end: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + + # Category-specific pricing + price: float | None = Field(default=None, ge=0) + early_bird_price: float | None = Field(default=None, ge=0) + early_bird_deadline: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + + # Capacity + max_participants: int | None = Field(default=None, ge=1) + + # Age/gender restrictions + min_age: int | None = Field(default=None, ge=0) + max_age: int | None = Field(default=None, ge=0) + gender_restriction: str | None = Field(default=None, max_length=20) + + # Display + description: str | None = Field(default=None, max_length=500) + display_order: int = Field(default=0) + is_active: bool = True + + # Multi-language support + translations: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + + +class RaceCategoryCreate(RaceCategoryBase): + race_id: uuid.UUID + + +class RaceCategoryUpdate(SQLModel): + name: str | None = None + distance_km: float | None = None + distance_unit: str | None = None + start_time: datetime | None = None + end_time: datetime | None = None + cutoff_time_minutes: int | None = None + registration_start: datetime | None = None + registration_end: datetime | None = None + price: float | None = None + early_bird_price: float | None = None + early_bird_deadline: datetime | None = None + max_participants: int | None = None + min_age: int | None = None + max_age: int | None = None + gender_restriction: str | None = None + description: str | None = None + display_order: int | None = None + is_active: bool | None = None + + +class RaceCategory(RaceCategoryBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + race_id: uuid.UUID = Field( + foreign_key="race.id", nullable=False, ondelete="CASCADE" + ) + created_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + # Relationships + race: Race = Relationship(back_populates="categories") + registrations: list["RaceRegistration"] = Relationship( + back_populates="category", cascade_delete=True + ) + + +class RaceCategoryPublic(RaceCategoryBase): + id: uuid.UUID + race_id: uuid.UUID + created_at: datetime + updated_at: datetime + + +class RaceCategoryPublicWithDetails(RaceCategoryPublic): + registration_count: int = 0 + available_spots: int | None = None + is_registration_open: bool = False + current_price: float | None = None + + +class RaceCategoriesPublic(SQLModel): + data: list[RaceCategoryPublic] + count: int + + +# RaceRegistration - Runner registrations +class RaceRegistrationBase(SQLModel): + # Runner information + bib_number: str | None = Field(default=None, max_length=50) + emergency_contact: str | None = Field(default=None, max_length=255) + emergency_phone: str | None = Field(default=None, max_length=50) + + # Additional info + tshirt_size: str | None = Field(default=None, max_length=10) + special_requirements: str | None = Field(default=None, max_length=500) + + # Payment & status — AutoString stores enum .value ("pending"), not .name ("PENDING") + registration_status: RegistrationStatusEnum = Field( + default=RegistrationStatusEnum.PENDING, + sa_column=Column(AutoString(), nullable=False), + ) + payment_status: PaymentStatusEnum = Field( + default=PaymentStatusEnum.UNPAID, + sa_column=Column(AutoString(), nullable=False), + ) + amount_paid: float | None = Field(default=None, ge=0) + payment_reference: str | None = Field(default=None, max_length=255) + + # Extra data stored as JSON + registration_data: dict[str, Any] | None = Field( + default=None, sa_column=Column(JSON) + ) + + +class RaceRegistrationCreate(RaceRegistrationBase): + race_id: uuid.UUID + category_id: uuid.UUID + + +class RaceRegistrationUpdate(SQLModel): + bib_number: str | None = None + emergency_contact: str | None = None + emergency_phone: str | None = None + tshirt_size: str | None = None + special_requirements: str | None = None + registration_status: RegistrationStatusEnum | None = None + payment_status: PaymentStatusEnum | None = None + amount_paid: float | None = None + payment_reference: str | None = None + registration_data: dict[str, Any] | None = None + + +class RaceRegistration(RaceRegistrationBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + race_id: uuid.UUID = Field( + foreign_key="race.id", nullable=False, ondelete="CASCADE" + ) + category_id: uuid.UUID = Field( + foreign_key="racecategory.id", nullable=False, ondelete="CASCADE" + ) + runner_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + + registered_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + # Relationships + race: Race = Relationship(back_populates="registrations") + category: RaceCategory = Relationship(back_populates="registrations") + runner: User = Relationship(back_populates="race_registrations") + result: Optional["RaceResult"] = Relationship( + back_populates="registration", sa_relationship_kwargs={"uselist": False} + ) + split_times: list["RaceSplitTime"] = Relationship( + back_populates="registration", cascade_delete=True + ) + + +class RaceRegistrationPublic(RaceRegistrationBase): + id: uuid.UUID + race_id: uuid.UUID + category_id: uuid.UUID + runner_id: uuid.UUID + registered_at: datetime + updated_at: datetime + + +class RaceRegistrationPublicWithDetails(RaceRegistrationPublic): + runner: UserPublic + category: RaceCategoryPublic + + +class RaceRegistrationsPublic(SQLModel): + data: list[RaceRegistrationPublic] + count: int + + +# RaceResult - Race completion results +class RaceResultBase(SQLModel): + finish_time_seconds: int | None = Field(default=None, ge=0) + overall_position: int | None = Field(default=None, ge=1) + category_position: int | None = Field(default=None, ge=1) + gender_position: int | None = Field(default=None, ge=1) + + status: ResultStatusEnum = Field( + default=ResultStatusEnum.FINISHED, + sa_column=Column(AutoString(), nullable=False), + ) + + # Calculated fields + pace_per_km_seconds: float | None = None + notes: str | None = Field(default=None, max_length=500) + + +class RaceResultCreate(RaceResultBase): + registration_id: uuid.UUID + + +class RaceResultUpdate(RaceResultBase): + finish_time_seconds: int | None = None + overall_position: int | None = None + category_position: int | None = None + gender_position: int | None = None + status: ResultStatusEnum | None = None + pace_per_km_seconds: float | None = None + notes: str | None = None + + +class RaceResult(RaceResultBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + registration_id: uuid.UUID = Field( + foreign_key="raceregistration.id", + unique=True, + nullable=False, + ondelete="CASCADE", + ) + + created_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + # Relationships + registration: RaceRegistration = Relationship(back_populates="result") + + +class RaceResultPublic(RaceResultBase): + id: uuid.UUID + registration_id: uuid.UUID + created_at: datetime + updated_at: datetime + + +class RaceResultsPublic(SQLModel): + data: list[RaceResultPublic] + count: int + + +# RaceAttribute - Flexible key-value attributes +class RaceAttributeBase(SQLModel): + key: str = Field(max_length=100, index=True) + value_text: str | None = Field(default=None, sa_column=Column(Text)) + + # Metadata about the attribute + attribute_type: AttributeTypeEnum = Field(default=AttributeTypeEnum.STRING) + label: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, max_length=500) + is_required: bool = False + is_public: bool = True + display_order: int = Field(default=0) + + +class RaceAttributeCreate(RaceAttributeBase): + race_id: uuid.UUID + + +class RaceAttributeUpdate(SQLModel): + value_text: str | None = None + attribute_type: AttributeTypeEnum | None = None + label: str | None = None + description: str | None = None + is_required: bool | None = None + is_public: bool | None = None + display_order: int | None = None + + +class RaceAttribute(RaceAttributeBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + race_id: uuid.UUID = Field( + foreign_key="race.id", nullable=False, ondelete="CASCADE" + ) + created_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + # Relationships + race: Race = Relationship(back_populates="attributes") + + +class RaceAttributePublic(RaceAttributeBase): + id: uuid.UUID + race_id: uuid.UUID + created_at: datetime + updated_at: datetime + + +class RaceAttributesPublic(SQLModel): + data: list[RaceAttributePublic] + count: int + + +# RaceCheckpoint - For split times tracking +class RaceCheckpointBase(SQLModel): + name: str = Field(max_length=100) + distance_km: float = Field(ge=0) + sequence: int = Field(ge=1) + is_active: bool = True + + +class RaceCheckpointCreate(RaceCheckpointBase): + race_id: uuid.UUID + + +class RaceCheckpointUpdate(SQLModel): + name: str | None = None + distance_km: float | None = None + sequence: int | None = None + is_active: bool | None = None + + +class RaceCheckpoint(RaceCheckpointBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + race_id: uuid.UUID = Field( + foreign_key="race.id", nullable=False, ondelete="CASCADE" + ) + created_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + # Relationships + race: Race = Relationship(back_populates="checkpoints") + split_times: list["RaceSplitTime"] = Relationship( + back_populates="checkpoint", cascade_delete=True + ) + + +class RaceCheckpointPublic(RaceCheckpointBase): + id: uuid.UUID + race_id: uuid.UUID + created_at: datetime + + +class RaceCheckpointsPublic(SQLModel): + data: list[RaceCheckpointPublic] + count: int + + +# RaceSplitTime - Split times at checkpoints +class RaceSplitTimeBase(SQLModel): + time_seconds: int = Field(ge=0) + + +class RaceSplitTimeCreate(RaceSplitTimeBase): + registration_id: uuid.UUID + checkpoint_id: uuid.UUID + + +class RaceSplitTimeUpdate(SQLModel): + time_seconds: int | None = None + + +class RaceSplitTime(RaceSplitTimeBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + registration_id: uuid.UUID = Field( + foreign_key="raceregistration.id", nullable=False, ondelete="CASCADE" + ) + checkpoint_id: uuid.UUID = Field( + foreign_key="racecheckpoint.id", nullable=False, ondelete="CASCADE" + ) + recorded_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + # Relationships + registration: RaceRegistration = Relationship(back_populates="split_times") + checkpoint: RaceCheckpoint = Relationship(back_populates="split_times") + + +class RaceSplitTimePublic(RaceSplitTimeBase): + id: uuid.UUID + registration_id: uuid.UUID + checkpoint_id: uuid.UUID + recorded_at: datetime + + +class RaceSplitTimesPublic(SQLModel): + data: list[RaceSplitTimePublic] + count: int + + +# ============================================================================= +# End of Race Models +# ============================================================================= + + +# ============================================================================= +# UserProfile - Runner preferences and personalization data +# ============================================================================= + + +class UserProfileBase(SQLModel): + fitness_level: FitnessEnum | None = Field(default=None, sa_column=Column(AutoString(), nullable=True)) + distance_preference: DistancePrefEnum | None = Field(default=None, sa_column=Column(AutoString(), nullable=True)) + terrain_preference: TerrainEnum | None = Field(default=None, sa_column=Column(AutoString(), nullable=True)) + home_latitude: float | None = Field(default=None, ge=-90, le=90) + home_longitude: float | None = Field(default=None, ge=-180, le=180) + home_city: str | None = Field(default=None, max_length=100) + weekly_mileage_km: float | None = Field(default=None, ge=0) + goal_race_date: date | None = None + bio: str | None = Field(default=None, sa_column=Column(Text)) + is_onboarded: bool = Field(default=False) + + +class UserProfileCreate(UserProfileBase): + pass + + +class UserProfileUpdate(SQLModel): + fitness_level: FitnessEnum | None = None + distance_preference: DistancePrefEnum | None = None + terrain_preference: TerrainEnum | None = None + home_latitude: float | None = None + home_longitude: float | None = None + home_city: str | None = None + weekly_mileage_km: float | None = None + goal_race_date: date | None = None + bio: str | None = None + is_onboarded: bool | None = None + + +class UserProfile(UserProfileBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field( + foreign_key="user.id", unique=True, ondelete="CASCADE", index=True + ) + created_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + user: User = Relationship(back_populates="profile") + + +class UserProfilePublic(UserProfileBase): + id: uuid.UUID + user_id: uuid.UUID + created_at: datetime + updated_at: datetime + + +# ============================================================================= +# UserRaceInteraction - Tracks views, saves, and shares for recommendations +# ============================================================================= + + +class UserRaceInteraction(SQLModel, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field( + foreign_key="user.id", ondelete="CASCADE", index=True + ) + race_id: uuid.UUID = Field( + foreign_key="race.id", ondelete="CASCADE", index=True + ) + action: InteractionTypeEnum = Field(sa_column=Column(AutoString(), nullable=False)) + created_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + +class UserRaceInteractionPublic(SQLModel): + id: uuid.UUID + user_id: uuid.UUID + race_id: uuid.UUID + action: InteractionTypeEnum + created_at: datetime + + +# ============================================================================= +# Vietnam Administrative Master Data +# ============================================================================= + + +class AdministrativeRegion(SQLModel, table=True): + __tablename__ = "administrative_regions" + + id: int = Field(primary_key=True) + name: str = Field(max_length=255) + name_en: str = Field(max_length=255) + code_name: str | None = Field(default=None, max_length=255) + code_name_en: str | None = Field(default=None, max_length=255) + + +class AdministrativeRegionPublic(SQLModel): + id: int + name: str + name_en: str + code_name: str | None = None + code_name_en: str | None = None + + +class AdministrativeUnit(SQLModel, table=True): + __tablename__ = "administrative_units" + + id: int = Field(primary_key=True) + full_name: str | None = Field(default=None, max_length=255) + full_name_en: str | None = Field(default=None, max_length=255) + short_name: str | None = Field(default=None, max_length=255) + short_name_en: str | None = Field(default=None, max_length=255) + code_name: str | None = Field(default=None, max_length=255) + code_name_en: str | None = Field(default=None, max_length=255) + + provinces: list["Province"] = Relationship(back_populates="administrative_unit") + wards: list["Ward"] = Relationship(back_populates="administrative_unit") + + +class AdministrativeUnitPublic(SQLModel): + id: int + full_name: str | None = None + full_name_en: str | None = None + short_name: str | None = None + short_name_en: str | None = None + code_name: str | None = None + code_name_en: str | None = None + + +class Province(SQLModel, table=True): + __tablename__ = "provinces" + + code: str = Field(primary_key=True, max_length=20) + name: str = Field(max_length=255) + name_en: str | None = Field(default=None, max_length=255) + full_name: str = Field(max_length=255) + full_name_en: str | None = Field(default=None, max_length=255) + code_name: str | None = Field(default=None, max_length=255) + administrative_unit_id: int | None = Field( + default=None, foreign_key="administrative_units.id", index=True + ) + + administrative_unit: AdministrativeUnit | None = Relationship( + back_populates="provinces" + ) + wards: list["Ward"] = Relationship(back_populates="province") + + +class ProvincePublic(SQLModel): + code: str + name: str + name_en: str | None = None + full_name: str + full_name_en: str | None = None + code_name: str | None = None + administrative_unit_id: int | None = None + + +class ProvincePublicWithDetails(ProvincePublic): + administrative_unit: AdministrativeUnitPublic | None = None + + +class ProvincesPublic(SQLModel): + data: list[ProvincePublic] + count: int + + +class Ward(SQLModel, table=True): + __tablename__ = "wards" + + code: str = Field(primary_key=True, max_length=20) + name: str = Field(max_length=255) + name_en: str | None = Field(default=None, max_length=255) + full_name: str | None = Field(default=None, max_length=255) + full_name_en: str | None = Field(default=None, max_length=255) + code_name: str | None = Field(default=None, max_length=255) + province_code: str | None = Field( + default=None, foreign_key="provinces.code", index=True + ) + administrative_unit_id: int | None = Field( + default=None, foreign_key="administrative_units.id", index=True + ) + + province: Province | None = Relationship(back_populates="wards") + administrative_unit: AdministrativeUnit | None = Relationship( + back_populates="wards" + ) + + +class WardPublic(SQLModel): + code: str + name: str + name_en: str | None = None + full_name: str | None = None + full_name_en: str | None = None + code_name: str | None = None + province_code: str | None = None + administrative_unit_id: int | None = None + + +class WardPublicWithDetails(WardPublic): + administrative_unit: AdministrativeUnitPublic | None = None + + +class WardsPublic(SQLModel): + data: list[WardPublic] + count: int + + +# ============================================================================= +# End of Vietnam Administrative Master Data +# ============================================================================= + + # Generic message class Message(SQLModel): message: str diff --git a/backend/app/services/ai.py b/backend/app/services/ai.py new file mode 100644 index 0000000000..8da4dda3f4 --- /dev/null +++ b/backend/app/services/ai.py @@ -0,0 +1,272 @@ +"""AI service: embeddings (OpenAI) and LLM features (Anthropic).""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import anthropic +from openai import AsyncOpenAI + +from app.core.config import settings + +if TYPE_CHECKING: + from app.models import Race, UserProfile + +logger = logging.getLogger(__name__) + +_openai_client: AsyncOpenAI | None = None +_anthropic_client: anthropic.AsyncAnthropic | None = None + + +def _openai() -> AsyncOpenAI: + global _openai_client + if _openai_client is None: + _openai_client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) + return _openai_client + + +def _anthropic() -> anthropic.AsyncAnthropic: + global _anthropic_client + if _anthropic_client is None: + _anthropic_client = anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY) + return _anthropic_client + + +async def embed_text(text: str) -> list[float]: + """Return an embedding vector for an arbitrary text string.""" + response = await _openai().embeddings.create( + model=settings.EMBEDDING_MODEL, + input=text[:8191], # max token limit guard + dimensions=settings.EMBEDDING_DIMENSIONS, + ) + return response.data[0].embedding + + +async def embed_race(race: "Race") -> list[float]: + """Build a rich text representation of a race and embed it.""" + parts: list[str] = [race.name] + if race.description: + parts.append(race.description) + if race.location: + parts.append(f"Location: {race.location}") + if race.terrain_type: + parts.append(f"Terrain: {race.terrain_type.value}") + if race.difficulty_level: + parts.append(f"Difficulty: {race.difficulty_level.value}") + if race.elevation_gain_m: + parts.append(f"Elevation gain: {race.elevation_gain_m}m") + text = " | ".join(parts) + return await embed_text(text) + + +def _race_summary_block(race: "Race") -> str: + lines = [f"Race: {race.name}"] + if race.description: + lines.append(f"Description: {race.description[:500]}") + if race.location: + lines.append(f"Location: {race.location}") + if race.terrain_type: + lines.append(f"Terrain: {race.terrain_type.value}") + if race.difficulty_level: + lines.append(f"Difficulty: {race.difficulty_level.value}") + if race.elevation_gain_m: + lines.append(f"Elevation gain: {race.elevation_gain_m}m") + return "\n".join(lines) + + +async def generate_race_recommendation_explanation( + race: "Race", + profile: "UserProfile | None", +) -> str: + """Return a 1-2 sentence explanation of why this race matches the user.""" + race_block = _race_summary_block(race) + + profile_lines: list[str] = [] + if profile: + if profile.fitness_level: + profile_lines.append(f"Fitness level: {profile.fitness_level.value}") + if profile.distance_preference: + profile_lines.append(f"Distance preference: {profile.distance_preference.value}") + if profile.terrain_preference: + profile_lines.append(f"Terrain preference: {profile.terrain_preference.value}") + profile_block = "\n".join(profile_lines) if profile_lines else "No profile available." + + # System block is cacheable; user block contains dynamic profile. + system_prompt = ( + "You are a race recommendation assistant for Vietnamese running events. " + "Given a race's details and a runner's profile, write 1-2 sentences " + "explaining why this race is a good match. Be specific and encouraging. " + "Output only the explanation, no preamble." + ) + + response = await _anthropic().messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=150, + system=[ + { + "type": "text", + "text": system_prompt, + "cache_control": {"type": "ephemeral"}, + } + ], + messages=[ + { + "role": "user", + "content": ( + f"Race details:\n{race_block}\n\n" + f"Runner profile:\n{profile_block}\n\n" + "Why is this race a good match?" + ), + } + ], + ) + block = response.content[0] + return block.text if hasattr(block, "text") else "" + + +async def enhance_race_description(race: "Race") -> str: + """Suggest an improved description for a race (does not save).""" + system_prompt = ( + "You are a copywriter for Vietnamese running event listings. " + "Given a race's current details, write an engaging 2-3 paragraph description " + "that highlights the unique experience, terrain, and challenge. " + "Output only the description text." + ) + + response = await _anthropic().messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=400, + system=[ + { + "type": "text", + "text": system_prompt, + "cache_control": {"type": "ephemeral"}, + } + ], + messages=[ + { + "role": "user", + "content": _race_summary_block(race), + } + ], + ) + block = response.content[0] + return block.text if hasattr(block, "text") else "" + + +async def suggest_race_tags(race: "Race") -> list[str]: + """Return a list of suggested tag slugs for a race (does not save).""" + system_prompt = ( + "You are a race categorization assistant. Given race details, " + "suggest 3-7 short lowercase tag slugs (hyphenated, no spaces) " + "that best describe this race. Examples: trail-running, mountainous, " + "beginner-friendly, ultra-distance, night-race, scenic. " + "Return only a JSON array of strings, nothing else." + ) + + response = await _anthropic().messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=100, + system=[ + { + "type": "text", + "text": system_prompt, + "cache_control": {"type": "ephemeral"}, + } + ], + messages=[ + { + "role": "user", + "content": _race_summary_block(race), + } + ], + ) + import json + + block = response.content[0] + text = block.text if hasattr(block, "text") else "[]" + try: + tags = json.loads(text) + return [str(t) for t in tags if isinstance(t, str)] + except json.JSONDecodeError: + logger.warning("suggest_race_tags: failed to parse JSON response: %s", text) + return [] + + +async def answer_race_question(race: "Race", question: str) -> str: + """Answer a runner's question about a specific race.""" + race_block = _race_summary_block(race) + + system_prompt = ( + "You are a helpful assistant for Vietnamese running events. " + "Answer the runner's question about the race using only the provided race details. " + "If the answer is not in the race details, say so honestly. " + "Be concise and friendly." + ) + + response = await _anthropic().messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=300, + system=[ + { + "type": "text", + "text": system_prompt, + "cache_control": {"type": "ephemeral"}, + } + ], + messages=[ + { + "role": "user", + "content": ( + f"Race details:\n{race_block}\n\n" + f"Question: {question}" + ), + } + ], + ) + block = response.content[0] + return block.text if hasattr(block, "text") else "" + + +async def generate_race_from_name(race_name: str) -> dict[str, str]: + """ + Generate race details from just a race name using OpenAI. + Returns a dictionary with suggested race fields. + """ + import json + + system_prompt = ( + "You are an expert on Vietnamese running races and marathons. " + "Given a race name, generate realistic and detailed information about what this race might be. " + "Research real Vietnamese locations, terrains, and typical race characteristics. " + "Return ONLY a JSON object with these fields (all strings): " + "description, location, terrain_type (road/trail/track/mixed), " + "difficulty_level (easy/moderate/hard/extreme), elevation_gain_m (number as string). " + "Be specific and realistic for Vietnam. No extra text, only valid JSON." + ) + + try: + response = await _openai().chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Race name: {race_name}"} + ], + temperature=0.7, + max_tokens=500, + ) + + content = response.choices[0].message.content + if not content: + return {} + + # Parse the JSON response + data = json.loads(content) + return data + except json.JSONDecodeError: + logger.warning("generate_race_from_name: failed to parse JSON response") + return {} + except Exception as e: + logger.error("generate_race_from_name: error generating race details: %s", e) + return {} diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py new file mode 100644 index 0000000000..f3524ef186 --- /dev/null +++ b/backend/app/services/cache.py @@ -0,0 +1,54 @@ +"""Redis cache helpers with JSON serialisation.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +import redis.asyncio as aioredis + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +_pool: aioredis.ConnectionPool | None = None + + +def _get_pool() -> aioredis.ConnectionPool: + global _pool + if _pool is None: + _pool = aioredis.ConnectionPool.from_url(settings.REDIS_URL, decode_responses=True) + return _pool + + +def _client() -> aioredis.Redis: # type: ignore[type-arg] + return aioredis.Redis(connection_pool=_get_pool()) + + +async def cache_get(key: str) -> Any | None: + try: + value = await _client().get(key) + if value is None: + return None + return json.loads(value) + except Exception: + logger.warning("cache_get failed for key %s", key) + return None + + +async def cache_set(key: str, value: Any, ttl: int) -> None: + try: + await _client().set(key, json.dumps(value), ex=ttl) + except Exception: + logger.warning("cache_set failed for key %s", key) + + +async def cache_delete_pattern(pattern: str) -> None: + try: + client = _client() + keys = await client.keys(pattern) + if keys: + await client.delete(*keys) + except Exception: + logger.warning("cache_delete_pattern failed for pattern %s", pattern) diff --git a/backend/app/services/media_storage.py b/backend/app/services/media_storage.py new file mode 100644 index 0000000000..fdc2c74b70 --- /dev/null +++ b/backend/app/services/media_storage.py @@ -0,0 +1,57 @@ +import uuid +from pathlib import Path + +from fastapi import UploadFile + +from app.core.config import settings + + +def _safe_extension(filename: str) -> str: + ext = Path(filename).suffix.lower().strip() + if not ext: + return ".bin" + if len(ext) > 10: + return ".bin" + return ext + + +def get_media_storage_root() -> Path: + root = Path(settings.MEDIA_UPLOAD_DIR) + if not root.is_absolute(): + root = Path(__file__).resolve().parents[2] / root + root.mkdir(parents=True, exist_ok=True) + return root + + +def save_uploaded_media( + *, + file: UploadFile, + content_type: str, + content_id: uuid.UUID, +) -> tuple[str, str, int]: + """Save file and return (stored_filename, relative_path, size_bytes).""" + storage_root = get_media_storage_root() + target_dir = storage_root / content_type / str(content_id) + target_dir.mkdir(parents=True, exist_ok=True) + + ext = _safe_extension(file.filename or "") + stored_filename = f"{uuid.uuid4().hex}{ext}" + target_path = target_dir / stored_filename + + content = file.file.read() + size_bytes = len(content) + target_path.write_bytes(content) + + relative_path = str(target_path.relative_to(storage_root)) + return stored_filename, relative_path, size_bytes + + +def resolve_media_path(file_path: str) -> Path: + storage_root = get_media_storage_root() + return storage_root / file_path + + +def delete_media_file(file_path: str) -> None: + target = resolve_media_path(file_path) + if target.exists() and target.is_file(): + target.unlink() diff --git a/backend/app/utils.py b/backend/app/utils.py index 29fcfc1471..6078d8c5a1 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -1,4 +1,5 @@ import logging +import math from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path @@ -121,3 +122,38 @@ def verify_password_reset_token(token: str) -> str | None: return str(decoded_token["sub"]) except InvalidTokenError: return None + + +# ============================================================================= +# Geo utilities +# ============================================================================= + +_EARTH_RADIUS_KM = 6371.0 + + +def haversine_distance_km( + lat1: float, lon1: float, lat2: float, lon2: float +) -> float: + """Return great-circle distance in kilometres between two WGS-84 points.""" + phi1, phi2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlambda = math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2 + return _EARTH_RADIUS_KM * 2 * math.asin(math.sqrt(a)) + + +def haversine_sql_expr(lat: float, lon: float) -> str: + """ + Return a PostgreSQL SQL fragment that evaluates to the great-circle distance + (in km) from a fixed point (lat, lon) to the race.latitude / race.longitude columns. + Safe to embed in a WHERE or ORDER BY clause via sqlalchemy text(). + """ + return ( + f"({_EARTH_RADIUS_KM} * acos(" + f" least(1.0," + f" sin(radians({lat})) * sin(radians(race.latitude))" + f" + cos(radians({lat})) * cos(radians(race.latitude))" + f" * cos(radians(race.longitude) - radians({lon}))" + f" )" + f"))" + ) diff --git a/backend/app/worker.py b/backend/app/worker.py new file mode 100644 index 0000000000..73401e5d7b --- /dev/null +++ b/backend/app/worker.py @@ -0,0 +1,58 @@ +"""ARQ worker: background job definitions and settings.""" + +from __future__ import annotations + +import logging +import uuid + +from arq.connections import RedisSettings + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +async def generate_race_embedding(ctx: dict, race_id_str: str) -> None: + """ARQ job: compute and persist the embedding for a single race.""" + from sqlmodel import Session + from app.core.db import engine + from app import crud + from app.services.ai import embed_race + + race_id = uuid.UUID(race_id_str) + try: + with Session(engine) as session: + race = crud.get_race(session=session, race_id=race_id) + if race is None: + logger.warning("generate_race_embedding: race %s not found", race_id) + return + vector = await embed_race(race) + crud.update_race_embedding(session=session, race_id=race_id, embedding=vector) + logger.info("Embedded race %s (%d dims)", race_id, len(vector)) + except Exception: + logger.exception("generate_race_embedding failed for race %s", race_id) + raise + + +async def reindex_all_races(ctx: dict, batch_size: int = 50) -> None: + """ARQ job: queue embedding generation for all un-embedded races.""" + from sqlmodel import Session + from app.core.db import engine + from app import crud + + with Session(engine) as session: + races = crud.get_races_without_embedding(session=session, limit=batch_size) + + for race in races: + await ctx["redis"].enqueue_job("generate_race_embedding", str(race.id)) + + logger.info("Queued %d races for embedding", len(races)) + + +class WorkerSettings: + """ARQ worker configuration.""" + + redis_settings = RedisSettings.from_dsn(settings.REDIS_URL) + functions = [generate_race_embedding, reindex_all_races] + max_jobs = 10 + job_timeout = 300 # 5 minutes per embedding job diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 89a5bdd74e..f370266d05 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,11 +19,17 @@ dependencies = [ "sentry-sdk[fastapi]>=2.0.0,<3.0.0", "pyjwt<3.0.0,>=2.8.0", "pwdlib[argon2,bcrypt]>=0.3.0", + "anthropic>=0.40.0", + "openai>=1.0.0", + "pgvector>=0.3.0", + "redis[hiredis]>=5.0.0", + "arq>=0.26.0", ] [dependency-groups] dev = [ "pytest<8.0.0,>=7.4.3", + "pytest-asyncio>=0.23.0", "mypy<2.0.0,>=1.8.0", "ty>=0.0.25", "ruff<1.0.0,>=0.2.2", @@ -66,6 +72,9 @@ ignore = [ # Preserve types, even if a file imports `from __future__ import annotations`. keep-runtime-typing = true +[tool.pytest.ini_options] +asyncio_mode = "auto" + [tool.coverage.run] source = ["app"] dynamic_context = "test_function" diff --git a/backend/scripts/seed_races.py b/backend/scripts/seed_races.py new file mode 100755 index 0000000000..77238f986b --- /dev/null +++ b/backend/scripts/seed_races.py @@ -0,0 +1,502 @@ +#!/usr/bin/env python3 +"""Seed the database with real Vietnamese race events. + +Usage (from src/backend/): + uv run python scripts/seed_races.py +""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from datetime import date +from sqlmodel import Session, select + +from app.core.db import engine +from app.models import ( + Race, + RaceCategoryCreate, + RaceCreate, + User, +) +from app import crud + +RACES: list[dict] = [ + { + "name": "Vietnam Mountain Marathon", + "description": "The iconic trail race through the misty peaks of Sa Pa, Lào Cai. One of Southeast Asia's most spectacular mountain races with breathtaking views of terraced rice fields.", + "location": "Sa Pa, Lào Cai", + "city": "Sa Pa", + "country": "Vietnam", + "latitude": 22.3364, + "longitude": 103.8438, + "terrain_type": "trail", + "difficulty_level": "extreme", + "elevation_gain_m": 4200, + "is_certified": True, + "website_url": "https://vietnammountainmarathon.com", + "event_start_date": date(2026, 8, 15), + "event_end_date": date(2026, 8, 16), + "base_price": 1_500_000, + "status": "published", + "categories": [ + {"name": "70K Ultra", "distance_km": 70, "price": 2_000_000, "cutoff_time_minutes": 1800}, + {"name": "42K Marathon", "distance_km": 42, "price": 1_500_000, "cutoff_time_minutes": 960}, + {"name": "21K Half", "distance_km": 21, "price": 900_000, "cutoff_time_minutes": 480}, + ], + }, + { + "name": "Dalat Ultra Trail", + "description": "Running through the pine forests and waterfalls of the Central Highlands, this race showcases Da Lat's stunning cool-climate scenery.", + "location": "Đà Lạt, Lâm Đồng", + "city": "Đà Lạt", + "country": "Vietnam", + "latitude": 11.9404, + "longitude": 108.4583, + "terrain_type": "trail", + "difficulty_level": "hard", + "elevation_gain_m": 2800, + "is_certified": False, + "website_url": None, + "event_start_date": date(2026, 5, 20), + "base_price": 800_000, + "status": "registration_open", + "categories": [ + {"name": "100K Ultra", "distance_km": 100, "price": 1_800_000, "cutoff_time_minutes": 2400}, + {"name": "50K", "distance_km": 50, "price": 1_200_000, "cutoff_time_minutes": 1200}, + {"name": "25K", "distance_km": 25, "price": 700_000, "cutoff_time_minutes": 600}, + ], + }, + { + "name": "Hà Nội International Marathon", + "description": "Run through the historic streets of Vietnam's capital city, passing Hoan Kiem Lake and the Old Quarter.", + "location": "Hà Nội", + "city": "Hà Nội", + "country": "Vietnam", + "latitude": 21.0285, + "longitude": 105.8542, + "terrain_type": "road", + "difficulty_level": "moderate", + "elevation_gain_m": 120, + "is_certified": True, + "website_url": None, + "event_start_date": date(2026, 11, 8), + "base_price": 600_000, + "status": "published", + "categories": [ + {"name": "42K Full Marathon", "distance_km": 42.195, "price": 900_000, "cutoff_time_minutes": 360}, + {"name": "21K Half Marathon", "distance_km": 21.1, "price": 600_000, "cutoff_time_minutes": 210}, + {"name": "10K", "distance_km": 10, "price": 350_000, "cutoff_time_minutes": 120}, + {"name": "5K Fun Run", "distance_km": 5, "price": 200_000, "cutoff_time_minutes": 90}, + ], + }, + { + "name": "Ho Chi Minh City Marathon", + "description": "The biggest road race in southern Vietnam, starting at the iconic City Hall and winding through District 1's wide boulevards.", + "location": "Hồ Chí Minh City", + "city": "Hồ Chí Minh City", + "country": "Vietnam", + "latitude": 10.7769, + "longitude": 106.7009, + "terrain_type": "road", + "difficulty_level": "moderate", + "elevation_gain_m": 60, + "is_certified": True, + "event_start_date": date(2026, 10, 11), + "base_price": 700_000, + "status": "published", + "categories": [ + {"name": "42K", "distance_km": 42.195, "price": 1_000_000, "cutoff_time_minutes": 360}, + {"name": "21K", "distance_km": 21.1, "price": 650_000, "cutoff_time_minutes": 210}, + {"name": "10K", "distance_km": 10, "price": 400_000, "cutoff_time_minutes": 120}, + ], + }, + { + "name": "Mù Cang Chải Skyrace", + "description": "One of Vietnam's most technical skyrunning routes, traversing the dramatic Mù Cang Chải terraced landscape at altitudes above 2000m.", + "location": "Mù Cang Chải, Yên Bái", + "city": "Mù Cang Chải", + "country": "Vietnam", + "latitude": 21.8167, + "longitude": 104.0833, + "terrain_type": "trail", + "difficulty_level": "extreme", + "elevation_gain_m": 5500, + "is_certified": False, + "event_start_date": date(2026, 9, 27), + "base_price": 1_200_000, + "status": "published", + "categories": [ + {"name": "VK (Vertical Kilometer)", "distance_km": 8, "price": 800_000, "cutoff_time_minutes": 180}, + {"name": "Skyrace 28K", "distance_km": 28, "price": 1_200_000, "cutoff_time_minutes": 600}, + ], + }, + { + "name": "Hội An Ancient Town Night Run", + "description": "A unique evening run through the UNESCO World Heritage streets of Hội An, lit by hundreds of lanterns.", + "location": "Hội An, Quảng Nam", + "city": "Hội An", + "country": "Vietnam", + "latitude": 15.8801, + "longitude": 108.3380, + "terrain_type": "road", + "difficulty_level": "easy", + "elevation_gain_m": 10, + "is_certified": False, + "event_start_date": date(2026, 4, 4), + "base_price": 300_000, + "status": "registration_open", + "categories": [ + {"name": "10K Night Run", "distance_km": 10, "price": 350_000, "cutoff_time_minutes": 120}, + {"name": "5K Fun Run", "distance_km": 5, "price": 200_000, "cutoff_time_minutes": 90}, + ], + }, + { + "name": "Nha Trang Bay Run", + "description": "A scenic coastal run along Nha Trang's famous beachfront with views of islands and turquoise water.", + "location": "Nha Trang, Khánh Hòa", + "city": "Nha Trang", + "country": "Vietnam", + "latitude": 12.2388, + "longitude": 109.1967, + "terrain_type": "road", + "difficulty_level": "easy", + "elevation_gain_m": 40, + "is_certified": True, + "event_start_date": date(2026, 6, 7), + "base_price": 450_000, + "status": "published", + "categories": [ + {"name": "21K", "distance_km": 21.1, "price": 600_000, "cutoff_time_minutes": 240}, + {"name": "10K", "distance_km": 10, "price": 400_000, "cutoff_time_minutes": 120}, + ], + }, + { + "name": "Phong Nha Cave Trail", + "description": "Run through the UNESCO World Heritage karst landscape of Phong Nha-Kẻ Bàng National Park.", + "location": "Phong Nha, Quảng Bình", + "city": "Phong Nha", + "country": "Vietnam", + "latitude": 17.5920, + "longitude": 106.2853, + "terrain_type": "trail", + "difficulty_level": "hard", + "elevation_gain_m": 1800, + "is_certified": False, + "event_start_date": date(2026, 7, 18), + "base_price": 700_000, + "status": "published", + "categories": [ + {"name": "50K", "distance_km": 50, "price": 1_000_000, "cutoff_time_minutes": 1200}, + {"name": "25K", "distance_km": 25, "price": 700_000, "cutoff_time_minutes": 600}, + ], + }, + { + "name": "Fansipan Vertical Race", + "description": "Climb Indochina's highest peak (3143m) in this vertical race from Sa Pa town to the summit of Fansipan.", + "location": "Sa Pa, Lào Cai", + "city": "Sa Pa", + "country": "Vietnam", + "latitude": 22.3033, + "longitude": 103.7756, + "terrain_type": "trail", + "difficulty_level": "extreme", + "elevation_gain_m": 2800, + "is_certified": False, + "event_start_date": date(2026, 5, 30), + "base_price": 1_500_000, + "status": "published", + "categories": [ + {"name": "Summit Attempt", "distance_km": 19, "price": 1_500_000, "cutoff_time_minutes": 480}, + ], + }, + { + "name": "Đà Nẵng International Marathon", + "description": "Run along the iconic Dragon Bridge and Han River waterfront in Vietnam's most livable city.", + "location": "Đà Nẵng", + "city": "Đà Nẵng", + "country": "Vietnam", + "latitude": 16.0544, + "longitude": 108.2022, + "terrain_type": "road", + "difficulty_level": "moderate", + "elevation_gain_m": 80, + "is_certified": True, + "event_start_date": date(2026, 8, 2), + "base_price": 600_000, + "status": "registration_open", + "categories": [ + {"name": "42K", "distance_km": 42.195, "price": 900_000, "cutoff_time_minutes": 360}, + {"name": "21K", "distance_km": 21.1, "price": 600_000, "cutoff_time_minutes": 210}, + {"name": "10K", "distance_km": 10, "price": 350_000, "cutoff_time_minutes": 120}, + ], + }, + { + "name": "Mekong Delta River Run", + "description": "A flat, scenic run through the lush Mekong Delta, crossing ancient bridges and passing floating markets.", + "location": "Cần Thơ, Cần Thơ", + "city": "Cần Thơ", + "country": "Vietnam", + "latitude": 10.0452, + "longitude": 105.7469, + "terrain_type": "road", + "difficulty_level": "easy", + "elevation_gain_m": 15, + "is_certified": False, + "event_start_date": date(2026, 12, 13), + "base_price": 300_000, + "status": "published", + "categories": [ + {"name": "21K", "distance_km": 21.1, "price": 500_000, "cutoff_time_minutes": 240}, + {"name": "10K", "distance_km": 10, "price": 300_000, "cutoff_time_minutes": 120}, + ], + }, + { + "name": "Hà Giang Loop Trail", + "description": "Explore the rugged, remote northern highlands on this multi-day ultra trail through the Đồng Văn Karst Plateau Geopark.", + "location": "Đồng Văn, Hà Giang", + "city": "Đồng Văn", + "country": "Vietnam", + "latitude": 23.2744, + "longitude": 105.3592, + "terrain_type": "trail", + "difficulty_level": "extreme", + "elevation_gain_m": 6000, + "is_certified": False, + "event_start_date": date(2026, 10, 24), + "base_price": 2_000_000, + "status": "published", + "categories": [ + {"name": "150K Ultra", "distance_km": 150, "price": 3_000_000, "cutoff_time_minutes": 4800}, + {"name": "70K", "distance_km": 70, "price": 2_000_000, "cutoff_time_minutes": 2000}, + ], + }, + { + "name": "Huế Imperial City Run", + "description": "A heritage run through the ancient imperial capital, starting at the Citadel walls and weaving through royal gardens.", + "location": "Huế, Thừa Thiên Huế", + "city": "Huế", + "country": "Vietnam", + "latitude": 16.4637, + "longitude": 107.5909, + "terrain_type": "road", + "difficulty_level": "easy", + "elevation_gain_m": 25, + "is_certified": False, + "event_start_date": date(2026, 3, 22), + "base_price": 250_000, + "status": "registration_open", + "categories": [ + {"name": "10K", "distance_km": 10, "price": 300_000, "cutoff_time_minutes": 120}, + {"name": "5K Heritage Walk/Run", "distance_km": 5, "price": 150_000, "cutoff_time_minutes": 90}, + ], + }, + { + "name": "Bidoup Núi Bà Trail Race", + "description": "Technical trail race through Bidoup-Núi Bà National Park, home to rare wildlife and towering primeval forests.", + "location": "Đà Lạt, Lâm Đồng", + "city": "Đà Lạt", + "country": "Vietnam", + "latitude": 12.1667, + "longitude": 108.7000, + "terrain_type": "trail", + "difficulty_level": "hard", + "elevation_gain_m": 2200, + "is_certified": False, + "event_start_date": date(2026, 6, 27), + "base_price": 700_000, + "status": "published", + "categories": [ + {"name": "50K", "distance_km": 50, "price": 1_100_000, "cutoff_time_minutes": 1200}, + {"name": "25K", "distance_km": 25, "price": 700_000, "cutoff_time_minutes": 600}, + {"name": "12K", "distance_km": 12, "price": 400_000, "cutoff_time_minutes": 240}, + ], + }, + { + "name": "Long Biên Half Marathon", + "description": "A fast, flat race across the iconic Long Biên Bridge spanning the Red River in Hà Nội.", + "location": "Hà Nội", + "city": "Hà Nội", + "country": "Vietnam", + "latitude": 21.0459, + "longitude": 105.8671, + "terrain_type": "road", + "difficulty_level": "moderate", + "elevation_gain_m": 30, + "is_certified": False, + "event_start_date": date(2026, 3, 8), + "base_price": 400_000, + "status": "registration_open", + "categories": [ + {"name": "21K", "distance_km": 21.1, "price": 500_000, "cutoff_time_minutes": 240}, + {"name": "10K", "distance_km": 10, "price": 350_000, "cutoff_time_minutes": 120}, + ], + }, + { + "name": "Bà Nà Hills Trail Challenge", + "description": "Run on the trails surrounding the famous Bà Nà Hills resort area with its legendary Golden Bridge and French village.", + "location": "Đà Nẵng", + "city": "Đà Nẵng", + "country": "Vietnam", + "latitude": 15.9978, + "longitude": 107.9877, + "terrain_type": "trail", + "difficulty_level": "hard", + "elevation_gain_m": 1500, + "is_certified": False, + "event_start_date": date(2026, 4, 18), + "base_price": 800_000, + "status": "published", + "categories": [ + {"name": "30K", "distance_km": 30, "price": 900_000, "cutoff_time_minutes": 720}, + {"name": "15K", "distance_km": 15, "price": 600_000, "cutoff_time_minutes": 360}, + ], + }, + { + "name": "Côn Đảo Island Race", + "description": "A rare racing experience on the remote Côn Đảo archipelago, known for its pristine beaches and historical significance.", + "location": "Côn Đảo, Bà Rịa-Vũng Tàu", + "city": "Côn Đảo", + "country": "Vietnam", + "latitude": 8.6833, + "longitude": 106.6000, + "terrain_type": "mixed", + "difficulty_level": "moderate", + "elevation_gain_m": 400, + "is_certified": False, + "event_start_date": date(2026, 11, 28), + "base_price": 1_200_000, + "status": "published", + "categories": [ + {"name": "21K", "distance_km": 21.1, "price": 1_200_000, "cutoff_time_minutes": 300}, + {"name": "10K", "distance_km": 10, "price": 800_000, "cutoff_time_minutes": 150}, + ], + }, + { + "name": "Tam Đảo Mountain Climb", + "description": "A classic mountain run from Tam Đảo town up the cloud forest ridge above Vĩnh Phúc province.", + "location": "Tam Đảo, Vĩnh Phúc", + "city": "Tam Đảo", + "country": "Vietnam", + "latitude": 21.4667, + "longitude": 105.6500, + "terrain_type": "trail", + "difficulty_level": "hard", + "elevation_gain_m": 900, + "is_certified": False, + "event_start_date": date(2026, 9, 6), + "base_price": 500_000, + "status": "published", + "categories": [ + {"name": "22K Trail", "distance_km": 22, "price": 650_000, "cutoff_time_minutes": 480}, + {"name": "11K Trail", "distance_km": 11, "price": 400_000, "cutoff_time_minutes": 240}, + ], + }, + { + "name": "Phan Thiết Beach Race", + "description": "Race along the sandy beaches and red sand dunes of Phan Thiết, Vietnam's kite-surfing capital.", + "location": "Phan Thiết, Bình Thuận", + "city": "Phan Thiết", + "country": "Vietnam", + "latitude": 10.9281, + "longitude": 108.1030, + "terrain_type": "mixed", + "difficulty_level": "moderate", + "elevation_gain_m": 50, + "is_certified": False, + "event_start_date": date(2026, 7, 4), + "base_price": 400_000, + "status": "published", + "categories": [ + {"name": "21K", "distance_km": 21.1, "price": 550_000, "cutoff_time_minutes": 270}, + {"name": "10K", "distance_km": 10, "price": 350_000, "cutoff_time_minutes": 130}, + ], + }, + { + "name": "Sóc Sơn Forest Trail", + "description": "A beginner-friendly forest trail race in the green lung north of Hà Nội, perfect for runners new to trail running.", + "location": "Sóc Sơn, Hà Nội", + "city": "Sóc Sơn", + "country": "Vietnam", + "latitude": 21.2378, + "longitude": 105.8481, + "terrain_type": "trail", + "difficulty_level": "easy", + "elevation_gain_m": 200, + "is_certified": False, + "event_start_date": date(2026, 4, 11), + "base_price": 250_000, + "status": "registration_open", + "categories": [ + {"name": "12K Intro Trail", "distance_km": 12, "price": 300_000, "cutoff_time_minutes": 240}, + {"name": "6K Walk/Run", "distance_km": 6, "price": 150_000, "cutoff_time_minutes": 120}, + ], + }, +] + + +def main() -> None: + with Session(engine) as session: + # Get or create organizer user + organizer = session.exec(select(User).where(User.is_superuser)).first() + if organizer is None: + print("ERROR: No superuser found. Run the app first to create the initial superuser.") + sys.exit(1) + + created = 0 + skipped = 0 + for race_data in RACES: + # Check if race already exists by name + existing = session.exec( + select(Race).where(Race.name == race_data["name"]) + ).first() + if existing: + skipped += 1 + continue + + categories_data = race_data.pop("categories", []) + + race_in = RaceCreate( + name=race_data["name"], + description=race_data.get("description"), + location=race_data["location"], + city=race_data.get("city"), + country=race_data.get("country", "Vietnam"), + latitude=race_data.get("latitude"), + longitude=race_data.get("longitude"), + terrain_type=race_data.get("terrain_type"), + difficulty_level=race_data.get("difficulty_level"), + elevation_gain_m=race_data.get("elevation_gain_m"), + is_certified=race_data.get("is_certified", False), + website_url=race_data.get("website_url"), + event_start_date=race_data["event_start_date"], + event_end_date=race_data.get("event_end_date"), + base_price=race_data.get("base_price"), + status=race_data.get("status", "published"), + ) + + race = crud.create_race( + session=session, + race_in=race_in, + organizer_id=organizer.id, + ) + + for cat_data in categories_data: + cat_in = RaceCategoryCreate( + name=cat_data["name"], + distance_km=cat_data.get("distance_km"), + price=cat_data.get("price"), + cutoff_time_minutes=cat_data.get("cutoff_time_minutes"), + race_id=race.id, + ) + crud.create_race_category(session=session, category_in=cat_in) + + created += 1 + print(f" Created: {race.name}") + + print(f"\nDone — {created} races created, {skipped} already existed.") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/api/routes/test_profiles.py b/backend/tests/api/routes/test_profiles.py new file mode 100644 index 0000000000..4d2c29f060 --- /dev/null +++ b/backend/tests/api/routes/test_profiles.py @@ -0,0 +1,194 @@ +"""API tests for /users/me/profile and saved-race endpoints.""" + +from fastapi.testclient import TestClient + +from app.core.config import settings + + +API = settings.API_V1_STR + + +# --------------------------------------------------------------------------- +# Profile endpoints +# --------------------------------------------------------------------------- + + +def test_get_profile_unauthenticated(client: TestClient) -> None: + r = client.get(f"{API}/users/me/profile") + assert r.status_code == 403 + + +def test_get_profile_not_found( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + r = client.get(f"{API}/users/me/profile", headers=normal_user_token_headers) + # Profile doesn't exist yet for a fresh user + assert r.status_code == 404 + + +def test_create_profile( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + data = { + "fitness_level": "intermediate", + "distance_preference": "mid", + "terrain_preference": "trail", + "home_city": "Hanoi", + "is_onboarded": True, + } + r = client.post( + f"{API}/users/me/profile", headers=normal_user_token_headers, json=data + ) + assert r.status_code == 200 + body = r.json() + assert body["fitness_level"] == "intermediate" + assert body["terrain_preference"] == "trail" + assert body["home_city"] == "Hanoi" + assert body["is_onboarded"] is True + assert "id" in body + assert "user_id" in body + + +def test_get_profile_after_create( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + # Ensure profile exists + client.post( + f"{API}/users/me/profile", + headers=normal_user_token_headers, + json={"fitness_level": "beginner", "is_onboarded": False}, + ) + r = client.get(f"{API}/users/me/profile", headers=normal_user_token_headers) + assert r.status_code == 200 + body = r.json() + assert "fitness_level" in body + + +def test_patch_profile( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + # Ensure profile exists + client.post( + f"{API}/users/me/profile", + headers=normal_user_token_headers, + json={"fitness_level": "beginner"}, + ) + r = client.patch( + f"{API}/users/me/profile", + headers=normal_user_token_headers, + json={"home_city": "Da Nang"}, + ) + assert r.status_code == 200 + assert r.json()["home_city"] == "Da Nang" + + +def test_upsert_profile_is_idempotent( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + data = {"fitness_level": "elite", "is_onboarded": True} + r1 = client.post( + f"{API}/users/me/profile", headers=normal_user_token_headers, json=data + ) + r2 = client.post( + f"{API}/users/me/profile", headers=normal_user_token_headers, json=data + ) + assert r1.status_code == 200 + assert r2.status_code == 200 + assert r1.json()["id"] == r2.json()["id"] + + +def test_delete_profile( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + # Create then delete as superuser + client.post( + f"{API}/users/me/profile", + headers=superuser_token_headers, + json={"is_onboarded": True}, + ) + r = client.delete(f"{API}/users/me/profile", headers=superuser_token_headers) + assert r.status_code == 200 + assert r.json()["message"] == "Profile deleted" + + +# --------------------------------------------------------------------------- +# Saved races +# --------------------------------------------------------------------------- + + +def test_get_saved_races_empty( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + r = client.get(f"{API}/users/me/saved-races", headers=normal_user_token_headers) + assert r.status_code == 200 + body = r.json() + assert body["data"] == [] + assert body["count"] == 0 + + +def test_save_race_not_found( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + import uuid + r = client.post( + f"{API}/races/{uuid.uuid4()}/save", headers=normal_user_token_headers + ) + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Tags +# --------------------------------------------------------------------------- + + +def test_list_tags_public(client: TestClient) -> None: + r = client.get(f"{API}/tags/") + assert r.status_code == 200 + body = r.json() + assert "data" in body + assert "count" in body + + +def test_create_tag_requires_admin( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + r = client.post( + f"{API}/tags/", + headers=normal_user_token_headers, + json={"name": "Scenic", "slug": "scenic"}, + ) + assert r.status_code == 403 + + +def test_create_tag_as_superuser( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + import uuid + slug = f"test-{uuid.uuid4().hex[:8]}" + r = client.post( + f"{API}/tags/", + headers=superuser_token_headers, + json={"name": f"Tag {slug}", "slug": slug}, + ) + assert r.status_code == 200 + body = r.json() + assert body["slug"] == slug + assert "id" in body + + +def test_create_duplicate_tag_returns_409( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + import uuid + slug = f"dup-{uuid.uuid4().hex[:8]}" + client.post( + f"{API}/tags/", + headers=superuser_token_headers, + json={"name": f"Dup {slug}", "slug": slug}, + ) + r = client.post( + f"{API}/tags/", + headers=superuser_token_headers, + json={"name": f"Dup2 {slug}", "slug": slug}, + ) + assert r.status_code == 409 diff --git a/backend/tests/api/routes/test_races_search.py b/backend/tests/api/routes/test_races_search.py new file mode 100644 index 0000000000..82ace28b78 --- /dev/null +++ b/backend/tests/api/routes/test_races_search.py @@ -0,0 +1,205 @@ +"""Tests for search & discovery race endpoints.""" + +import uuid + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.models import Race, RaceStatusEnum + +API = settings.API_V1_STR + + +def _create_race( + client: TestClient, + headers: dict[str, str], + name: str = "Test Race", + status: str = "published", + **kwargs: object, +) -> dict: + payload = { + "name": name, + "status": status, + "city": "Hanoi", + "country": "Vietnam", + **kwargs, + } + r = client.post(f"{API}/races/", headers=headers, json=payload) + assert r.status_code == 200, r.text + return r.json() + + +# --------------------------------------------------------------------------- +# /search +# --------------------------------------------------------------------------- + + +def test_search_races_empty_query(client: TestClient) -> None: + r = client.get(f"{API}/races/search") + assert r.status_code == 200 + body = r.json() + assert "data" in body + assert "count" in body + + +def test_search_races_with_q( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + slug = uuid.uuid4().hex[:8] + _create_race(client, superuser_token_headers, name=f"UniqueMarathon {slug}") + r = client.get(f"{API}/races/search", params={"q": f"UniqueMarathon {slug}"}) + assert r.status_code == 200 + + +def test_search_races_status_filter( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + _create_race(client, superuser_token_headers, status="draft") + r = client.get(f"{API}/races/search", params={"status": "published"}) + assert r.status_code == 200 + body = r.json() + for race in body["data"]: + assert race["status"] == "published" + + +def test_search_races_terrain_filter( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + _create_race( + client, superuser_token_headers, name="Trail Blast", terrain_type="trail" + ) + r = client.get(f"{API}/races/search", params={"terrain": "trail"}) + assert r.status_code == 200 + body = r.json() + for race in body["data"]: + assert race["terrain_type"] == "trail" + + +def test_search_races_sort_options(client: TestClient) -> None: + for sort in ("date", "popularity"): + r = client.get(f"{API}/races/search", params={"sort": sort}) + assert r.status_code == 200 + + r = client.get(f"{API}/races/search", params={"sort": "invalid"}) + assert r.status_code == 422 + + +def test_search_races_pagination(client: TestClient) -> None: + r = client.get(f"{API}/races/search", params={"skip": 0, "limit": 5}) + assert r.status_code == 200 + body = r.json() + assert len(body["data"]) <= 5 + + +# --------------------------------------------------------------------------- +# /nearby +# --------------------------------------------------------------------------- + + +def test_nearby_requires_lat_lon(client: TestClient) -> None: + r = client.get(f"{API}/races/nearby") + assert r.status_code == 422 + + +def test_nearby_returns_list(client: TestClient) -> None: + r = client.get( + f"{API}/races/nearby", params={"lat": 21.03, "lon": 105.83, "radius_km": 500} + ) + assert r.status_code == 200 + body = r.json() + assert "data" in body + assert "count" in body + for race in body["data"]: + assert "distance_km" in race + + +def test_nearby_distance_is_numeric(client: TestClient) -> None: + r = client.get( + f"{API}/races/nearby", + params={"lat": 0, "lon": 0, "radius_km": 20000}, + ) + assert r.status_code == 200 + for race in r.json()["data"]: + assert isinstance(race["distance_km"], float) + + +# --------------------------------------------------------------------------- +# /trending +# --------------------------------------------------------------------------- + + +def test_trending_public(client: TestClient) -> None: + r = client.get(f"{API}/races/trending") + assert r.status_code == 200 + body = r.json() + assert "data" in body + assert "count" in body + + +def test_trending_days_param(client: TestClient) -> None: + r = client.get(f"{API}/races/trending", params={"days": 30, "limit": 5}) + assert r.status_code == 200 + + +def test_trending_invalid_days(client: TestClient) -> None: + r = client.get(f"{API}/races/trending", params={"days": 0}) + assert r.status_code == 422 + + +# --------------------------------------------------------------------------- +# /recommended +# --------------------------------------------------------------------------- + + +def test_recommended_requires_auth(client: TestClient) -> None: + r = client.get(f"{API}/races/recommended") + assert r.status_code == 403 + + +def test_recommended_for_authenticated_user( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + r = client.get(f"{API}/races/recommended", headers=normal_user_token_headers) + assert r.status_code == 200 + body = r.json() + assert "data" in body + assert "count" in body + + +def test_recommended_with_profile( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + # Create a profile to get non-fallback recommendations + client.post( + f"{API}/users/me/profile", + headers=normal_user_token_headers, + json={"fitness_level": "intermediate", "terrain_preference": "trail"}, + ) + r = client.get(f"{API}/races/recommended", headers=normal_user_token_headers) + assert r.status_code == 200 + + +# --------------------------------------------------------------------------- +# /{race_id}/similar +# --------------------------------------------------------------------------- + + +def test_similar_not_found(client: TestClient) -> None: + r = client.get(f"{API}/races/{uuid.uuid4()}/similar") + assert r.status_code == 404 + + +def test_similar_returns_list( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + race = _create_race( + client, superuser_token_headers, name="Base Race", terrain_type="road" + ) + r = client.get(f"{API}/races/{race['id']}/similar") + assert r.status_code == 200 + body = r.json() + assert "data" in body + for r_item in body["data"]: + assert r_item["id"] != race["id"] diff --git a/backend/tests/crud/test_tags_profile_interactions.py b/backend/tests/crud/test_tags_profile_interactions.py new file mode 100644 index 0000000000..745f0bf55c --- /dev/null +++ b/backend/tests/crud/test_tags_profile_interactions.py @@ -0,0 +1,167 @@ +"""CRUD tests for RaceTag, UserProfile, and UserRaceInteraction.""" + +import uuid + +import pytest +from sqlmodel import Session + +from app import crud +from app.models import ( + FitnessEnum, + InteractionTypeEnum, + RaceTagCreate, + TerrainEnum, + UserProfileCreate, + UserProfileUpdate, +) +from tests.utils.user import create_random_user + + +# --------------------------------------------------------------------------- +# RaceTag +# --------------------------------------------------------------------------- + + +def test_get_or_create_tag_creates_new(db: Session) -> None: + slug = f"test-tag-{uuid.uuid4().hex[:8]}" + tag_in = RaceTagCreate(name=f"Test Tag {slug}", slug=slug) + tag = crud.get_or_create_tag(session=db, tag_in=tag_in) + assert tag.id is not None + assert tag.slug == slug + + +def test_get_or_create_tag_returns_existing(db: Session) -> None: + slug = f"dup-{uuid.uuid4().hex[:8]}" + tag_in = RaceTagCreate(name=f"Dup {slug}", slug=slug) + tag1 = crud.get_or_create_tag(session=db, tag_in=tag_in) + tag2 = crud.get_or_create_tag(session=db, tag_in=tag_in) + assert tag1.id == tag2.id + + +def test_get_all_tags_returns_list(db: Session) -> None: + slug = f"list-{uuid.uuid4().hex[:8]}" + crud.get_or_create_tag( + session=db, tag_in=RaceTagCreate(name=f"List {slug}", slug=slug) + ) + tags = crud.get_all_tags(session=db) + assert isinstance(tags, list) + assert len(tags) >= 1 + + +def test_get_all_tags_count(db: Session) -> None: + count = crud.get_all_tags_count(session=db) + assert count >= 0 + + +# --------------------------------------------------------------------------- +# UserProfile +# --------------------------------------------------------------------------- + + +def test_get_user_profile_none_when_missing(db: Session) -> None: + user = create_random_user(db) + profile = crud.get_user_profile(session=db, user_id=user.id) + assert profile is None + + +def test_upsert_user_profile_creates(db: Session) -> None: + user = create_random_user(db) + profile_in = UserProfileCreate( + fitness_level=FitnessEnum.INTERMEDIATE, + terrain_preference=TerrainEnum.TRAIL, + home_city="Hanoi", + is_onboarded=True, + ) + profile = crud.upsert_user_profile( + session=db, user_id=user.id, profile_in=profile_in + ) + assert profile.user_id == user.id + assert profile.fitness_level == FitnessEnum.INTERMEDIATE + assert profile.terrain_preference == TerrainEnum.TRAIL + assert profile.home_city == "Hanoi" + assert profile.is_onboarded is True + + +def test_upsert_user_profile_updates_existing(db: Session) -> None: + user = create_random_user(db) + crud.upsert_user_profile( + session=db, + user_id=user.id, + profile_in=UserProfileCreate(fitness_level=FitnessEnum.BEGINNER), + ) + updated = crud.upsert_user_profile( + session=db, + user_id=user.id, + profile_in=UserProfileCreate(fitness_level=FitnessEnum.ELITE), + ) + assert updated.fitness_level == FitnessEnum.ELITE + + +def test_update_user_profile_partial(db: Session) -> None: + user = create_random_user(db) + crud.upsert_user_profile( + session=db, + user_id=user.id, + profile_in=UserProfileCreate(home_city="Hanoi", fitness_level=FitnessEnum.BEGINNER), + ) + profile = crud.get_user_profile(session=db, user_id=user.id) + assert profile is not None + updated = crud.update_user_profile( + session=db, + db_profile=profile, + profile_in=UserProfileUpdate(home_city="Ho Chi Minh City"), + ) + assert updated.home_city == "Ho Chi Minh City" + assert updated.fitness_level == FitnessEnum.BEGINNER # unchanged + + +def test_delete_user_profile(db: Session) -> None: + user = create_random_user(db) + crud.upsert_user_profile( + session=db, + user_id=user.id, + profile_in=UserProfileCreate(is_onboarded=True), + ) + deleted = crud.delete_user_profile(session=db, user_id=user.id) + assert deleted is True + assert crud.get_user_profile(session=db, user_id=user.id) is None + + +def test_delete_user_profile_missing_returns_false(db: Session) -> None: + assert crud.delete_user_profile(session=db, user_id=uuid.uuid4()) is False + + +# --------------------------------------------------------------------------- +# UserRaceInteraction +# --------------------------------------------------------------------------- + + +def test_record_interaction_creates_row(db: Session) -> None: + user = create_random_user(db) + # Need a real race_id; use a random UUID — FK will fail in real DB, + # but this exercises the function signature and model creation path. + # Integration tests that hit the DB with migrations will cover FK behaviour. + with pytest.raises(Exception): + # Expected to raise because race_id FK doesn't exist in test DB + crud.record_interaction( + session=db, + user_id=user.id, + race_id=uuid.uuid4(), + action=InteractionTypeEnum.VIEWED, + ) + + +def test_get_user_interaction_returns_none_when_absent(db: Session) -> None: + result = crud.get_user_interaction( + session=db, + user_id=uuid.uuid4(), + race_id=uuid.uuid4(), + action=InteractionTypeEnum.SAVED, + ) + assert result is None + + +def test_get_user_saved_races_empty(db: Session) -> None: + user = create_random_user(db) + races = crud.get_user_saved_races(session=db, user_id=user.id) + assert races == [] diff --git a/backend/tests/services/__init__.py b/backend/tests/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/services/test_ai.py b/backend/tests/services/test_ai.py new file mode 100644 index 0000000000..74509f5d6c --- /dev/null +++ b/backend/tests/services/test_ai.py @@ -0,0 +1,221 @@ +"""Tests for AI service functions using pytest-mock to avoid real API calls.""" + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.services.ai import ( + answer_race_question, + embed_race, + embed_text, + enhance_race_description, + generate_race_recommendation_explanation, + suggest_race_tags, +) + + +def _mock_race( + name: str = "Test Race", + description: str = "A challenging trail race", + location: str = "Hanoi, Vietnam", + terrain_type: str | None = "trail", + difficulty_level: str | None = "hard", + elevation_gain_m: int | None = 1200, +) -> MagicMock: + race = MagicMock() + race.id = uuid.uuid4() + race.name = name + race.description = description + race.location = location + + if terrain_type: + terrain = MagicMock() + terrain.value = terrain_type + race.terrain_type = terrain + else: + race.terrain_type = None + + if difficulty_level: + difficulty = MagicMock() + difficulty.value = difficulty_level + race.difficulty_level = difficulty + else: + race.difficulty_level = None + + race.elevation_gain_m = elevation_gain_m + return race + + +def _mock_profile( + fitness_level: str = "advanced", + distance_preference: str = "ultra", + terrain_preference: str = "trail", +) -> MagicMock: + profile = MagicMock() + fl = MagicMock() + fl.value = fitness_level + profile.fitness_level = fl + dp = MagicMock() + dp.value = distance_preference + profile.distance_preference = dp + tp = MagicMock() + tp.value = terrain_preference + profile.terrain_preference = tp + return profile + + +@pytest.mark.asyncio +async def test_embed_text_returns_vector() -> None: + fake_embedding = [0.1] * 1536 + mock_response = MagicMock() + mock_response.data = [MagicMock(embedding=fake_embedding)] + + with patch("app.services.ai._openai") as mock_openai_fn: + mock_client = AsyncMock() + mock_openai_fn.return_value = mock_client + mock_client.embeddings.create = AsyncMock(return_value=mock_response) + + result = await embed_text("hello world") + + assert result == fake_embedding + mock_client.embeddings.create.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_embed_race_builds_text_and_embeds() -> None: + race = _mock_race() + fake_embedding = [0.2] * 1536 + + with patch("app.services.ai.embed_text", new_callable=AsyncMock) as mock_embed: + mock_embed.return_value = fake_embedding + result = await embed_race(race) + + assert result == fake_embedding + call_text: str = mock_embed.call_args[0][0] + assert "Test Race" in call_text + assert "trail" in call_text + + +@pytest.mark.asyncio +async def test_generate_recommendation_explanation() -> None: + race = _mock_race() + profile = _mock_profile() + fake_text = "This race is perfect for your trail running goals!" + + mock_block = MagicMock() + mock_block.text = fake_text + mock_response = MagicMock() + mock_response.content = [mock_block] + + with patch("app.services.ai._anthropic") as mock_anthropic_fn: + mock_client = AsyncMock() + mock_anthropic_fn.return_value = mock_client + mock_client.messages.create = AsyncMock(return_value=mock_response) + + result = await generate_race_recommendation_explanation(race, profile) + + assert result == fake_text + mock_client.messages.create.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_generate_recommendation_explanation_no_profile() -> None: + race = _mock_race() + fake_text = "A great race for any runner." + + mock_block = MagicMock() + mock_block.text = fake_text + mock_response = MagicMock() + mock_response.content = [mock_block] + + with patch("app.services.ai._anthropic") as mock_anthropic_fn: + mock_client = AsyncMock() + mock_anthropic_fn.return_value = mock_client + mock_client.messages.create = AsyncMock(return_value=mock_response) + + result = await generate_race_recommendation_explanation(race, None) + + assert result == fake_text + + +@pytest.mark.asyncio +async def test_enhance_race_description() -> None: + race = _mock_race() + enhanced = "Experience the breathtaking trails of northern Vietnam..." + + mock_block = MagicMock() + mock_block.text = enhanced + mock_response = MagicMock() + mock_response.content = [mock_block] + + with patch("app.services.ai._anthropic") as mock_anthropic_fn: + mock_client = AsyncMock() + mock_anthropic_fn.return_value = mock_client + mock_client.messages.create = AsyncMock(return_value=mock_response) + + result = await enhance_race_description(race) + + assert result == enhanced + + +@pytest.mark.asyncio +async def test_suggest_race_tags_returns_list() -> None: + race = _mock_race() + + mock_block = MagicMock() + mock_block.text = '["trail-running", "mountainous", "ultra-distance"]' + mock_response = MagicMock() + mock_response.content = [mock_block] + + with patch("app.services.ai._anthropic") as mock_anthropic_fn: + mock_client = AsyncMock() + mock_anthropic_fn.return_value = mock_client + mock_client.messages.create = AsyncMock(return_value=mock_response) + + result = await suggest_race_tags(race) + + assert result == ["trail-running", "mountainous", "ultra-distance"] + + +@pytest.mark.asyncio +async def test_suggest_race_tags_invalid_json_returns_empty() -> None: + race = _mock_race() + + mock_block = MagicMock() + mock_block.text = "not valid json" + mock_response = MagicMock() + mock_response.content = [mock_block] + + with patch("app.services.ai._anthropic") as mock_anthropic_fn: + mock_client = AsyncMock() + mock_anthropic_fn.return_value = mock_client + mock_client.messages.create = AsyncMock(return_value=mock_response) + + result = await suggest_race_tags(race) + + assert result == [] + + +@pytest.mark.asyncio +async def test_answer_race_question() -> None: + race = _mock_race() + question = "Is this race beginner-friendly?" + expected_answer = "This race is rated hard and is better suited for advanced runners." + + mock_block = MagicMock() + mock_block.text = expected_answer + mock_response = MagicMock() + mock_response.content = [mock_block] + + with patch("app.services.ai._anthropic") as mock_anthropic_fn: + mock_client = AsyncMock() + mock_anthropic_fn.return_value = mock_client + mock_client.messages.create = AsyncMock(return_value=mock_response) + + result = await answer_race_question(race=race, question=question) + + assert result == expected_answer + call_kwargs = mock_client.messages.create.call_args[1] + user_content = call_kwargs["messages"][0]["content"] + assert question in user_content diff --git a/bun.lock b/bun.lock index 34d6b22a9b..e1f8b48cd9 100644 --- a/bun.lock +++ b/bun.lock @@ -31,9 +31,11 @@ "axios": "1.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "form-data": "4.0.5", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", "react": "^19.1.1", "react-dom": "^19.2.3", "react-error-boundary": "^6.0.0", @@ -202,18 +204,30 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="], + + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="], + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="], "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], @@ -226,12 +240,26 @@ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + "@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="], + + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="], + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], + "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="], + + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="], + + "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="], + + "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], @@ -240,6 +268,8 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="], + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], @@ -250,10 +280,22 @@ "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], + + "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="], + + "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="], + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], @@ -498,6 +540,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], @@ -698,6 +742,8 @@ "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], @@ -792,18 +838,38 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@radix-ui/react-accordion/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-aspect-ratio/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-checkbox/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-checkbox/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-collapsible/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-collapsible/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-collection/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-context-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-context-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -818,18 +884,54 @@ "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-form/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-form/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + + "@radix-ui/react-form/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-hover-card/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-hover-card/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-menubar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-menubar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-one-time-password-field/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-one-time-password-field/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-password-toggle-field/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-password-toggle-field/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-popover/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-radio-group/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-radio-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -848,10 +950,34 @@ "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-slider/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-toast/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-toast/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-toggle/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-toggle-group/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-toggle-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-toolbar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-toolbar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-toolbar/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + "@radix-ui/react-tooltip/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -902,30 +1028,76 @@ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "radix-ui/@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], + + "radix-ui/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + + "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "radix-ui/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + + "radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "@radix-ui/react-accordion/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-aspect-ratio/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-checkbox/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-collapsible/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-context-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-form/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-hover-card/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-menubar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-one-time-password-field/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-password-toggle-field/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-radio-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-slider/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-switch/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-toast/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toggle-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toolbar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@tanstack/react-router/@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], diff --git a/compose.yml b/compose.yml index 2488fc007b..7d7c65057b 100644 --- a/compose.yml +++ b/compose.yml @@ -1,7 +1,7 @@ services: db: - image: postgres:18 + image: pgvector/pgvector:pg18 restart: always healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] @@ -19,6 +19,18 @@ services: - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_DB=${POSTGRES_DB?Variable not set} + redis: + image: redis:7-alpine + restart: always + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + retries: 5 + start_period: 10s + timeout: 5s + volumes: + - app-redis-data:/data + adminer: image: adminer restart: always @@ -136,6 +148,38 @@ services: # Enable redirection for HTTP and HTTPS - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.middlewares=https-redirect + runner-site: + image: '${DOCKER_IMAGE_RUNNER_SITE?Variable not set}:${TAG-latest}' + build: + context: ./runner-site + dockerfile: Dockerfile + restart: always + networks: + - traefik-public + - default + depends_on: + - backend + environment: + - NEXT_PUBLIC_API_URL=http://backend:8000/api/v1 + - NEXT_PUBLIC_SITE_URL=${RUNNER_SITE_HOST?Variable not set} + labels: + - traefik.enable=true + - traefik.docker.network=traefik-public + - traefik.constraint-label=traefik-public + + - traefik.http.services.${STACK_NAME?Variable not set}-runner-site.loadbalancer.server.port=3000 + + - traefik.http.routers.${STACK_NAME?Variable not set}-runner-site-http.rule=Host(`${DOMAIN?Variable not set}`) + - traefik.http.routers.${STACK_NAME?Variable not set}-runner-site-http.entrypoints=http + + - traefik.http.routers.${STACK_NAME?Variable not set}-runner-site-https.rule=Host(`${DOMAIN?Variable not set}`) + - traefik.http.routers.${STACK_NAME?Variable not set}-runner-site-https.entrypoints=https + - traefik.http.routers.${STACK_NAME?Variable not set}-runner-site-https.tls=true + - traefik.http.routers.${STACK_NAME?Variable not set}-runner-site-https.tls.certresolver=le + + # Enable redirection for HTTP and HTTPS + - traefik.http.routers.${STACK_NAME?Variable not set}-runner-site-http.middlewares=https-redirect + frontend: image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}' restart: always @@ -167,6 +211,7 @@ services: - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect volumes: app-db-data: + app-redis-data: networks: traefik-public: diff --git a/frontend/package.json b/frontend/package.json index f3b8d23e24..3fbf74e0b4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,23 +25,34 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.21", "@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-router": "^1.163.3", "@tanstack/react-router-devtools": "^1.163.3", "@tanstack/react-table": "^8.21.3", + "@tiptap/extension-link": "^3.23.4", + "@tiptap/extension-placeholder": "^3.23.4", + "@tiptap/react": "^3.23.4", + "@tiptap/starter-kit": "^3.23.4", "axios": "1.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "form-data": "4.0.5", + "i18next": "^26.2.0", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", "react": "^19.1.1", "react-dom": "^19.2.3", "react-error-boundary": "^6.0.0", "react-hook-form": "^7.68.0", + "react-i18next": "^17.0.8", "react-icons": "^5.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000000..5cdad58f17 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,4804 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@hookform/resolvers': + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.75.0(react@19.2.6)) + '@radix-ui/react-avatar': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-checkbox': + specifier: ^1.3.3 + version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-label': + specifier: ^2.1.8 + version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-radio-group': + specifier: ^1.3.8 + version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-scroll-area': + specifier: ^1.2.10 + version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-separator': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.3.0(vite@7.3.3(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0)) + '@tanstack/react-query': + specifier: ^5.90.21 + version: 5.100.10(react@19.2.6) + '@tanstack/react-query-devtools': + specifier: ^5.91.1 + version: 5.100.10(@tanstack/react-query@5.100.10(react@19.2.6))(react@19.2.6) + '@tanstack/react-router': + specifier: ^1.163.3 + version: 1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-router-devtools': + specifier: ^1.163.3 + version: 1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + axios: + specifier: 1.13.5 + version: 1.13.5 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + form-data: + specifier: 4.0.5 + version: 4.0.5 + lucide-react: + specifier: ^0.563.0 + version: 0.563.0(react@19.2.6) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + radix-ui: + specifier: ^1.4.3 + version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: + specifier: ^19.1.1 + version: 19.2.6 + react-dom: + specifier: ^19.2.3 + version: 19.2.6(react@19.2.6) + react-error-boundary: + specifier: ^6.0.0 + version: 6.1.1(react@19.2.6) + react-hook-form: + specifier: ^7.68.0 + version: 7.75.0(react@19.2.6) + react-icons: + specifier: ^5.5.0 + version: 5.6.0(react@19.2.6) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + tailwind-merge: + specifier: ^3.4.0 + version: 3.6.0 + tailwindcss: + specifier: ^4.2.1 + version: 4.3.0 + zod: + specifier: ^4.3.6 + version: 4.4.3 + devDependencies: + '@biomejs/biome': + specifier: ^2.3.14 + version: 2.4.15 + '@hey-api/openapi-ts': + specifier: 0.73.0 + version: 0.73.0(typescript@5.9.3) + '@playwright/test': + specifier: 1.58.2 + version: 1.58.2 + '@tanstack/router-devtools': + specifier: ^1.166.7 + version: 1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/router-plugin': + specifier: ^1.140.0 + version: 1.167.35(@tanstack/react-router@1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.3(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0)) + '@types/node': + specifier: ^25.5.0 + version: 25.8.0 + '@types/react': + specifier: ^19.2.7 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react-swc': + specifier: ^4.2.3 + version: 4.3.1(vite@7.3.3(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0)) + dotenv: + specifier: ^17.3.1 + version: 17.4.2 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.0 + version: 7.3.3(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0) + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.4.15': + resolution: {integrity: sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.15': + resolution: {integrity: sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.15': + resolution: {integrity: sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.15': + resolution: {integrity: sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.4.15': + resolution: {integrity: sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.4.15': + resolution: {integrity: sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.4.15': + resolution: {integrity: sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.4.15': + resolution: {integrity: sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.15': + resolution: {integrity: sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@hey-api/json-schema-ref-parser@1.0.6': + resolution: {integrity: sha512-yktiFZoWPtEW8QKS65eqKwA5MTKp88CyiL8q72WynrBs/73SAaxlSWlA2zW/DZlywZ5hX1OYzrCC0wFdvO9c2w==} + engines: {node: '>= 16'} + + '@hey-api/openapi-ts@0.73.0': + resolution: {integrity: sha512-sUscR3OIGW0k9U//28Cu6BTp3XaogWMDORj9H+5Du9E5AvTT7LZbCEDvkLhebFOPkp2cZAQfd66HiZsiwssBcQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=22.10.0} + hasBin: true + peerDependencies: + typescript: ^5.5.3 + + '@hookform/resolvers@5.2.2': + resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} + peerDependencies: + react-hook-form: ^7.55.0 + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-accessible-icon@1.1.7': + resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.7': + resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.11': + resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.1.8': + resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.15': + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-label@2.1.8': + resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.16': + resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.1.8': + resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.3': + resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.8': + resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.15': + resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.1.11': + resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@swc/core-darwin-arm64@1.15.33': + resolution: {integrity: sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.33': + resolution: {integrity: sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.33': + resolution: {integrity: sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.33': + resolution: {integrity: sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.33': + resolution: {integrity: sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.15.33': + resolution: {integrity: sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.15.33': + resolution: {integrity: sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.15.33': + resolution: {integrity: sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.33': + resolution: {integrity: sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.33': + resolution: {integrity: sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.33': + resolution: {integrity: sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.33': + resolution: {integrity: sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.33': + resolution: {integrity: sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.26': + resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/history@1.161.6': + resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} + engines: {node: '>=20.19'} + + '@tanstack/query-core@5.100.10': + resolution: {integrity: sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==} + + '@tanstack/query-devtools@5.100.10': + resolution: {integrity: sha512-3DmJf25hDPus5IpVvp6ujXv6bKV2zPzI9vpbAmpJigsL/H6DPvPjmf7/Q9yVKEke//8fgeQ45abjgnLuyYxAiw==} + + '@tanstack/react-query-devtools@5.100.10': + resolution: {integrity: sha512-zes0+o9ef5rAZXJ9f/SeaLs2nufJaeVkZkl/Or9NGrWVF41kL9Od9ED9nCwtQlgiF2VGtrzhEw5AU/igAO+aAg==} + peerDependencies: + '@tanstack/react-query': ^5.100.10 + react: ^18 || ^19 + + '@tanstack/react-query@5.100.10': + resolution: {integrity: sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-router-devtools@1.166.13': + resolution: {integrity: sha512-6yKRFFJrEEOiGp5RAAuGCYsl81M4XAhJmLcu9PKj+HZle4A3dsP60lwHoqQYWHMK9nKKFkdXR+D8qxzxqtQbEA==} + engines: {node: '>=20.19'} + peerDependencies: + '@tanstack/react-router': ^1.168.15 + '@tanstack/router-core': ^1.168.11 + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + peerDependenciesMeta: + '@tanstack/router-core': + optional: true + + '@tanstack/react-router@1.169.2': + resolution: {integrity: sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/router-core@1.169.2': + resolution: {integrity: sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw==} + engines: {node: '>=20.19'} + + '@tanstack/router-devtools-core@1.167.3': + resolution: {integrity: sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg==} + engines: {node: '>=20.19'} + peerDependencies: + '@tanstack/router-core': ^1.168.11 + csstype: ^3.0.10 + peerDependenciesMeta: + csstype: + optional: true + + '@tanstack/router-devtools@1.166.13': + resolution: {integrity: sha512-Qs8gkyI7m+eAxG3VcIOHuTSsUfA5ZxZcOa99ZyIIIJFxW6hy1k+m2s1J0ZYN1SNlip8P2ofd/MHiqmR1IWipMg==} + engines: {node: '>=20.19'} + peerDependencies: + '@tanstack/react-router': ^1.168.15 + csstype: ^3.0.10 + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + peerDependenciesMeta: + csstype: + optional: true + + '@tanstack/router-generator@1.166.42': + resolution: {integrity: sha512-2qBWC0t78r6b3vI+AbnvCZcFAvbYBDlLuWZrTjQbcjUmwG3qyeQp983tJyDuj9wb5//adG1tgAGXZkJ3aDwdBg==} + engines: {node: '>=20.19'} + + '@tanstack/router-plugin@1.167.35': + resolution: {integrity: sha512-UAScU5VAzLYVY4FML/Cbc5S5TucT4I8Ata05yozGOe4ZfepTKRffA5xWLtD2N+ov5svdv0KTX/kqlZnYPe28mA==} + engines: {node: '>=20.19'} + peerDependencies: + '@rsbuild/core': '>=1.0.2 || ^2.0.0' + '@tanstack/react-router': ^1.169.2 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0' + vite-plugin-solid: ^2.11.10 || ^3.0.0-0 + webpack: '>=5.92.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-utils@1.161.8': + resolution: {integrity: sha512-xyiLWEKjfBAVhauDSSjXxyf7s8elU6SM+V050sbkofvGmIIvkwPFtDsX7Gvwh14kBd6iCwAT+RiPvXTxAptY0Q==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@tanstack/virtual-file-routes@1.161.7': + resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==} + engines: {node: '>=20.19'} + hasBin: true + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@25.8.0': + resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@vitejs/plugin-react-swc@4.3.1': + resolution: {integrity: sha512-PaeokKjAGraNN+s5SIApgsktnJprIyt3zgEIu7awnEdfn29QiB2crTcCzyi2XGpX9rUnTc0cKU07Wm0N0g7H2w==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4 || ^5 || ^6 || ^7 || ^8 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansis@4.3.0: + resolution: {integrity: sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg==} + engines: {node: '>=14'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.13.5: + resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} + + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + + baseline-browser-mapping@2.10.29: + resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + c12@2.0.1: + resolution: {integrity: sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@13.0.0: + resolution: {integrity: sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==} + engines: {node: '>=18'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.356: + resolution: {integrity: sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==} + + enhanced-resolve@5.21.3: + resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==} + engines: {node: '>=10.13.0'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + giget@1.2.5: + resolution: {integrity: sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==} + hasBin: true + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + goober@2.1.19: + resolution: {integrity: sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==} + peerDependencies: + csstype: ^3.0.10 + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isbot@5.1.40: + resolution: {integrity: sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==} + engines: {node: '>=18'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.563.0: + resolution: {integrity: sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-releases@2.0.44: + resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nypm@0.5.4: + resolution: {integrity: sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + + ohash@1.1.6: + resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==} + + open@10.1.2: + resolution: {integrity: sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==} + engines: {node: '>=18'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + radix-ui@1.4.3: + resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + + react-error-boundary@6.1.1: + resolution: {integrity: sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + + react-hook-form@7.75.0: + resolution: {integrity: sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-icons@5.6.0: + resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==} + peerDependencies: + react: '*' + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + engines: {node: '>=10'} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@biomejs/biome@2.4.15': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.15 + '@biomejs/cli-darwin-x64': 2.4.15 + '@biomejs/cli-linux-arm64': 2.4.15 + '@biomejs/cli-linux-arm64-musl': 2.4.15 + '@biomejs/cli-linux-x64': 2.4.15 + '@biomejs/cli-linux-x64-musl': 2.4.15 + '@biomejs/cli-win32-arm64': 2.4.15 + '@biomejs/cli-win32-x64': 2.4.15 + + '@biomejs/cli-darwin-arm64@2.4.15': + optional: true + + '@biomejs/cli-darwin-x64@2.4.15': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.15': + optional: true + + '@biomejs/cli-linux-arm64@2.4.15': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.15': + optional: true + + '@biomejs/cli-linux-x64@2.4.15': + optional: true + + '@biomejs/cli-win32-arm64@2.4.15': + optional: true + + '@biomejs/cli-win32-x64@2.4.15': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@floating-ui/utils@0.2.11': {} + + '@hey-api/json-schema-ref-parser@1.0.6': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.1.1 + lodash: 4.18.1 + + '@hey-api/openapi-ts@0.73.0(typescript@5.9.3)': + dependencies: + '@hey-api/json-schema-ref-parser': 1.0.6 + ansi-colors: 4.1.3 + c12: 2.0.1 + color-support: 1.1.3 + commander: 13.0.0 + handlebars: 4.7.8 + open: 10.1.2 + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + '@hookform/resolvers@5.2.2(react-hook-form@7.75.0(react@19.2.6))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.75.0(react@19.2.6) + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jsdevtools/ono@7.1.3': {} + + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.3(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/rect': 1.1.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/rect@1.1.1': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@standard-schema/utils@0.3.0': {} + + '@swc/core-darwin-arm64@1.15.33': + optional: true + + '@swc/core-darwin-x64@1.15.33': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.33': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.33': + optional: true + + '@swc/core-linux-arm64-musl@1.15.33': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.33': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.33': + optional: true + + '@swc/core-linux-x64-gnu@1.15.33': + optional: true + + '@swc/core-linux-x64-musl@1.15.33': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.33': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.33': + optional: true + + '@swc/core-win32-x64-msvc@1.15.33': + optional: true + + '@swc/core@1.15.33': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.26 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.33 + '@swc/core-darwin-x64': 1.15.33 + '@swc/core-linux-arm-gnueabihf': 1.15.33 + '@swc/core-linux-arm64-gnu': 1.15.33 + '@swc/core-linux-arm64-musl': 1.15.33 + '@swc/core-linux-ppc64-gnu': 1.15.33 + '@swc/core-linux-s390x-gnu': 1.15.33 + '@swc/core-linux-x64-gnu': 1.15.33 + '@swc/core-linux-x64-musl': 1.15.33 + '@swc/core-win32-arm64-msvc': 1.15.33 + '@swc/core-win32-ia32-msvc': 1.15.33 + '@swc/core-win32-x64-msvc': 1.15.33 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.26': + dependencies: + '@swc/counter': 0.1.3 + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/vite@4.3.0(vite@7.3.3(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 7.3.3(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0) + + '@tanstack/history@1.161.6': {} + + '@tanstack/query-core@5.100.10': {} + + '@tanstack/query-devtools@5.100.10': {} + + '@tanstack/react-query-devtools@5.100.10(@tanstack/react-query@5.100.10(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/query-devtools': 5.100.10 + '@tanstack/react-query': 5.100.10(react@19.2.6) + react: 19.2.6 + + '@tanstack/react-query@5.100.10(react@19.2.6)': + dependencies: + '@tanstack/query-core': 5.100.10 + react: 19.2.6 + + '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/react-router': 1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/router-devtools-core': 1.167.3(@tanstack/router-core@1.169.2)(csstype@3.2.3) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@tanstack/router-core': 1.169.2 + transitivePeerDependencies: + - csstype + + '@tanstack/react-router@1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/history': 1.161.6 + '@tanstack/react-store': 0.9.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/router-core': 1.169.2 + isbot: 5.1.40 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@tanstack/react-store@0.9.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.6) + + '@tanstack/react-table@8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@tanstack/router-core@1.169.2': + dependencies: + '@tanstack/history': 1.161.6 + cookie-es: 3.1.1 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + + '@tanstack/router-devtools-core@1.167.3(@tanstack/router-core@1.169.2)(csstype@3.2.3)': + dependencies: + '@tanstack/router-core': 1.169.2 + clsx: 2.1.1 + goober: 2.1.19(csstype@3.2.3) + optionalDependencies: + csstype: 3.2.3 + + '@tanstack/router-devtools@1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/react-router': 1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-router-devtools': 1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + clsx: 2.1.1 + goober: 2.1.19(csstype@3.2.3) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + csstype: 3.2.3 + transitivePeerDependencies: + - '@tanstack/router-core' + + '@tanstack/router-generator@1.166.42': + dependencies: + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.169.2 + '@tanstack/router-utils': 1.161.8 + '@tanstack/virtual-file-routes': 1.161.7 + jiti: 2.7.0 + magic-string: 0.30.21 + prettier: 3.8.3 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.167.35(@tanstack/react-router@1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@7.3.3(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.169.2 + '@tanstack/router-generator': 1.166.42 + '@tanstack/router-utils': 1.161.8 + '@tanstack/virtual-file-routes': 1.161.7 + chokidar: 3.6.0 + unplugin: 3.0.0 + zod: 3.25.76 + optionalDependencies: + '@tanstack/react-router': 1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + vite: 7.3.3(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0) + transitivePeerDependencies: + - supports-color + + '@tanstack/router-utils@1.161.8': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + ansis: 4.3.0 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.4 + pathe: 2.0.3 + tinyglobby: 0.2.16 + transitivePeerDependencies: + - supports-color + + '@tanstack/store@0.9.3': {} + + '@tanstack/table-core@8.21.3': {} + + '@tanstack/virtual-file-routes@1.161.7': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@25.8.0': + dependencies: + undici-types: 7.24.6 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react-swc@4.3.1(vite@7.3.3(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + '@swc/core': 1.15.33 + vite: 7.3.3(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0) + transitivePeerDependencies: + - '@swc/helpers' + + acorn@8.16.0: {} + + ansi-colors@4.1.3: {} + + ansis@4.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + asynckit@0.4.0: {} + + axios@1.13.5: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.10.29: {} + + binary-extensions@2.3.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.29 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.356 + node-releases: 2.0.44 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + c12@2.0.1: + dependencies: + chokidar: 4.0.3 + confbox: 0.1.8 + defu: 6.1.7 + dotenv: 16.6.1 + giget: 1.2.5 + jiti: 2.7.0 + mlly: 1.8.2 + ohash: 1.1.6 + pathe: 1.1.2 + perfect-debounce: 1.0.0 + pkg-types: 1.3.1 + rc9: 2.1.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + caniuse-lite@1.0.30001792: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chownr@2.0.0: {} + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + color-support@1.1.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@13.0.0: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + convert-source-map@2.0.0: {} + + cookie-es@3.1.1: {} + + csstype@3.2.3: {} + + date-fns@4.1.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + defu@6.1.7: {} + + delayed-stream@1.0.0: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + diff@8.0.4: {} + + dotenv@16.6.1: {} + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.356: {} + + enhanced-resolve@5.21.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + follow-redirects@1.16.0: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + 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.3 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + giget@1.2.5: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.7 + node-fetch-native: 1.6.7 + nypm: 0.5.4 + pathe: 2.0.3 + tar: 6.2.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + goober@2.1.19(csstype@3.2.3): + dependencies: + csstype: 3.2.3 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-number@7.0.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isbot@5.1.40: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lodash@4.18.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.563.0(react@19.2.6): + dependencies: + react: 19.2.6 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimist@1.2.8: {} + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + neo-async@2.6.2: {} + + next-themes@0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + node-fetch-native@1.6.7: {} + + node-releases@2.0.44: {} + + normalize-path@3.0.0: {} + + nypm@0.5.4: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + pathe: 2.0.3 + pkg-types: 1.3.1 + tinyexec: 0.3.2 + ufo: 1.6.4 + + ohash@1.1.6: {} + + open@10.1.2: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + is-wsl: 3.1.1 + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.8.3: {} + + proxy-from-env@1.1.0: {} + + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + rc9@2.1.2: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react-error-boundary@6.1.1(react@19.2.6): + dependencies: + react: 19.2.6 + + react-hook-form@7.75.0(react@19.2.6): + dependencies: + react: 19.2.6 + + react-icons@5.6.0(react@19.2.6): + dependencies: + react: 19.2.6 + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.6) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.6) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.6) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.6) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + get-nonce: 1.0.1 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react@19.2.6: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + readdirp@4.1.2: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + run-applescript@7.1.0: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + seroval-plugins@1.5.4(seroval@1.5.4): + dependencies: + seroval: 1.5.4 + + seroval@1.5.4: {} + + sonner@2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tslib@2.8.1: {} + + tw-animate-css@1.4.0: {} + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + uglify-js@3.19.3: + optional: true + + undici-types@7.24.6: {} + + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.6): + dependencies: + react: 19.2.6 + + vite@7.3.3(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.32.0): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.8.0 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + + webpack-virtual-modules@0.6.2: {} + + wordwrap@1.0.0: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + zod@3.25.76: {} + + zod@4.4.3: {} diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000000..25711ba222 --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + '@swc/core': set this to true or false + esbuild: set this to true or false diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000000..53ffe9a982 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,23 @@ +# robots.txt for VNRunner +# Allow all search engines to index the site + +User-agent: * +Allow: / + +# Sitemap location +Sitemap: https://vnrunner.com/sitemap.xml + +# Crawl-delay for politeness (optional) +# Crawl-delay: 1 + +# Disallow admin and auth pages +Disallow: /admin/ +Disallow: /login +Disallow: /signup +Disallow: /recover-password +Disallow: /reset-password + +# Allow public pages +Allow: / +Allow: /races +Allow: /about diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index fb66c1f837..b0c7575c54 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -1,5 +1,205 @@ // This file is auto-generated by @hey-api/openapi-ts +export const AIRaceSuggestionSchema = { + properties: { + description: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Description' + }, + location: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Location' + }, + terrain_type: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Terrain Type' + }, + difficulty_level: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Difficulty Level' + }, + elevation_gain_m: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Elevation Gain M' + } + }, + type: 'object', + title: 'AIRaceSuggestion' +} as const; + +export const AdministrativeRegionPublicSchema = { + properties: { + id: { + type: 'integer', + title: 'Id' + }, + name: { + type: 'string', + title: 'Name' + }, + name_en: { + type: 'string', + title: 'Name En' + }, + code_name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Code Name' + }, + code_name_en: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Code Name En' + } + }, + type: 'object', + required: ['id', 'name', 'name_en'], + title: 'AdministrativeRegionPublic' +} as const; + +export const AdministrativeUnitPublicSchema = { + properties: { + id: { + type: 'integer', + title: 'Id' + }, + full_name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Full Name' + }, + full_name_en: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Full Name En' + }, + short_name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Short Name' + }, + short_name_en: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Short Name En' + }, + code_name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Code Name' + }, + code_name_en: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Code Name En' + } + }, + type: 'object', + required: ['id'], + title: 'AdministrativeUnitPublic' +} as const; + +export const AskRequestSchema = { + properties: { + question: { + type: 'string', + title: 'Question' + } + }, + type: 'object', + required: ['question'], + title: 'AskRequest' +} as const; + +export const AttributeTypeEnumSchema = { + type: 'string', + enum: ['string', 'text', 'url', 'date', 'datetime', 'number', 'boolean', 'email', 'phone'], + title: 'AttributeTypeEnum' +} as const; + export const Body_login_login_access_tokenSchema = { properties: { grant_type: { @@ -57,6 +257,127 @@ export const Body_login_login_access_tokenSchema = { title: 'Body_login-login_access_token' } as const; +export const Body_media_upload_media_assetSchema = { + properties: { + file: { + type: 'string', + contentMediaType: 'application/octet-stream', + title: 'File' + }, + content_type: { + type: 'string', + title: 'Content Type' + }, + content_id: { + type: 'string', + format: 'uuid', + title: 'Content Id' + }, + kind: { + type: 'string', + title: 'Kind', + default: 'gallery' + }, + alt_text: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Alt Text' + }, + display_order: { + type: 'integer', + title: 'Display Order', + default: 0 + }, + is_primary: { + type: 'boolean', + title: 'Is Primary', + default: false + }, + is_public: { + type: 'boolean', + title: 'Is Public', + default: true + } + }, + type: 'object', + required: ['file', 'content_type', 'content_id'], + title: 'Body_media-upload_media_asset' +} as const; + +export const CategoryTranslationUpdateSchema = { + properties: { + language: { + type: 'string', + maxLength: 10, + minLength: 2, + title: 'Language' + }, + name: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Description' + } + }, + type: 'object', + required: ['language'], + title: 'CategoryTranslationUpdate', + description: 'Update translations for a race category' +} as const; + +export const DescriptionSuggestionSchema = { + properties: { + description: { + type: 'string', + title: 'Description' + } + }, + type: 'object', + required: ['description'], + title: 'DescriptionSuggestion' +} as const; + +export const DifficultyEnumSchema = { + type: 'string', + enum: ['easy', 'moderate', 'hard', 'extreme'], + title: 'DifficultyEnum' +} as const; + +export const DistancePrefEnumSchema = { + type: 'string', + enum: ['short', 'mid', 'long', 'ultra'], + title: 'DistancePrefEnum' +} as const; + +export const FitnessEnumSchema = { + type: 'string', + enum: ['beginner', 'intermediate', 'advanced', 'elite'], + title: 'FitnessEnum' +} as const; + export const HTTPValidationErrorSchema = { properties: { detail: { @@ -71,6 +392,12 @@ export const HTTPValidationErrorSchema = { title: 'HTTPValidationError' } as const; +export const InteractionTypeEnumSchema = { + type: 'string', + enum: ['viewed', 'saved', 'unsaved', 'registered', 'shared'], + title: 'InteractionTypeEnum' +} as const; + export const ItemCreateSchema = { properties: { title: { @@ -196,36 +523,223 @@ export const ItemsPublicSchema = { title: 'ItemsPublic' } as const; -export const MessageSchema = { +export const MediaAssetPublicSchema = { properties: { - message: { + content_type: { type: 'string', - title: 'Message' - } - }, - type: 'object', - required: ['message'], - title: 'Message' -} as const; - -export const NewPasswordSchema = { - properties: { - token: { + maxLength: 100, + title: 'Content Type' + }, + content_id: { type: 'string', - title: 'Token' + format: 'uuid', + title: 'Content Id' }, - new_password: { + kind: { type: 'string', - maxLength: 128, - minLength: 8, - title: 'New Password' - } - }, - type: 'object', + maxLength: 50, + title: 'Kind', + default: 'gallery' + }, + alt_text: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Alt Text' + }, + display_order: { + type: 'integer', + title: 'Display Order', + default: 0 + }, + is_primary: { + type: 'boolean', + title: 'Is Primary', + default: false + }, + is_public: { + type: 'boolean', + title: 'Is Public', + default: true + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + original_filename: { + type: 'string', + title: 'Original Filename' + }, + file_name: { + type: 'string', + title: 'File Name' + }, + file_url: { + type: 'string', + title: 'File Url' + }, + mime_type: { + type: 'string', + title: 'Mime Type' + }, + size_bytes: { + type: 'integer', + title: 'Size Bytes' + }, + uploaded_by_id: { + anyOf: [ + { + type: 'string', + format: 'uuid' + }, + { + type: 'null' + } + ], + title: 'Uploaded By Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: ['content_type', 'content_id', 'id', 'original_filename', 'file_name', 'file_url', 'mime_type', 'size_bytes', 'created_at', 'updated_at'], + title: 'MediaAssetPublic' +} as const; + +export const MediaAssetUpdateSchema = { + properties: { + kind: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Kind' + }, + alt_text: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Alt Text' + }, + display_order: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Display Order' + }, + is_primary: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Is Primary' + }, + is_public: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Is Public' + } + }, + type: 'object', + title: 'MediaAssetUpdate' +} as const; + +export const MediaAssetsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/MediaAssetPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'MediaAssetsPublic' +} as const; + +export const MessageSchema = { + properties: { + message: { + type: 'string', + title: 'Message' + } + }, + type: 'object', + required: ['message'], + title: 'Message' +} as const; + +export const NewPasswordSchema = { + properties: { + token: { + type: 'string', + title: 'Token' + }, + new_password: { + type: 'string', + maxLength: 128, + minLength: 8, + title: 'New Password' + } + }, + type: 'object', required: ['token', 'new_password'], title: 'NewPassword' } as const; +export const PaymentStatusEnumSchema = { + type: 'string', + enum: ['unpaid', 'paid', 'refunded', 'partial'], + title: 'PaymentStatusEnum' +} as const; + export const PrivateUserCreateSchema = { properties: { email: { @@ -251,83 +765,5017 @@ export const PrivateUserCreateSchema = { title: 'PrivateUserCreate' } as const; -export const TokenSchema = { +export const ProvincePublicSchema = { properties: { - access_token: { + code: { type: 'string', - title: 'Access Token' + title: 'Code' }, - token_type: { + name: { type: 'string', - title: 'Token Type', - default: 'bearer' + title: 'Name' + }, + name_en: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Name En' + }, + full_name: { + type: 'string', + title: 'Full Name' + }, + full_name_en: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Full Name En' + }, + code_name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Code Name' + }, + administrative_unit_id: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Administrative Unit Id' } }, type: 'object', - required: ['access_token'], - title: 'Token' + required: ['code', 'name', 'full_name'], + title: 'ProvincePublic' } as const; -export const UpdatePasswordSchema = { +export const ProvincePublicWithDetailsSchema = { properties: { - current_password: { + code: { type: 'string', - maxLength: 128, - minLength: 8, - title: 'Current Password' + title: 'Code' + }, + name: { + type: 'string', + title: 'Name' + }, + name_en: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Name En' + }, + full_name: { + type: 'string', + title: 'Full Name' + }, + full_name_en: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Full Name En' + }, + code_name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Code Name' + }, + administrative_unit_id: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Administrative Unit Id' + }, + administrative_unit: { + anyOf: [ + { + '$ref': '#/components/schemas/AdministrativeUnitPublic' + }, + { + type: 'null' + } + ] + } + }, + type: 'object', + required: ['code', 'name', 'full_name'], + title: 'ProvincePublicWithDetails' +} as const; + +export const ProvincesPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/ProvincePublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'ProvincesPublic' +} as const; + +export const RaceAnswerSchema = { + properties: { + answer: { + type: 'string', + title: 'Answer' + } + }, + type: 'object', + required: ['answer'], + title: 'RaceAnswer' +} as const; + +export const RaceAttributeCreateSchema = { + properties: { + key: { + type: 'string', + maxLength: 100, + title: 'Key' + }, + value_text: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Value Text' + }, + attribute_type: { + '$ref': '#/components/schemas/AttributeTypeEnum', + default: 'string' + }, + label: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Label' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + is_required: { + type: 'boolean', + title: 'Is Required', + default: false + }, + is_public: { + type: 'boolean', + title: 'Is Public', + default: true + }, + display_order: { + type: 'integer', + title: 'Display Order', + default: 0 + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + } + }, + type: 'object', + required: ['key', 'race_id'], + title: 'RaceAttributeCreate' +} as const; + +export const RaceAttributePublicSchema = { + properties: { + key: { + type: 'string', + maxLength: 100, + title: 'Key' + }, + value_text: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Value Text' + }, + attribute_type: { + '$ref': '#/components/schemas/AttributeTypeEnum', + default: 'string' + }, + label: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Label' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + is_required: { + type: 'boolean', + title: 'Is Required', + default: false + }, + is_public: { + type: 'boolean', + title: 'Is Public', + default: true + }, + display_order: { + type: 'integer', + title: 'Display Order', + default: 0 + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: ['key', 'id', 'race_id', 'created_at', 'updated_at'], + title: 'RaceAttributePublic' +} as const; + +export const RaceAttributeUpdateSchema = { + properties: { + value_text: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Value Text' + }, + attribute_type: { + anyOf: [ + { + '$ref': '#/components/schemas/AttributeTypeEnum' + }, + { + type: 'null' + } + ] + }, + label: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Label' + }, + description: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Description' + }, + is_required: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Is Required' + }, + is_public: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Is Public' + }, + display_order: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Display Order' + } + }, + type: 'object', + title: 'RaceAttributeUpdate' +} as const; + +export const RaceAttributesPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RaceAttributePublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RaceAttributesPublic' +} as const; + +export const RaceCategoriesPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RaceCategoryPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RaceCategoriesPublic' +} as const; + +export const RaceCategoryCreateSchema = { + properties: { + name: { + type: 'string', + maxLength: 100, + title: 'Name' + }, + distance_km: { + type: 'number', + exclusiveMinimum: 0, + title: 'Distance Km' + }, + distance_unit: { + type: 'string', + maxLength: 10, + title: 'Distance Unit', + default: 'km' + }, + start_time: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Start Time' + }, + end_time: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'End Time' + }, + cutoff_time_minutes: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Cutoff Time Minutes' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Price' + }, + early_bird_price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Early Bird Price' + }, + early_bird_deadline: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Early Bird Deadline' + }, + max_participants: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Max Participants' + }, + min_age: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Min Age' + }, + max_age: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Max Age' + }, + gender_restriction: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Gender Restriction' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + display_order: { + type: 'integer', + title: 'Display Order', + default: 0 + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + translations: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Translations' + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + } + }, + type: 'object', + required: ['name', 'distance_km', 'race_id'], + title: 'RaceCategoryCreate' +} as const; + +export const RaceCategoryPublicSchema = { + properties: { + name: { + type: 'string', + maxLength: 100, + title: 'Name' + }, + distance_km: { + type: 'number', + exclusiveMinimum: 0, + title: 'Distance Km' + }, + distance_unit: { + type: 'string', + maxLength: 10, + title: 'Distance Unit', + default: 'km' + }, + start_time: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Start Time' + }, + end_time: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'End Time' + }, + cutoff_time_minutes: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Cutoff Time Minutes' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Price' + }, + early_bird_price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Early Bird Price' + }, + early_bird_deadline: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Early Bird Deadline' + }, + max_participants: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Max Participants' + }, + min_age: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Min Age' + }, + max_age: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Max Age' + }, + gender_restriction: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Gender Restriction' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + display_order: { + type: 'integer', + title: 'Display Order', + default: 0 + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + translations: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Translations' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: ['name', 'distance_km', 'id', 'race_id', 'created_at', 'updated_at'], + title: 'RaceCategoryPublic' +} as const; + +export const RaceCategoryPublicWithDetailsSchema = { + properties: { + name: { + type: 'string', + maxLength: 100, + title: 'Name' + }, + distance_km: { + type: 'number', + exclusiveMinimum: 0, + title: 'Distance Km' + }, + distance_unit: { + type: 'string', + maxLength: 10, + title: 'Distance Unit', + default: 'km' + }, + start_time: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Start Time' + }, + end_time: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'End Time' + }, + cutoff_time_minutes: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Cutoff Time Minutes' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Price' + }, + early_bird_price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Early Bird Price' + }, + early_bird_deadline: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Early Bird Deadline' + }, + max_participants: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Max Participants' + }, + min_age: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Min Age' + }, + max_age: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Max Age' + }, + gender_restriction: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Gender Restriction' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + display_order: { + type: 'integer', + title: 'Display Order', + default: 0 + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + translations: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Translations' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + registration_count: { + type: 'integer', + title: 'Registration Count', + default: 0 + }, + available_spots: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Available Spots' + }, + is_registration_open: { + type: 'boolean', + title: 'Is Registration Open', + default: false + }, + current_price: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Current Price' + } + }, + type: 'object', + required: ['name', 'distance_km', 'id', 'race_id', 'created_at', 'updated_at'], + title: 'RaceCategoryPublicWithDetails' +} as const; + +export const RaceCategoryUpdateSchema = { + properties: { + name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Name' + }, + distance_km: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Distance Km' + }, + distance_unit: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Distance Unit' + }, + start_time: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Start Time' + }, + end_time: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'End Time' + }, + cutoff_time_minutes: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Cutoff Time Minutes' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + price: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Price' + }, + early_bird_price: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Early Bird Price' + }, + early_bird_deadline: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Early Bird Deadline' + }, + max_participants: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Max Participants' + }, + min_age: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Min Age' + }, + max_age: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Max Age' + }, + gender_restriction: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Gender Restriction' + }, + description: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Description' + }, + display_order: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Display Order' + }, + is_active: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Is Active' + } + }, + type: 'object', + title: 'RaceCategoryUpdate' +} as const; + +export const RaceCreateSchema = { + properties: { + name: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + event_start_date: { + type: 'string', + format: 'date-time', + title: 'Event Start Date' + }, + event_end_date: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Event End Date' + }, + location: { + type: 'string', + maxLength: 255, + title: 'Location' + }, + city: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'City' + }, + state: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'State' + }, + country: { + type: 'string', + maxLength: 100, + title: 'Country', + default: 'Vietnam' + }, + province_code: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Province Code' + }, + ward_code: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Ward Code' + }, + country_code: { + anyOf: [ + { + type: 'string', + maxLength: 10 + }, + { + type: 'null' + } + ], + title: 'Country Code' + }, + province_name: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Province Name' + }, + ward_name: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Ward Name' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + status: { + '$ref': '#/components/schemas/RaceStatusEnum', + default: 'draft' + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + base_price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Base Price' + }, + currency: { + type: 'string', + maxLength: 3, + title: 'Currency', + default: 'USD' + }, + race_metadata: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Race Metadata' + }, + latitude: { + anyOf: [ + { + type: 'number', + maximum: 90, + minimum: -90 + }, + { + type: 'null' + } + ], + title: 'Latitude' + }, + longitude: { + anyOf: [ + { + type: 'number', + maximum: 180, + minimum: -180 + }, + { + type: 'null' + } + ], + title: 'Longitude' + }, + terrain_type: { + anyOf: [ + { + '$ref': '#/components/schemas/TerrainEnum' + }, + { + type: 'null' + } + ] + }, + difficulty_level: { + anyOf: [ + { + '$ref': '#/components/schemas/DifficultyEnum' + }, + { + type: 'null' + } + ] + }, + elevation_gain_m: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Elevation Gain M' + }, + is_certified: { + type: 'boolean', + title: 'Is Certified', + default: false + }, + gpx_file_url: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Gpx File Url' + }, + website_url: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Website Url' + }, + default_language: { + type: 'string', + maxLength: 10, + title: 'Default Language', + default: 'vi' + }, + translations: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Translations' + } + }, + type: 'object', + required: ['name', 'event_start_date', 'location'], + title: 'RaceCreate' +} as const; + +export const RaceNameInputSchema = { + properties: { + name: { + type: 'string', + title: 'Name' + } + }, + type: 'object', + required: ['name'], + title: 'RaceNameInput' +} as const; + +export const RacePublicSchema = { + properties: { + name: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + event_start_date: { + type: 'string', + format: 'date-time', + title: 'Event Start Date' + }, + event_end_date: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Event End Date' + }, + location: { + type: 'string', + maxLength: 255, + title: 'Location' + }, + city: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'City' + }, + state: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'State' + }, + country: { + type: 'string', + maxLength: 100, + title: 'Country', + default: 'Vietnam' + }, + province_code: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Province Code' + }, + ward_code: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Ward Code' + }, + country_code: { + anyOf: [ + { + type: 'string', + maxLength: 10 + }, + { + type: 'null' + } + ], + title: 'Country Code' + }, + province_name: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Province Name' + }, + ward_name: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Ward Name' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + status: { + '$ref': '#/components/schemas/RaceStatusEnum', + default: 'draft' + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + base_price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Base Price' + }, + currency: { + type: 'string', + maxLength: 3, + title: 'Currency', + default: 'USD' + }, + race_metadata: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Race Metadata' + }, + latitude: { + anyOf: [ + { + type: 'number', + maximum: 90, + minimum: -90 + }, + { + type: 'null' + } + ], + title: 'Latitude' + }, + longitude: { + anyOf: [ + { + type: 'number', + maximum: 180, + minimum: -180 + }, + { + type: 'null' + } + ], + title: 'Longitude' + }, + terrain_type: { + anyOf: [ + { + '$ref': '#/components/schemas/TerrainEnum' + }, + { + type: 'null' + } + ] + }, + difficulty_level: { + anyOf: [ + { + '$ref': '#/components/schemas/DifficultyEnum' + }, + { + type: 'null' + } + ] + }, + elevation_gain_m: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Elevation Gain M' + }, + is_certified: { + type: 'boolean', + title: 'Is Certified', + default: false + }, + gpx_file_url: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Gpx File Url' + }, + website_url: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Website Url' + }, + default_language: { + type: 'string', + maxLength: 10, + title: 'Default Language', + default: 'vi' + }, + translations: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Translations' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + organizer_id: { + type: 'string', + format: 'uuid', + title: 'Organizer Id' + } + }, + type: 'object', + required: ['name', 'event_start_date', 'location', 'id', 'created_at', 'updated_at', 'organizer_id'], + title: 'RacePublic' +} as const; + +export const RacePublicWithDetailsSchema = { + properties: { + name: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + event_start_date: { + type: 'string', + format: 'date-time', + title: 'Event Start Date' + }, + event_end_date: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Event End Date' + }, + location: { + type: 'string', + maxLength: 255, + title: 'Location' + }, + city: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'City' + }, + state: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'State' + }, + country: { + type: 'string', + maxLength: 100, + title: 'Country', + default: 'Vietnam' + }, + province_code: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Province Code' + }, + ward_code: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Ward Code' + }, + country_code: { + anyOf: [ + { + type: 'string', + maxLength: 10 + }, + { + type: 'null' + } + ], + title: 'Country Code' + }, + province_name: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Province Name' + }, + ward_name: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Ward Name' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + status: { + '$ref': '#/components/schemas/RaceStatusEnum', + default: 'draft' + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + base_price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Base Price' + }, + currency: { + type: 'string', + maxLength: 3, + title: 'Currency', + default: 'USD' + }, + race_metadata: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Race Metadata' + }, + latitude: { + anyOf: [ + { + type: 'number', + maximum: 90, + minimum: -90 + }, + { + type: 'null' + } + ], + title: 'Latitude' + }, + longitude: { + anyOf: [ + { + type: 'number', + maximum: 180, + minimum: -180 + }, + { + type: 'null' + } + ], + title: 'Longitude' + }, + terrain_type: { + anyOf: [ + { + '$ref': '#/components/schemas/TerrainEnum' + }, + { + type: 'null' + } + ] + }, + difficulty_level: { + anyOf: [ + { + '$ref': '#/components/schemas/DifficultyEnum' + }, + { + type: 'null' + } + ] + }, + elevation_gain_m: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Elevation Gain M' + }, + is_certified: { + type: 'boolean', + title: 'Is Certified', + default: false + }, + gpx_file_url: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Gpx File Url' + }, + website_url: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Website Url' + }, + default_language: { + type: 'string', + maxLength: 10, + title: 'Default Language', + default: 'vi' + }, + translations: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Translations' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + organizer_id: { + type: 'string', + format: 'uuid', + title: 'Organizer Id' + }, + categories: { + items: { + '$ref': '#/components/schemas/RaceCategoryPublic' + }, + type: 'array', + title: 'Categories', + default: [] + }, + tags: { + items: { + '$ref': '#/components/schemas/TagPublic' + }, + type: 'array', + title: 'Tags', + default: [] + }, + registration_count: { + type: 'integer', + title: 'Registration Count', + default: 0 + }, + province: { + anyOf: [ + { + '$ref': '#/components/schemas/ProvincePublic' + }, + { + type: 'null' + } + ] + }, + ward: { + anyOf: [ + { + '$ref': '#/components/schemas/WardPublic' + }, + { + type: 'null' + } + ] + } + }, + type: 'object', + required: ['name', 'event_start_date', 'location', 'id', 'created_at', 'updated_at', 'organizer_id'], + title: 'RacePublicWithDetails' +} as const; + +export const RacePublicWithDistanceSchema = { + properties: { + name: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + event_start_date: { + type: 'string', + format: 'date-time', + title: 'Event Start Date' + }, + event_end_date: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Event End Date' + }, + location: { + type: 'string', + maxLength: 255, + title: 'Location' + }, + city: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'City' + }, + state: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'State' + }, + country: { + type: 'string', + maxLength: 100, + title: 'Country', + default: 'Vietnam' + }, + province_code: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Province Code' + }, + ward_code: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Ward Code' + }, + country_code: { + anyOf: [ + { + type: 'string', + maxLength: 10 + }, + { + type: 'null' + } + ], + title: 'Country Code' + }, + province_name: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Province Name' + }, + ward_name: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Ward Name' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + status: { + '$ref': '#/components/schemas/RaceStatusEnum', + default: 'draft' + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + base_price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Base Price' + }, + currency: { + type: 'string', + maxLength: 3, + title: 'Currency', + default: 'USD' + }, + race_metadata: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Race Metadata' + }, + latitude: { + anyOf: [ + { + type: 'number', + maximum: 90, + minimum: -90 + }, + { + type: 'null' + } + ], + title: 'Latitude' + }, + longitude: { + anyOf: [ + { + type: 'number', + maximum: 180, + minimum: -180 + }, + { + type: 'null' + } + ], + title: 'Longitude' + }, + terrain_type: { + anyOf: [ + { + '$ref': '#/components/schemas/TerrainEnum' + }, + { + type: 'null' + } + ] + }, + difficulty_level: { + anyOf: [ + { + '$ref': '#/components/schemas/DifficultyEnum' + }, + { + type: 'null' + } + ] + }, + elevation_gain_m: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Elevation Gain M' + }, + is_certified: { + type: 'boolean', + title: 'Is Certified', + default: false + }, + gpx_file_url: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Gpx File Url' + }, + website_url: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Website Url' + }, + default_language: { + type: 'string', + maxLength: 10, + title: 'Default Language', + default: 'vi' + }, + translations: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Translations' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + organizer_id: { + type: 'string', + format: 'uuid', + title: 'Organizer Id' + }, + distance_km: { + type: 'number', + title: 'Distance Km' + } + }, + type: 'object', + required: ['name', 'event_start_date', 'location', 'id', 'created_at', 'updated_at', 'organizer_id', 'distance_km'], + title: 'RacePublicWithDistance' +} as const; + +export const RacePublicWithExplanationSchema = { + properties: { + name: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + event_start_date: { + type: 'string', + format: 'date-time', + title: 'Event Start Date' + }, + event_end_date: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Event End Date' + }, + location: { + type: 'string', + maxLength: 255, + title: 'Location' + }, + city: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'City' + }, + state: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'State' + }, + country: { + type: 'string', + maxLength: 100, + title: 'Country', + default: 'Vietnam' + }, + province_code: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Province Code' + }, + ward_code: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Ward Code' + }, + country_code: { + anyOf: [ + { + type: 'string', + maxLength: 10 + }, + { + type: 'null' + } + ], + title: 'Country Code' + }, + province_name: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Province Name' + }, + ward_name: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Ward Name' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + status: { + '$ref': '#/components/schemas/RaceStatusEnum', + default: 'draft' + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + base_price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Base Price' + }, + currency: { + type: 'string', + maxLength: 3, + title: 'Currency', + default: 'USD' + }, + race_metadata: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Race Metadata' + }, + latitude: { + anyOf: [ + { + type: 'number', + maximum: 90, + minimum: -90 + }, + { + type: 'null' + } + ], + title: 'Latitude' + }, + longitude: { + anyOf: [ + { + type: 'number', + maximum: 180, + minimum: -180 + }, + { + type: 'null' + } + ], + title: 'Longitude' + }, + terrain_type: { + anyOf: [ + { + '$ref': '#/components/schemas/TerrainEnum' + }, + { + type: 'null' + } + ] + }, + difficulty_level: { + anyOf: [ + { + '$ref': '#/components/schemas/DifficultyEnum' + }, + { + type: 'null' + } + ] + }, + elevation_gain_m: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Elevation Gain M' + }, + is_certified: { + type: 'boolean', + title: 'Is Certified', + default: false + }, + gpx_file_url: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Gpx File Url' + }, + website_url: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Website Url' + }, + default_language: { + type: 'string', + maxLength: 10, + title: 'Default Language', + default: 'vi' + }, + translations: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Translations' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + organizer_id: { + type: 'string', + format: 'uuid', + title: 'Organizer Id' + }, + ai_explanation: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Ai Explanation' + } + }, + type: 'object', + required: ['name', 'event_start_date', 'location', 'id', 'created_at', 'updated_at', 'organizer_id'], + title: 'RacePublicWithExplanation' +} as const; + +export const RaceRegistrationCreateSchema = { + properties: { + bib_number: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Bib Number' + }, + emergency_contact: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Emergency Contact' + }, + emergency_phone: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Emergency Phone' + }, + tshirt_size: { + anyOf: [ + { + type: 'string', + maxLength: 10 + }, + { + type: 'null' + } + ], + title: 'Tshirt Size' + }, + special_requirements: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Special Requirements' + }, + registration_status: { + '$ref': '#/components/schemas/RegistrationStatusEnum', + default: 'pending' + }, + payment_status: { + '$ref': '#/components/schemas/PaymentStatusEnum', + default: 'unpaid' + }, + amount_paid: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Amount Paid' + }, + payment_reference: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Payment Reference' + }, + registration_data: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Registration Data' + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + }, + category_id: { + type: 'string', + format: 'uuid', + title: 'Category Id' + } + }, + type: 'object', + required: ['race_id', 'category_id'], + title: 'RaceRegistrationCreate' +} as const; + +export const RaceRegistrationPublicSchema = { + properties: { + bib_number: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Bib Number' + }, + emergency_contact: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Emergency Contact' + }, + emergency_phone: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Emergency Phone' + }, + tshirt_size: { + anyOf: [ + { + type: 'string', + maxLength: 10 + }, + { + type: 'null' + } + ], + title: 'Tshirt Size' + }, + special_requirements: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Special Requirements' + }, + registration_status: { + '$ref': '#/components/schemas/RegistrationStatusEnum', + default: 'pending' + }, + payment_status: { + '$ref': '#/components/schemas/PaymentStatusEnum', + default: 'unpaid' + }, + amount_paid: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Amount Paid' + }, + payment_reference: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Payment Reference' + }, + registration_data: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Registration Data' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + }, + category_id: { + type: 'string', + format: 'uuid', + title: 'Category Id' + }, + runner_id: { + type: 'string', + format: 'uuid', + title: 'Runner Id' + }, + registered_at: { + type: 'string', + format: 'date-time', + title: 'Registered At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: ['id', 'race_id', 'category_id', 'runner_id', 'registered_at', 'updated_at'], + title: 'RaceRegistrationPublic' +} as const; + +export const RaceRegistrationPublicWithDetailsSchema = { + properties: { + bib_number: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Bib Number' + }, + emergency_contact: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Emergency Contact' + }, + emergency_phone: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Emergency Phone' + }, + tshirt_size: { + anyOf: [ + { + type: 'string', + maxLength: 10 + }, + { + type: 'null' + } + ], + title: 'Tshirt Size' + }, + special_requirements: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Special Requirements' + }, + registration_status: { + '$ref': '#/components/schemas/RegistrationStatusEnum', + default: 'pending' + }, + payment_status: { + '$ref': '#/components/schemas/PaymentStatusEnum', + default: 'unpaid' + }, + amount_paid: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Amount Paid' + }, + payment_reference: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Payment Reference' + }, + registration_data: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Registration Data' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + }, + category_id: { + type: 'string', + format: 'uuid', + title: 'Category Id' + }, + runner_id: { + type: 'string', + format: 'uuid', + title: 'Runner Id' + }, + registered_at: { + type: 'string', + format: 'date-time', + title: 'Registered At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + runner: { + '$ref': '#/components/schemas/UserPublic' + }, + category: { + '$ref': '#/components/schemas/RaceCategoryPublic' + } + }, + type: 'object', + required: ['id', 'race_id', 'category_id', 'runner_id', 'registered_at', 'updated_at', 'runner', 'category'], + title: 'RaceRegistrationPublicWithDetails' +} as const; + +export const RaceRegistrationUpdateSchema = { + properties: { + bib_number: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Bib Number' + }, + emergency_contact: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Emergency Contact' + }, + emergency_phone: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Emergency Phone' + }, + tshirt_size: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Tshirt Size' + }, + special_requirements: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Special Requirements' + }, + registration_status: { + anyOf: [ + { + '$ref': '#/components/schemas/RegistrationStatusEnum' + }, + { + type: 'null' + } + ] + }, + payment_status: { + anyOf: [ + { + '$ref': '#/components/schemas/PaymentStatusEnum' + }, + { + type: 'null' + } + ] + }, + amount_paid: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Amount Paid' + }, + payment_reference: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Payment Reference' + }, + registration_data: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Registration Data' + } + }, + type: 'object', + title: 'RaceRegistrationUpdate' +} as const; + +export const RaceRegistrationsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RaceRegistrationPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RaceRegistrationsPublic' +} as const; + +export const RaceResultCreateSchema = { + properties: { + finish_time_seconds: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Finish Time Seconds' + }, + overall_position: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Overall Position' + }, + category_position: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Category Position' + }, + gender_position: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Gender Position' + }, + status: { + '$ref': '#/components/schemas/ResultStatusEnum', + default: 'finished' + }, + pace_per_km_seconds: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Pace Per Km Seconds' + }, + notes: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Notes' + }, + registration_id: { + type: 'string', + format: 'uuid', + title: 'Registration Id' + } + }, + type: 'object', + required: ['registration_id'], + title: 'RaceResultCreate' +} as const; + +export const RaceResultPublicSchema = { + properties: { + finish_time_seconds: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Finish Time Seconds' + }, + overall_position: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Overall Position' + }, + category_position: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Category Position' + }, + gender_position: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Gender Position' + }, + status: { + '$ref': '#/components/schemas/ResultStatusEnum', + default: 'finished' + }, + pace_per_km_seconds: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Pace Per Km Seconds' + }, + notes: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Notes' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + registration_id: { + type: 'string', + format: 'uuid', + title: 'Registration Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: ['id', 'registration_id', 'created_at', 'updated_at'], + title: 'RaceResultPublic' +} as const; + +export const RaceResultUpdateSchema = { + properties: { + finish_time_seconds: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Finish Time Seconds' + }, + overall_position: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Overall Position' + }, + category_position: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Category Position' + }, + gender_position: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Gender Position' + }, + status: { + anyOf: [ + { + '$ref': '#/components/schemas/ResultStatusEnum' + }, + { + type: 'null' + } + ] + }, + pace_per_km_seconds: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Pace Per Km Seconds' + }, + notes: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Notes' + } + }, + type: 'object', + title: 'RaceResultUpdate' +} as const; + +export const RaceResultsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RaceResultPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RaceResultsPublic' +} as const; + +export const RaceStatusEnumSchema = { + type: 'string', + enum: ['draft', 'published', 'registration_open', 'registration_closed', 'completed', 'cancelled'], + title: 'RaceStatusEnum' +} as const; + +export const RaceTagCreateSchema = { + properties: { + name: { + type: 'string', + maxLength: 50, + title: 'Name' + }, + slug: { + type: 'string', + maxLength: 50, + title: 'Slug' + }, + translations: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Translations' + } + }, + type: 'object', + required: ['name', 'slug'], + title: 'RaceTagCreate' +} as const; + +export const RaceTranslationUpdateSchema = { + properties: { + language: { + type: 'string', + maxLength: 10, + minLength: 2, + title: 'Language' + }, + name: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + location: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Location' + } + }, + type: 'object', + required: ['language'], + title: 'RaceTranslationUpdate', + description: 'Update translations for a race' +} as const; + +export const RaceUpdateSchema = { + properties: { + name: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Description' + }, + event_start_date: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Event Start Date' + }, + event_end_date: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Event End Date' + }, + location: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Location' + }, + city: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'City' + }, + state: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'State' + }, + country: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Country' + }, + province_code: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Province Code' + }, + ward_code: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Ward Code' + }, + country_code: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Country Code' + }, + province_name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Province Name' + }, + ward_name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Ward Name' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + status: { + anyOf: [ + { + '$ref': '#/components/schemas/RaceStatusEnum' + }, + { + type: 'null' + } + ] + }, + is_active: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Is Active' + }, + base_price: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Base Price' + }, + currency: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Currency' + }, + race_metadata: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Race Metadata' + }, + latitude: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Latitude' + }, + longitude: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Longitude' + }, + terrain_type: { + anyOf: [ + { + '$ref': '#/components/schemas/TerrainEnum' + }, + { + type: 'null' + } + ] + }, + difficulty_level: { + anyOf: [ + { + '$ref': '#/components/schemas/DifficultyEnum' + }, + { + type: 'null' + } + ] + }, + elevation_gain_m: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Elevation Gain M' + }, + is_certified: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Is Certified' + }, + gpx_file_url: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Gpx File Url' + }, + website_url: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Website Url' + }, + tag_ids: { + anyOf: [ + { + items: { + type: 'string', + format: 'uuid' + }, + type: 'array' + }, + { + type: 'null' + } + ], + title: 'Tag Ids' + } + }, + type: 'object', + title: 'RaceUpdate' +} as const; + +export const RacesPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RacePublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RacesPublic' +} as const; + +export const RacesPublicWithDistanceSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RacePublicWithDistance' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RacesPublicWithDistance' +} as const; + +export const RacesPublicWithExplanationSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RacePublicWithExplanation' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RacesPublicWithExplanation' +} as const; + +export const RegistrationStatusEnumSchema = { + type: 'string', + enum: ['pending', 'confirmed', 'cancelled', 'waitlist'], + title: 'RegistrationStatusEnum' +} as const; + +export const ResultStatusEnumSchema = { + type: 'string', + enum: ['finished', 'dnf', 'dns', 'dq'], + title: 'ResultStatusEnum' +} as const; + +export const RoleCreateSchema = { + properties: { + name: { + type: 'string', + maxLength: 50, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Description' + } + }, + type: 'object', + required: ['name'], + title: 'RoleCreate' +} as const; + +export const RolePublicSchema = { + properties: { + name: { + type: 'string', + maxLength: 50, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Created At' + } + }, + type: 'object', + required: ['name', 'id'], + title: 'RolePublic' +} as const; + +export const RoleUpdateSchema = { + properties: { + name: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Description' + } + }, + type: 'object', + title: 'RoleUpdate' +} as const; + +export const RolesPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RolePublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RolesPublic' +} as const; + +export const TagPublicSchema = { + properties: { + name: { + type: 'string', + maxLength: 50, + title: 'Name' + }, + slug: { + type: 'string', + maxLength: 50, + title: 'Slug' + }, + translations: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Translations' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + } + }, + type: 'object', + required: ['name', 'slug', 'id'], + title: 'TagPublic' +} as const; + +export const TagSuggestionSchema = { + properties: { + tags: { + items: { + type: 'string' + }, + type: 'array', + title: 'Tags' + } + }, + type: 'object', + required: ['tags'], + title: 'TagSuggestion' +} as const; + +export const TagTranslationUpdateSchema = { + properties: { + language: { + type: 'string', + maxLength: 10, + minLength: 2, + title: 'Language' + }, + name: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Name' + } + }, + type: 'object', + required: ['language'], + title: 'TagTranslationUpdate', + description: 'Update translations for a tag' +} as const; + +export const TagsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/TagPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'TagsPublic' +} as const; + +export const TerrainEnumSchema = { + type: 'string', + enum: ['road', 'trail', 'track', 'mixed'], + title: 'TerrainEnum' +} as const; + +export const TokenSchema = { + properties: { + access_token: { + type: 'string', + title: 'Access Token' + }, + token_type: { + type: 'string', + title: 'Token Type', + default: 'bearer' + } + }, + type: 'object', + required: ['access_token'], + title: 'Token' +} as const; + +export const UpdatePasswordSchema = { + properties: { + current_password: { + type: 'string', + maxLength: 128, + minLength: 8, + title: 'Current Password' + }, + new_password: { + type: 'string', + maxLength: 128, + minLength: 8, + title: 'New Password' + } + }, + type: 'object', + required: ['current_password', 'new_password'], + title: 'UpdatePassword' +} as const; + +export const UserCreateSchema = { + properties: { + email: { + type: 'string', + maxLength: 255, + format: 'email', + title: 'Email' + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + is_superuser: { + type: 'boolean', + title: 'Is Superuser', + default: false + }, + full_name: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Full Name' + }, + password: { + type: 'string', + maxLength: 128, + minLength: 8, + title: 'Password' + } + }, + type: 'object', + required: ['email', 'password'], + title: 'UserCreate' +} as const; + +export const UserProfileCreateSchema = { + properties: { + fitness_level: { + anyOf: [ + { + '$ref': '#/components/schemas/FitnessEnum' + }, + { + type: 'null' + } + ] + }, + distance_preference: { + anyOf: [ + { + '$ref': '#/components/schemas/DistancePrefEnum' + }, + { + type: 'null' + } + ] + }, + terrain_preference: { + anyOf: [ + { + '$ref': '#/components/schemas/TerrainEnum' + }, + { + type: 'null' + } + ] + }, + home_latitude: { + anyOf: [ + { + type: 'number', + maximum: 90, + minimum: -90 + }, + { + type: 'null' + } + ], + title: 'Home Latitude' + }, + home_longitude: { + anyOf: [ + { + type: 'number', + maximum: 180, + minimum: -180 + }, + { + type: 'null' + } + ], + title: 'Home Longitude' + }, + home_city: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Home City' + }, + weekly_mileage_km: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Weekly Mileage Km' + }, + goal_race_date: { + anyOf: [ + { + type: 'string', + format: 'date' + }, + { + type: 'null' + } + ], + title: 'Goal Race Date' + }, + bio: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Bio' + }, + is_onboarded: { + type: 'boolean', + title: 'Is Onboarded', + default: false + } + }, + type: 'object', + title: 'UserProfileCreate' +} as const; + +export const UserProfilePublicSchema = { + properties: { + fitness_level: { + anyOf: [ + { + '$ref': '#/components/schemas/FitnessEnum' + }, + { + type: 'null' + } + ] + }, + distance_preference: { + anyOf: [ + { + '$ref': '#/components/schemas/DistancePrefEnum' + }, + { + type: 'null' + } + ] + }, + terrain_preference: { + anyOf: [ + { + '$ref': '#/components/schemas/TerrainEnum' + }, + { + type: 'null' + } + ] + }, + home_latitude: { + anyOf: [ + { + type: 'number', + maximum: 90, + minimum: -90 + }, + { + type: 'null' + } + ], + title: 'Home Latitude' + }, + home_longitude: { + anyOf: [ + { + type: 'number', + maximum: 180, + minimum: -180 + }, + { + type: 'null' + } + ], + title: 'Home Longitude' + }, + home_city: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Home City' + }, + weekly_mileage_km: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Weekly Mileage Km' + }, + goal_race_date: { + anyOf: [ + { + type: 'string', + format: 'date' + }, + { + type: 'null' + } + ], + title: 'Goal Race Date' + }, + bio: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Bio' + }, + is_onboarded: { + type: 'boolean', + title: 'Is Onboarded', + default: false + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + user_id: { + type: 'string', + format: 'uuid', + title: 'User Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: ['id', 'user_id', 'created_at', 'updated_at'], + title: 'UserProfilePublic' +} as const; + +export const UserProfileUpdateSchema = { + properties: { + fitness_level: { + anyOf: [ + { + '$ref': '#/components/schemas/FitnessEnum' + }, + { + type: 'null' + } + ] + }, + distance_preference: { + anyOf: [ + { + '$ref': '#/components/schemas/DistancePrefEnum' + }, + { + type: 'null' + } + ] + }, + terrain_preference: { + anyOf: [ + { + '$ref': '#/components/schemas/TerrainEnum' + }, + { + type: 'null' + } + ] + }, + home_latitude: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Home Latitude' }, - new_password: { - type: 'string', - maxLength: 128, - minLength: 8, - title: 'New Password' - } - }, - type: 'object', - required: ['current_password', 'new_password'], - title: 'UpdatePassword' -} as const; - -export const UserCreateSchema = { - properties: { - email: { - type: 'string', - maxLength: 255, - format: 'email', - title: 'Email' + home_longitude: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Home Longitude' }, - is_active: { - type: 'boolean', - title: 'Is Active', - default: true + home_city: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Home City' }, - is_superuser: { - type: 'boolean', - title: 'Is Superuser', - default: false + weekly_mileage_km: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Weekly Mileage Km' }, - full_name: { + goal_race_date: { anyOf: [ { type: 'string', - maxLength: 255 + format: 'date' }, { type: 'null' } ], - title: 'Full Name' + title: 'Goal Race Date' }, - password: { - type: 'string', - maxLength: 128, - minLength: 8, - title: 'Password' + bio: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Bio' + }, + is_onboarded: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Is Onboarded' } }, type: 'object', - required: ['email', 'password'], - title: 'UserCreate' + title: 'UserProfileUpdate' } as const; export const UserPublicSchema = { @@ -376,6 +5824,14 @@ export const UserPublicSchema = { } ], title: 'Created At' + }, + roles: { + items: { + '$ref': '#/components/schemas/RolePublic' + }, + type: 'array', + title: 'Roles', + default: [] } }, type: 'object', @@ -383,6 +5839,37 @@ export const UserPublicSchema = { title: 'UserPublic' } as const; +export const UserRaceInteractionPublicSchema = { + properties: { + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + user_id: { + type: 'string', + format: 'uuid', + title: 'User Id' + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + }, + action: { + '$ref': '#/components/schemas/InteractionTypeEnum' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + } + }, + type: 'object', + required: ['id', 'user_id', 'race_id', 'action', 'created_at'], + title: 'UserRaceInteractionPublic' +} as const; + export const UserRegisterSchema = { properties: { email: { @@ -556,4 +6043,197 @@ export const ValidationErrorSchema = { type: 'object', required: ['loc', 'msg', 'type'], title: 'ValidationError' +} as const; + +export const WardPublicSchema = { + properties: { + code: { + type: 'string', + title: 'Code' + }, + name: { + type: 'string', + title: 'Name' + }, + name_en: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Name En' + }, + full_name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Full Name' + }, + full_name_en: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Full Name En' + }, + code_name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Code Name' + }, + province_code: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Province Code' + }, + administrative_unit_id: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Administrative Unit Id' + } + }, + type: 'object', + required: ['code', 'name'], + title: 'WardPublic' +} as const; + +export const WardPublicWithDetailsSchema = { + properties: { + code: { + type: 'string', + title: 'Code' + }, + name: { + type: 'string', + title: 'Name' + }, + name_en: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Name En' + }, + full_name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Full Name' + }, + full_name_en: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Full Name En' + }, + code_name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Code Name' + }, + province_code: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Province Code' + }, + administrative_unit_id: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Administrative Unit Id' + }, + administrative_unit: { + anyOf: [ + { + '$ref': '#/components/schemas/AdministrativeUnitPublic' + }, + { + type: 'null' + } + ] + } + }, + type: 'object', + required: ['code', 'name'], + title: 'WardPublicWithDetails' +} as const; + +export const WardsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/WardPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'WardsPublic' } as const; \ No newline at end of file diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index ba79e3f726..f0400d5f97 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,31 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; +import type { AdminReindexRaceEmbeddingsData, AdminReindexRaceEmbeddingsResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MediaReadMediaAssetsData, MediaReadMediaAssetsResponse, MediaUploadMediaAssetData, MediaUploadMediaAssetResponse, MediaReadMediaFileData, MediaReadMediaFileResponse, MediaUpdateMediaAssetData, MediaUpdateMediaAssetResponse, MediaDeleteMediaAssetData, MediaDeleteMediaAssetResponse, PrivateCreateUserData, PrivateCreateUserResponse, ProfilesGetMyProfileResponse, ProfilesUpsertMyProfileData, ProfilesUpsertMyProfileResponse, ProfilesDeleteMyProfileResponse, ProfilesUpdateMyProfileData, ProfilesUpdateMyProfileResponse, ProfilesGetMySavedRacesResponse, ProfilesSaveRaceData, ProfilesSaveRaceResponse, ProfilesUnsaveRaceData, ProfilesUnsaveRaceResponse, ProfilesTrackRaceViewData, ProfilesTrackRaceViewResponse, ProvincesReadAdministrativeRegionsResponse, ProvincesReadAdministrativeUnitsResponse, ProvincesReadProvincesData, ProvincesReadProvincesResponse, ProvincesReadProvinceData, ProvincesReadProvinceResponse, ProvincesReadWardsByProvinceData, ProvincesReadWardsByProvinceResponse, ProvincesReadWardData, ProvincesReadWardResponse, RaceAttributesReadRaceAttributesData, RaceAttributesReadRaceAttributesResponse, RaceAttributesCreateRaceAttributeData, RaceAttributesCreateRaceAttributeResponse, RaceAttributesReadRaceAttributeData, RaceAttributesReadRaceAttributeResponse, RaceAttributesUpdateRaceAttributeData, RaceAttributesUpdateRaceAttributeResponse, RaceAttributesDeleteRaceAttributeData, RaceAttributesDeleteRaceAttributeResponse, RaceCategoriesReadRaceCategoriesData, RaceCategoriesReadRaceCategoriesResponse, RaceCategoriesCreateRaceCategoryData, RaceCategoriesCreateRaceCategoryResponse, RaceCategoriesReadRaceCategoryData, RaceCategoriesReadRaceCategoryResponse, RaceCategoriesUpdateRaceCategoryData, RaceCategoriesUpdateRaceCategoryResponse, RaceCategoriesDeleteRaceCategoryData, RaceCategoriesDeleteRaceCategoryResponse, RaceCategoriesUpdateCategoryTranslationsData, RaceCategoriesUpdateCategoryTranslationsResponse, RaceCategoriesGetCategoryTranslationsData, RaceCategoriesGetCategoryTranslationsResponse, RaceRegistrationsReadRaceRegistrationsData, RaceRegistrationsReadRaceRegistrationsResponse, RaceRegistrationsCreateRaceRegistrationData, RaceRegistrationsCreateRaceRegistrationResponse, RaceRegistrationsReadMyRegistrationsData, RaceRegistrationsReadMyRegistrationsResponse, RaceRegistrationsReadRaceRegistrationData, RaceRegistrationsReadRaceRegistrationResponse, RaceRegistrationsUpdateRaceRegistrationData, RaceRegistrationsUpdateRaceRegistrationResponse, RaceRegistrationsDeleteRaceRegistrationData, RaceRegistrationsDeleteRaceRegistrationResponse, RaceResultsReadRaceResultsData, RaceResultsReadRaceResultsResponse, RaceResultsCreateRaceResultData, RaceResultsCreateRaceResultResponse, RaceResultsReadRaceResultData, RaceResultsReadRaceResultResponse, RaceResultsUpdateRaceResultData, RaceResultsUpdateRaceResultResponse, RaceResultsDeleteRaceResultData, RaceResultsDeleteRaceResultResponse, RaceResultsReadRaceResultByRegistrationData, RaceResultsReadRaceResultByRegistrationResponse, RacesSearchRacesData, RacesSearchRacesResponse, RacesGetNearbyRacesData, RacesGetNearbyRacesResponse, RacesGetTrendingRacesData, RacesGetTrendingRacesResponse, RacesGetRecommendedRacesData, RacesGetRecommendedRacesResponse, RacesReadMyOrganizedRacesData, RacesReadMyOrganizedRacesResponse, RacesReadRacesData, RacesReadRacesResponse, RacesCreateRaceData, RacesCreateRaceResponse, RacesGenerateRaceDetailsData, RacesGenerateRaceDetailsResponse, RacesReadRaceData, RacesReadRaceResponse, RacesUpdateRaceData, RacesUpdateRaceResponse, RacesDeleteRaceData, RacesDeleteRaceResponse, RacesGetSimilarRacesData, RacesGetSimilarRacesResponse, RacesAutoTagRaceData, RacesAutoTagRaceResponse, RacesEnhanceRaceDescriptionData, RacesEnhanceRaceDescriptionResponse, RacesAskRaceQuestionData, RacesAskRaceQuestionResponse, RacesUpdateRaceTranslationsData, RacesUpdateRaceTranslationsResponse, RacesGetRaceTranslationsData, RacesGetRaceTranslationsResponse, RolesReadRolesData, RolesReadRolesResponse, RolesCreateRoleData, RolesCreateRoleResponse, RolesReadRoleData, RolesReadRoleResponse, RolesUpdateRoleData, RolesUpdateRoleResponse, RolesDeleteRoleData, RolesDeleteRoleResponse, RolesAssignRoleToUserData, RolesAssignRoleToUserResponse, RolesRemoveRoleFromUserData, RolesRemoveRoleFromUserResponse, TagsListTagsResponse, TagsCreateTagData, TagsCreateTagResponse, TagsSetTagsForRaceData, TagsSetTagsForRaceResponse, TagsUpdateTagTranslationsData, TagsUpdateTagTranslationsResponse, TagsGetTagTranslationsData, TagsGetTagTranslationsResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; + +export class AdminService { + /** + * Reindex Race Embeddings + * Queue embedding generation for all races that lack an embedding. + * Admin only. + * @param data The data for the request. + * @param data.batchSize + * @returns Message Successful Response + * @throws ApiError + */ + public static reindexRaceEmbeddings(data: AdminReindexRaceEmbeddingsData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/admin/races/reindex', + query: { + batch_size: data.batchSize + }, + errors: { + 422: 'Validation Error' + } + }); + } +} export class ItemsService { /** @@ -213,6 +237,125 @@ export class LoginService { } } +export class MediaService { + /** + * Read Media Assets + * List media assets for any content type. + * @param data The data for the request. + * @param data.contentType + * @param data.contentId + * @param data.kind + * @param data.isPublic + * @param data.skip + * @param data.limit + * @returns MediaAssetsPublic Successful Response + * @throws ApiError + */ + public static readMediaAssets(data: MediaReadMediaAssetsData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/media/', + query: { + content_type: data.contentType, + content_id: data.contentId, + kind: data.kind, + is_public: data.isPublic, + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Upload Media Asset + * Upload media for any content type (currently race-aware for permissions). + * @param data The data for the request. + * @param data.formData + * @returns MediaAssetPublic Successful Response + * @throws ApiError + */ + public static uploadMediaAsset(data: MediaUploadMediaAssetData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/media/upload', + formData: data.formData, + mediaType: 'multipart/form-data', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Media File + * Serve a media file by media id. + * @param data The data for the request. + * @param data.mediaId + * @returns unknown Successful Response + * @throws ApiError + */ + public static readMediaFile(data: MediaReadMediaFileData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/media/{media_id}/file', + path: { + media_id: data.mediaId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Media Asset + * Update media metadata. + * @param data The data for the request. + * @param data.mediaId + * @param data.requestBody + * @returns MediaAssetPublic Successful Response + * @throws ApiError + */ + public static updateMediaAsset(data: MediaUpdateMediaAssetData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/media/{media_id}', + path: { + media_id: data.mediaId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Media Asset + * Delete a media asset and its file. + * @param data The data for the request. + * @param data.mediaId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteMediaAsset(data: MediaDeleteMediaAssetData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/media/{media_id}', + path: { + media_id: data.mediaId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + export class PrivateService { /** * Create User @@ -235,6 +378,1520 @@ export class PrivateService { } } +export class ProfilesService { + /** + * Get My Profile + * Return the current user's running profile. + * @returns UserProfilePublic Successful Response + * @throws ApiError + */ + public static getMyProfile(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/users/me/profile' + }); + } + + /** + * Upsert My Profile + * Create or replace the current user's running profile. + * @param data The data for the request. + * @param data.requestBody + * @returns UserProfilePublic Successful Response + * @throws ApiError + */ + public static upsertMyProfile(data: ProfilesUpsertMyProfileData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/users/me/profile', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete My Profile + * Delete the current user's running profile and reset onboarding state. + * @returns unknown Successful Response + * @throws ApiError + */ + public static deleteMyProfile(): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/users/me/profile' + }); + } + + /** + * Update My Profile + * Partially update the current user's running profile. + * @param data The data for the request. + * @param data.requestBody + * @returns UserProfilePublic Successful Response + * @throws ApiError + */ + public static updateMyProfile(data: ProfilesUpdateMyProfileData): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/api/v1/users/me/profile', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get My Saved Races + * Return all races the current user has saved. + * @returns RacesPublic Successful Response + * @throws ApiError + */ + public static getMySavedRaces(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/users/me/saved-races' + }); + } + + /** + * Save Race + * Save a race to the current user's wishlist. + * @param data The data for the request. + * @param data.raceId + * @returns UserRaceInteractionPublic Successful Response + * @throws ApiError + */ + public static saveRace(data: ProfilesSaveRaceData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/races/{race_id}/save', + path: { + race_id: data.raceId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Unsave Race + * Remove a race from the current user's wishlist. + * @param data The data for the request. + * @param data.raceId + * @returns unknown Successful Response + * @throws ApiError + */ + public static unsaveRace(data: ProfilesUnsaveRaceData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/races/{race_id}/save', + path: { + race_id: data.raceId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Track Race View + * Record that the current user viewed a race detail page. + * @param data The data for the request. + * @param data.raceId + * @returns UserRaceInteractionPublic Successful Response + * @throws ApiError + */ + public static trackRaceView(data: ProfilesTrackRaceViewData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/races/{race_id}/view', + path: { + race_id: data.raceId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class ProvincesService { + /** + * Read Administrative Regions + * Get all administrative regions. + * @returns AdministrativeRegionPublic Successful Response + * @throws ApiError + */ + public static readAdministrativeRegions(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/provinces/regions' + }); + } + + /** + * Read Administrative Units + * Get all administrative units. + * @returns AdministrativeUnitPublic Successful Response + * @throws ApiError + */ + public static readAdministrativeUnits(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/provinces/units' + }); + } + + /** + * Read Provinces + * Get all provinces with pagination. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns ProvincesPublic Successful Response + * @throws ApiError + */ + public static readProvinces(data: ProvincesReadProvincesData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/provinces/', + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Province + * Get a specific province by code with administrative unit details. + * @param data The data for the request. + * @param data.provinceCode + * @returns ProvincePublicWithDetails Successful Response + * @throws ApiError + */ + public static readProvince(data: ProvincesReadProvinceData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/provinces/{province_code}', + path: { + province_code: data.provinceCode + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Wards By Province + * Get all wards for a specific province. + * @param data The data for the request. + * @param data.provinceCode + * @param data.skip + * @param data.limit + * @returns WardsPublic Successful Response + * @throws ApiError + */ + public static readWardsByProvince(data: ProvincesReadWardsByProvinceData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/provinces/{province_code}/wards', + path: { + province_code: data.provinceCode + }, + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Ward + * Get a specific ward by code with details. + * @param data The data for the request. + * @param data.wardCode + * @returns WardPublicWithDetails Successful Response + * @throws ApiError + */ + public static readWard(data: ProvincesReadWardData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/provinces/wards/{ward_code}', + path: { + ward_code: data.wardCode + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class RaceAttributesService { + /** + * Read Race Attributes + * Retrieve race attributes for a specific race. + * Public endpoint - filters by is_public by default unless authenticated organizer/admin. + * @param data The data for the request. + * @param data.raceId + * @param data.isPublic + * @returns RaceAttributesPublic Successful Response + * @throws ApiError + */ + public static readRaceAttributes(data: RaceAttributesReadRaceAttributesData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-attributes/', + query: { + race_id: data.raceId, + is_public: data.isPublic + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Race Attribute + * Create new race attribute. + * Only the race organizer or admin can create attributes. + * @param data The data for the request. + * @param data.requestBody + * @returns RaceAttributePublic Successful Response + * @throws ApiError + */ + public static createRaceAttribute(data: RaceAttributesCreateRaceAttributeData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/race-attributes/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Race Attribute + * Get race attribute by ID. + * @param data The data for the request. + * @param data.attributeId + * @returns RaceAttributePublic Successful Response + * @throws ApiError + */ + public static readRaceAttribute(data: RaceAttributesReadRaceAttributeData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-attributes/{attribute_id}', + path: { + attribute_id: data.attributeId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Race Attribute + * Update a race attribute. + * Only the race organizer or admin can update. + * @param data The data for the request. + * @param data.attributeId + * @param data.requestBody + * @returns RaceAttributePublic Successful Response + * @throws ApiError + */ + public static updateRaceAttribute(data: RaceAttributesUpdateRaceAttributeData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/race-attributes/{attribute_id}', + path: { + attribute_id: data.attributeId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Race Attribute + * Delete a race attribute. + * Only the race organizer or admin can delete. + * @param data The data for the request. + * @param data.attributeId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteRaceAttribute(data: RaceAttributesDeleteRaceAttributeData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/race-attributes/{attribute_id}', + path: { + attribute_id: data.attributeId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class RaceCategoriesService { + /** + * Read Race Categories + * Retrieve race categories for a specific race. Public endpoint. + * @param data The data for the request. + * @param data.raceId + * @param data.skip + * @param data.limit + * @returns RaceCategoriesPublic Successful Response + * @throws ApiError + */ + public static readRaceCategories(data: RaceCategoriesReadRaceCategoriesData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-categories/', + query: { + race_id: data.raceId, + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Race Category + * Create new race category. + * Only the race organizer or admin can create categories. + * @param data The data for the request. + * @param data.requestBody + * @returns RaceCategoryPublic Successful Response + * @throws ApiError + */ + public static createRaceCategory(data: RaceCategoriesCreateRaceCategoryData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/race-categories/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Race Category + * Get race category by ID with details. Public endpoint. + * @param data The data for the request. + * @param data.categoryId + * @returns RaceCategoryPublicWithDetails Successful Response + * @throws ApiError + */ + public static readRaceCategory(data: RaceCategoriesReadRaceCategoryData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-categories/{category_id}', + path: { + category_id: data.categoryId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Race Category + * Update a race category. + * Only the race organizer or admin can update. + * @param data The data for the request. + * @param data.categoryId + * @param data.requestBody + * @returns RaceCategoryPublic Successful Response + * @throws ApiError + */ + public static updateRaceCategory(data: RaceCategoriesUpdateRaceCategoryData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/race-categories/{category_id}', + path: { + category_id: data.categoryId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Race Category + * Delete a race category. + * Only the race organizer or admin can delete. + * @param data The data for the request. + * @param data.categoryId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteRaceCategory(data: RaceCategoriesDeleteRaceCategoryData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/race-categories/{category_id}', + path: { + category_id: data.categoryId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Category Translations + * Update translations for a race category. + * Only race organizer or admin can update translations. + * @param data The data for the request. + * @param data.categoryId + * @param data.requestBody + * @returns RaceCategoryPublic Successful Response + * @throws ApiError + */ + public static updateCategoryTranslations(data: RaceCategoriesUpdateCategoryTranslationsData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/race-categories/{category_id}/translations', + path: { + category_id: data.categoryId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get Category Translations + * Get all translations for a race category. + * Public endpoint - anyone can view translations. + * @param data The data for the request. + * @param data.categoryId + * @returns unknown Successful Response + * @throws ApiError + */ + public static getCategoryTranslations(data: RaceCategoriesGetCategoryTranslationsData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-categories/{category_id}/translations', + path: { + category_id: data.categoryId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class RaceRegistrationsService { + /** + * Read Race Registrations + * Retrieve race registrations. + * - Runners see only their own registrations + * - Organizers see registrations for their races + * - Admins see all registrations + * @param data The data for the request. + * @param data.raceId + * @param data.categoryId + * @param data.skip + * @param data.limit + * @returns RaceRegistrationsPublic Successful Response + * @throws ApiError + */ + public static readRaceRegistrations(data: RaceRegistrationsReadRaceRegistrationsData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-registrations/', + query: { + race_id: data.raceId, + category_id: data.categoryId, + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Race Registration + * Create new race registration. + * Runners can register themselves for races. + * @param data The data for the request. + * @param data.requestBody + * @returns RaceRegistrationPublic Successful Response + * @throws ApiError + */ + public static createRaceRegistration(data: RaceRegistrationsCreateRaceRegistrationData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/race-registrations/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read My Registrations + * Retrieve current user's race registrations. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns RaceRegistrationsPublic Successful Response + * @throws ApiError + */ + public static readMyRegistrations(data: RaceRegistrationsReadMyRegistrationsData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-registrations/my', + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Race Registration + * Get race registration by ID with details. + * @param data The data for the request. + * @param data.registrationId + * @returns RaceRegistrationPublicWithDetails Successful Response + * @throws ApiError + */ + public static readRaceRegistration(data: RaceRegistrationsReadRaceRegistrationData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-registrations/{registration_id}', + path: { + registration_id: data.registrationId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Race Registration + * Update a race registration. + * - Runners can update their own registration details + * - Organizers can update any field for their races + * - Admins can update anything + * @param data The data for the request. + * @param data.registrationId + * @param data.requestBody + * @returns RaceRegistrationPublic Successful Response + * @throws ApiError + */ + public static updateRaceRegistration(data: RaceRegistrationsUpdateRaceRegistrationData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/race-registrations/{registration_id}', + path: { + registration_id: data.registrationId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Race Registration + * Delete/Cancel a race registration. + * - Runners can cancel their own registrations + * - Organizers can cancel registrations for their races + * - Admins can cancel any registration + * @param data The data for the request. + * @param data.registrationId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteRaceRegistration(data: RaceRegistrationsDeleteRaceRegistrationData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/race-registrations/{registration_id}', + path: { + registration_id: data.registrationId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class RaceResultsService { + /** + * Read Race Results + * Retrieve race results for a specific race. Public endpoint. + * @param data The data for the request. + * @param data.raceId + * @param data.skip + * @param data.limit + * @returns RaceResultsPublic Successful Response + * @throws ApiError + */ + public static readRaceResults(data: RaceResultsReadRaceResultsData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-results/', + query: { + race_id: data.raceId, + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Race Result + * Create new race result. + * Only race organizers, volunteers, or admins can create results. + * @param data The data for the request. + * @param data.requestBody + * @returns RaceResultPublic Successful Response + * @throws ApiError + */ + public static createRaceResult(data: RaceResultsCreateRaceResultData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/race-results/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Race Result + * Get race result by ID. Public endpoint. + * @param data The data for the request. + * @param data.resultId + * @returns RaceResultPublic Successful Response + * @throws ApiError + */ + public static readRaceResult(data: RaceResultsReadRaceResultData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-results/{result_id}', + path: { + result_id: data.resultId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Race Result + * Update a race result. + * Only race organizers, volunteers, or admins can update results. + * @param data The data for the request. + * @param data.resultId + * @param data.requestBody + * @returns RaceResultPublic Successful Response + * @throws ApiError + */ + public static updateRaceResult(data: RaceResultsUpdateRaceResultData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/race-results/{result_id}', + path: { + result_id: data.resultId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Race Result + * Delete a race result. + * Only race organizers or admins can delete results. + * @param data The data for the request. + * @param data.resultId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteRaceResult(data: RaceResultsDeleteRaceResultData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/race-results/{result_id}', + path: { + result_id: data.resultId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Race Result By Registration + * Get race result by registration ID. Public endpoint. + * @param data The data for the request. + * @param data.registrationId + * @returns RaceResultPublic Successful Response + * @throws ApiError + */ + public static readRaceResultByRegistration(data: RaceResultsReadRaceResultByRegistrationData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-results/registration/{registration_id}', + path: { + registration_id: data.registrationId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class RacesService { + /** + * Search Races + * Search races with full-text + semantic vector search (RRF fusion), geo, and filters. + * @param data The data for the request. + * @param data.q Full-text search query + * @param data.lat + * @param data.lon + * @param data.radiusKm + * @param data.distanceMinKm + * @param data.distanceMaxKm + * @param data.terrain + * @param data.difficulty + * @param data.dateFrom + * @param data.dateTo + * @param data.tagSlugs + * @param data.status + * @param data.provinceCode Filter by province code + * @param data.wardCode Filter by ward code + * @param data.sort + * @param data.skip + * @param data.limit + * @returns RacesPublic Successful Response + * @throws ApiError + */ + public static searchRaces(data: RacesSearchRacesData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/races/search', + query: { + q: data.q, + lat: data.lat, + lon: data.lon, + radius_km: data.radiusKm, + distance_min_km: data.distanceMinKm, + distance_max_km: data.distanceMaxKm, + terrain: data.terrain, + difficulty: data.difficulty, + date_from: data.dateFrom, + date_to: data.dateTo, + tag_slugs: data.tagSlugs, + status: data.status, + province_code: data.provinceCode, + ward_code: data.wardCode, + sort: data.sort, + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get Nearby Races + * Return races within radius_km of the given coordinates, sorted by distance. + * @param data The data for the request. + * @param data.lat + * @param data.lon + * @param data.radiusKm + * @param data.limit + * @returns RacesPublicWithDistance Successful Response + * @throws ApiError + */ + public static getNearbyRaces(data: RacesGetNearbyRacesData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/races/nearby', + query: { + lat: data.lat, + lon: data.lon, + radius_km: data.radiusKm, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get Trending Races + * Return trending races based on interaction count over the last N days. + * @param data The data for the request. + * @param data.days + * @param data.limit + * @returns RacesPublic Successful Response + * @throws ApiError + */ + public static getTrendingRaces(data: RacesGetTrendingRacesData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/races/trending', + query: { + days: data.days, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get Recommended Races + * Return personalized race recommendations with AI-generated explanations. + * @param data The data for the request. + * @param data.limit + * @returns RacesPublicWithExplanation Successful Response + * @throws ApiError + */ + public static getRecommendedRaces(data: RacesGetRecommendedRacesData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/races/recommended', + query: { + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read My Organized Races + * Retrieve races organized by the current user. + * Requires organizer or admin role. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns RacesPublic Successful Response + * @throws ApiError + */ + public static readMyOrganizedRaces(data: RacesReadMyOrganizedRacesData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/races/my/organized', + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Races + * Retrieve races. Public endpoint - anyone can view races. + * Optionally filter by organizer_id. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @param data.organizerId + * @returns RacesPublic Successful Response + * @throws ApiError + */ + public static readRaces(data: RacesReadRacesData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/races/', + query: { + skip: data.skip, + limit: data.limit, + organizer_id: data.organizerId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Race + * Create new race. + * Requires organizer or admin role. + * @param data The data for the request. + * @param data.requestBody + * @returns RacePublic Successful Response + * @throws ApiError + */ + public static createRace(data: RacesCreateRaceData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/races/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Generate Race Details + * Use AI to generate race details from a race name. + * Requires authentication. + * @param data The data for the request. + * @param data.requestBody + * @returns AIRaceSuggestion Successful Response + * @throws ApiError + */ + public static generateRaceDetails(data: RacesGenerateRaceDetailsData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/races/ai-assist', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Race + * Get race by ID with details. Public endpoint. + * @param data The data for the request. + * @param data.raceId + * @returns RacePublicWithDetails Successful Response + * @throws ApiError + */ + public static readRace(data: RacesReadRaceData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/races/{race_id}', + path: { + race_id: data.raceId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Race + * Update a race. + * Only the organizer or admin can update. + * @param data The data for the request. + * @param data.raceId + * @param data.requestBody + * @returns RacePublic Successful Response + * @throws ApiError + */ + public static updateRace(data: RacesUpdateRaceData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/races/{race_id}', + path: { + race_id: data.raceId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Race + * Delete a race. + * Only the organizer or admin can delete. + * @param data The data for the request. + * @param data.raceId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteRace(data: RacesDeleteRaceData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/races/{race_id}', + path: { + race_id: data.raceId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get Similar Races + * Return races similar to the given race. + * @param data The data for the request. + * @param data.raceId + * @param data.limit + * @returns RacesPublic Successful Response + * @throws ApiError + */ + public static getSimilarRaces(data: RacesGetSimilarRacesData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/races/{race_id}/similar', + path: { + race_id: data.raceId + }, + query: { + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Auto Tag Race + * Suggest tags for a race using AI (does not save — returns suggestions only). + * @param data The data for the request. + * @param data.raceId + * @returns TagSuggestion Successful Response + * @throws ApiError + */ + public static autoTagRace(data: RacesAutoTagRaceData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/races/{race_id}/auto-tag', + path: { + race_id: data.raceId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Enhance Race Description + * Suggest an improved description using AI (does not save — returns suggestion only). + * @param data The data for the request. + * @param data.raceId + * @returns DescriptionSuggestion Successful Response + * @throws ApiError + */ + public static enhanceRaceDescription(data: RacesEnhanceRaceDescriptionData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/races/{race_id}/enhance-description', + path: { + race_id: data.raceId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Ask Race Question + * Answer a question about a specific race using AI. Rate limited to 10 req/min per IP. + * @param data The data for the request. + * @param data.raceId + * @param data.requestBody + * @returns RaceAnswer Successful Response + * @throws ApiError + */ + public static askRaceQuestion(data: RacesAskRaceQuestionData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/races/{race_id}/ask', + path: { + race_id: data.raceId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Race Translations + * Update translations for a race. + * Only race organizer or admin can update translations. + * @param data The data for the request. + * @param data.raceId + * @param data.requestBody + * @returns RacePublic Successful Response + * @throws ApiError + */ + public static updateRaceTranslations(data: RacesUpdateRaceTranslationsData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/races/{race_id}/translations', + path: { + race_id: data.raceId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get Race Translations + * Get all translations for a race. + * Public endpoint - anyone can view translations. + * @param data The data for the request. + * @param data.raceId + * @returns unknown Successful Response + * @throws ApiError + */ + public static getRaceTranslations(data: RacesGetRaceTranslationsData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/races/{race_id}/translations', + path: { + race_id: data.raceId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class RolesService { + /** + * Read Roles + * Retrieve roles. Admin only. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns RolesPublic Successful Response + * @throws ApiError + */ + public static readRoles(data: RolesReadRolesData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/roles/', + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Role + * Create new role. Admin only. + * @param data The data for the request. + * @param data.requestBody + * @returns RolePublic Successful Response + * @throws ApiError + */ + public static createRole(data: RolesCreateRoleData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/roles/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Role + * Get role by ID. Admin only. + * @param data The data for the request. + * @param data.roleId + * @returns RolePublic Successful Response + * @throws ApiError + */ + public static readRole(data: RolesReadRoleData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/roles/{role_id}', + path: { + role_id: data.roleId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Role + * Update a role. Admin only. + * @param data The data for the request. + * @param data.roleId + * @param data.requestBody + * @returns RolePublic Successful Response + * @throws ApiError + */ + public static updateRole(data: RolesUpdateRoleData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/roles/{role_id}', + path: { + role_id: data.roleId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Role + * Delete a role. Admin only. + * @param data The data for the request. + * @param data.roleId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteRole(data: RolesDeleteRoleData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/roles/{role_id}', + path: { + role_id: data.roleId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Assign Role To User + * Assign a role to a user. Admin only. + * @param data The data for the request. + * @param data.roleId + * @param data.userId + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static assignRoleToUser(data: RolesAssignRoleToUserData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/roles/{role_id}/assign/{user_id}', + path: { + role_id: data.roleId, + user_id: data.userId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Remove Role From User + * Remove a role from a user. Admin only. + * @param data The data for the request. + * @param data.roleId + * @param data.userId + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static removeRoleFromUser(data: RolesRemoveRoleFromUserData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/roles/{role_id}/remove/{user_id}', + path: { + role_id: data.roleId, + user_id: data.userId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class TagsService { + /** + * List Tags + * List all available race tags. Public endpoint. Cached 10 minutes. + * @returns TagsPublic Successful Response + * @throws ApiError + */ + public static listTags(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/tags/' + }); + } + + /** + * Create Tag + * Create a new tag. Admin only. + * @param data The data for the request. + * @param data.requestBody + * @returns TagPublic Successful Response + * @throws ApiError + */ + public static createTag(data: TagsCreateTagData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/tags/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Set Tags For Race + * Replace the full tag list on a race. Organizer or admin only. + * @param data The data for the request. + * @param data.raceId + * @param data.requestBody + * @returns TagPublic Successful Response + * @throws ApiError + */ + public static setTagsForRace(data: TagsSetTagsForRaceData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/tags/{race_id}/tags', + path: { + race_id: data.raceId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Tag Translations + * Update translations for a tag. + * Admin only. + * @param data The data for the request. + * @param data.tagId + * @param data.requestBody + * @returns TagPublic Successful Response + * @throws ApiError + */ + public static updateTagTranslations(data: TagsUpdateTagTranslationsData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/tags/{tag_id}/translations', + path: { + tag_id: data.tagId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get Tag Translations + * Get all translations for a tag. + * Public endpoint - anyone can view translations. + * @param data The data for the request. + * @param data.tagId + * @returns unknown Successful Response + * @throws ApiError + */ + public static getTagTranslations(data: TagsGetTagTranslationsData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/tags/{tag_id}/translations', + path: { + tag_id: data.tagId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + export class UsersService { /** * Read Users diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 91b5ba34c2..71ad151784 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1,5 +1,37 @@ // This file is auto-generated by @hey-api/openapi-ts +export type AdministrativeRegionPublic = { + id: number; + name: string; + name_en: string; + code_name?: (string | null); + code_name_en?: (string | null); +}; + +export type AdministrativeUnitPublic = { + id: number; + full_name?: (string | null); + full_name_en?: (string | null); + short_name?: (string | null); + short_name_en?: (string | null); + code_name?: (string | null); + code_name_en?: (string | null); +}; + +export type AIRaceSuggestion = { + description?: (string | null); + location?: (string | null); + terrain_type?: (string | null); + difficulty_level?: (string | null); + elevation_gain_m?: (string | null); +}; + +export type AskRequest = { + question: string; +}; + +export type AttributeTypeEnum = 'string' | 'text' | 'url' | 'date' | 'datetime' | 'number' | 'boolean' | 'email' | 'phone'; + export type Body_login_login_access_token = { grant_type?: (string | null); username: string; @@ -9,10 +41,42 @@ export type Body_login_login_access_token = { client_secret?: (string | null); }; +export type Body_media_upload_media_asset = { + file: string; + content_type: string; + content_id: string; + kind?: string; + alt_text?: (string | null); + display_order?: number; + is_primary?: boolean; + is_public?: boolean; +}; + +/** + * Update translations for a race category + */ +export type CategoryTranslationUpdate = { + language: string; + name?: (string | null); + description?: (string | null); +}; + +export type DescriptionSuggestion = { + description: string; +}; + +export type DifficultyEnum = 'easy' | 'moderate' | 'hard' | 'extreme'; + +export type DistancePrefEnum = 'short' | 'mid' | 'long' | 'ultra'; + +export type FitnessEnum = 'beginner' | 'intermediate' | 'advanced' | 'elite'; + export type HTTPValidationError = { detail?: Array; }; +export type InteractionTypeEnum = 'viewed' | 'saved' | 'unsaved' | 'registered' | 'shared'; + export type ItemCreate = { title: string; description?: (string | null); @@ -36,6 +100,38 @@ export type ItemUpdate = { description?: (string | null); }; +export type MediaAssetPublic = { + content_type: string; + content_id: string; + kind?: string; + alt_text?: (string | null); + display_order?: number; + is_primary?: boolean; + is_public?: boolean; + id: string; + original_filename: string; + file_name: string; + file_url: string; + mime_type: string; + size_bytes: number; + uploaded_by_id?: (string | null); + created_at: string; + updated_at: string; +}; + +export type MediaAssetsPublic = { + data: Array; + count: number; +}; + +export type MediaAssetUpdate = { + kind?: (string | null); + alt_text?: (string | null); + display_order?: (number | null); + is_primary?: (boolean | null); + is_public?: (boolean | null); +}; + export type Message = { message: string; }; @@ -45,6 +141,8 @@ export type NewPassword = { new_password: string; }; +export type PaymentStatusEnum = 'unpaid' | 'paid' | 'refunded' | 'partial'; + export type PrivateUserCreate = { email: string; password: string; @@ -52,6 +150,645 @@ export type PrivateUserCreate = { is_verified?: boolean; }; +export type ProvincePublic = { + code: string; + name: string; + name_en?: (string | null); + full_name: string; + full_name_en?: (string | null); + code_name?: (string | null); + administrative_unit_id?: (number | null); +}; + +export type ProvincePublicWithDetails = { + code: string; + name: string; + name_en?: (string | null); + full_name: string; + full_name_en?: (string | null); + code_name?: (string | null); + administrative_unit_id?: (number | null); + administrative_unit?: (AdministrativeUnitPublic | null); +}; + +export type ProvincesPublic = { + data: Array; + count: number; +}; + +export type RaceAnswer = { + answer: string; +}; + +export type RaceAttributeCreate = { + key: string; + value_text?: (string | null); + attribute_type?: AttributeTypeEnum; + label?: (string | null); + description?: (string | null); + is_required?: boolean; + is_public?: boolean; + display_order?: number; + race_id: string; +}; + +export type RaceAttributePublic = { + key: string; + value_text?: (string | null); + attribute_type?: AttributeTypeEnum; + label?: (string | null); + description?: (string | null); + is_required?: boolean; + is_public?: boolean; + display_order?: number; + id: string; + race_id: string; + created_at: string; + updated_at: string; +}; + +export type RaceAttributesPublic = { + data: Array; + count: number; +}; + +export type RaceAttributeUpdate = { + value_text?: (string | null); + attribute_type?: (AttributeTypeEnum | null); + label?: (string | null); + description?: (string | null); + is_required?: (boolean | null); + is_public?: (boolean | null); + display_order?: (number | null); +}; + +export type RaceCategoriesPublic = { + data: Array; + count: number; +}; + +export type RaceCategoryCreate = { + name: string; + distance_km: number; + distance_unit?: string; + start_time?: (string | null); + end_time?: (string | null); + cutoff_time_minutes?: (number | null); + registration_start?: (string | null); + registration_end?: (string | null); + price?: (number | null); + early_bird_price?: (number | null); + early_bird_deadline?: (string | null); + max_participants?: (number | null); + min_age?: (number | null); + max_age?: (number | null); + gender_restriction?: (string | null); + description?: (string | null); + display_order?: number; + is_active?: boolean; + translations?: ({ + [key: string]: unknown; +} | null); + race_id: string; +}; + +export type RaceCategoryPublic = { + name: string; + distance_km: number; + distance_unit?: string; + start_time?: (string | null); + end_time?: (string | null); + cutoff_time_minutes?: (number | null); + registration_start?: (string | null); + registration_end?: (string | null); + price?: (number | null); + early_bird_price?: (number | null); + early_bird_deadline?: (string | null); + max_participants?: (number | null); + min_age?: (number | null); + max_age?: (number | null); + gender_restriction?: (string | null); + description?: (string | null); + display_order?: number; + is_active?: boolean; + translations?: ({ + [key: string]: unknown; +} | null); + id: string; + race_id: string; + created_at: string; + updated_at: string; +}; + +export type RaceCategoryPublicWithDetails = { + name: string; + distance_km: number; + distance_unit?: string; + start_time?: (string | null); + end_time?: (string | null); + cutoff_time_minutes?: (number | null); + registration_start?: (string | null); + registration_end?: (string | null); + price?: (number | null); + early_bird_price?: (number | null); + early_bird_deadline?: (string | null); + max_participants?: (number | null); + min_age?: (number | null); + max_age?: (number | null); + gender_restriction?: (string | null); + description?: (string | null); + display_order?: number; + is_active?: boolean; + translations?: ({ + [key: string]: unknown; +} | null); + id: string; + race_id: string; + created_at: string; + updated_at: string; + registration_count?: number; + available_spots?: (number | null); + is_registration_open?: boolean; + current_price?: (number | null); +}; + +export type RaceCategoryUpdate = { + name?: (string | null); + distance_km?: (number | null); + distance_unit?: (string | null); + start_time?: (string | null); + end_time?: (string | null); + cutoff_time_minutes?: (number | null); + registration_start?: (string | null); + registration_end?: (string | null); + price?: (number | null); + early_bird_price?: (number | null); + early_bird_deadline?: (string | null); + max_participants?: (number | null); + min_age?: (number | null); + max_age?: (number | null); + gender_restriction?: (string | null); + description?: (string | null); + display_order?: (number | null); + is_active?: (boolean | null); +}; + +export type RaceCreate = { + name: string; + description?: (string | null); + event_start_date: string; + event_end_date?: (string | null); + location: string; + city?: (string | null); + state?: (string | null); + country?: string; + province_code?: (string | null); + ward_code?: (string | null); + country_code?: (string | null); + province_name?: (string | null); + ward_name?: (string | null); + registration_start?: (string | null); + registration_end?: (string | null); + status?: RaceStatusEnum; + is_active?: boolean; + base_price?: (number | null); + currency?: string; + race_metadata?: ({ + [key: string]: unknown; +} | null); + latitude?: (number | null); + longitude?: (number | null); + terrain_type?: (TerrainEnum | null); + difficulty_level?: (DifficultyEnum | null); + elevation_gain_m?: (number | null); + is_certified?: boolean; + gpx_file_url?: (string | null); + website_url?: (string | null); + default_language?: string; + translations?: ({ + [key: string]: unknown; +} | null); +}; + +export type RaceNameInput = { + name: string; +}; + +export type RacePublic = { + name: string; + description?: (string | null); + event_start_date: string; + event_end_date?: (string | null); + location: string; + city?: (string | null); + state?: (string | null); + country?: string; + province_code?: (string | null); + ward_code?: (string | null); + country_code?: (string | null); + province_name?: (string | null); + ward_name?: (string | null); + registration_start?: (string | null); + registration_end?: (string | null); + status?: RaceStatusEnum; + is_active?: boolean; + base_price?: (number | null); + currency?: string; + race_metadata?: ({ + [key: string]: unknown; +} | null); + latitude?: (number | null); + longitude?: (number | null); + terrain_type?: (TerrainEnum | null); + difficulty_level?: (DifficultyEnum | null); + elevation_gain_m?: (number | null); + is_certified?: boolean; + gpx_file_url?: (string | null); + website_url?: (string | null); + default_language?: string; + translations?: ({ + [key: string]: unknown; +} | null); + id: string; + created_at: string; + updated_at: string; + organizer_id: string; +}; + +export type RacePublicWithDetails = { + name: string; + description?: (string | null); + event_start_date: string; + event_end_date?: (string | null); + location: string; + city?: (string | null); + state?: (string | null); + country?: string; + province_code?: (string | null); + ward_code?: (string | null); + country_code?: (string | null); + province_name?: (string | null); + ward_name?: (string | null); + registration_start?: (string | null); + registration_end?: (string | null); + status?: RaceStatusEnum; + is_active?: boolean; + base_price?: (number | null); + currency?: string; + race_metadata?: ({ + [key: string]: unknown; +} | null); + latitude?: (number | null); + longitude?: (number | null); + terrain_type?: (TerrainEnum | null); + difficulty_level?: (DifficultyEnum | null); + elevation_gain_m?: (number | null); + is_certified?: boolean; + gpx_file_url?: (string | null); + website_url?: (string | null); + default_language?: string; + translations?: ({ + [key: string]: unknown; +} | null); + id: string; + created_at: string; + updated_at: string; + organizer_id: string; + categories?: Array; + tags?: Array; + registration_count?: number; + province?: (ProvincePublic | null); + ward?: (WardPublic | null); +}; + +export type RacePublicWithDistance = { + name: string; + description?: (string | null); + event_start_date: string; + event_end_date?: (string | null); + location: string; + city?: (string | null); + state?: (string | null); + country?: string; + province_code?: (string | null); + ward_code?: (string | null); + country_code?: (string | null); + province_name?: (string | null); + ward_name?: (string | null); + registration_start?: (string | null); + registration_end?: (string | null); + status?: RaceStatusEnum; + is_active?: boolean; + base_price?: (number | null); + currency?: string; + race_metadata?: ({ + [key: string]: unknown; +} | null); + latitude?: (number | null); + longitude?: (number | null); + terrain_type?: (TerrainEnum | null); + difficulty_level?: (DifficultyEnum | null); + elevation_gain_m?: (number | null); + is_certified?: boolean; + gpx_file_url?: (string | null); + website_url?: (string | null); + default_language?: string; + translations?: ({ + [key: string]: unknown; +} | null); + id: string; + created_at: string; + updated_at: string; + organizer_id: string; + distance_km: number; +}; + +export type RacePublicWithExplanation = { + name: string; + description?: (string | null); + event_start_date: string; + event_end_date?: (string | null); + location: string; + city?: (string | null); + state?: (string | null); + country?: string; + province_code?: (string | null); + ward_code?: (string | null); + country_code?: (string | null); + province_name?: (string | null); + ward_name?: (string | null); + registration_start?: (string | null); + registration_end?: (string | null); + status?: RaceStatusEnum; + is_active?: boolean; + base_price?: (number | null); + currency?: string; + race_metadata?: ({ + [key: string]: unknown; +} | null); + latitude?: (number | null); + longitude?: (number | null); + terrain_type?: (TerrainEnum | null); + difficulty_level?: (DifficultyEnum | null); + elevation_gain_m?: (number | null); + is_certified?: boolean; + gpx_file_url?: (string | null); + website_url?: (string | null); + default_language?: string; + translations?: ({ + [key: string]: unknown; +} | null); + id: string; + created_at: string; + updated_at: string; + organizer_id: string; + ai_explanation?: (string | null); +}; + +export type RaceRegistrationCreate = { + bib_number?: (string | null); + emergency_contact?: (string | null); + emergency_phone?: (string | null); + tshirt_size?: (string | null); + special_requirements?: (string | null); + registration_status?: RegistrationStatusEnum; + payment_status?: PaymentStatusEnum; + amount_paid?: (number | null); + payment_reference?: (string | null); + registration_data?: ({ + [key: string]: unknown; +} | null); + race_id: string; + category_id: string; +}; + +export type RaceRegistrationPublic = { + bib_number?: (string | null); + emergency_contact?: (string | null); + emergency_phone?: (string | null); + tshirt_size?: (string | null); + special_requirements?: (string | null); + registration_status?: RegistrationStatusEnum; + payment_status?: PaymentStatusEnum; + amount_paid?: (number | null); + payment_reference?: (string | null); + registration_data?: ({ + [key: string]: unknown; +} | null); + id: string; + race_id: string; + category_id: string; + runner_id: string; + registered_at: string; + updated_at: string; +}; + +export type RaceRegistrationPublicWithDetails = { + bib_number?: (string | null); + emergency_contact?: (string | null); + emergency_phone?: (string | null); + tshirt_size?: (string | null); + special_requirements?: (string | null); + registration_status?: RegistrationStatusEnum; + payment_status?: PaymentStatusEnum; + amount_paid?: (number | null); + payment_reference?: (string | null); + registration_data?: ({ + [key: string]: unknown; +} | null); + id: string; + race_id: string; + category_id: string; + runner_id: string; + registered_at: string; + updated_at: string; + runner: UserPublic; + category: RaceCategoryPublic; +}; + +export type RaceRegistrationsPublic = { + data: Array; + count: number; +}; + +export type RaceRegistrationUpdate = { + bib_number?: (string | null); + emergency_contact?: (string | null); + emergency_phone?: (string | null); + tshirt_size?: (string | null); + special_requirements?: (string | null); + registration_status?: (RegistrationStatusEnum | null); + payment_status?: (PaymentStatusEnum | null); + amount_paid?: (number | null); + payment_reference?: (string | null); + registration_data?: ({ + [key: string]: unknown; +} | null); +}; + +export type RaceResultCreate = { + finish_time_seconds?: (number | null); + overall_position?: (number | null); + category_position?: (number | null); + gender_position?: (number | null); + status?: ResultStatusEnum; + pace_per_km_seconds?: (number | null); + notes?: (string | null); + registration_id: string; +}; + +export type RaceResultPublic = { + finish_time_seconds?: (number | null); + overall_position?: (number | null); + category_position?: (number | null); + gender_position?: (number | null); + status?: ResultStatusEnum; + pace_per_km_seconds?: (number | null); + notes?: (string | null); + id: string; + registration_id: string; + created_at: string; + updated_at: string; +}; + +export type RaceResultsPublic = { + data: Array; + count: number; +}; + +export type RaceResultUpdate = { + finish_time_seconds?: (number | null); + overall_position?: (number | null); + category_position?: (number | null); + gender_position?: (number | null); + status?: (ResultStatusEnum | null); + pace_per_km_seconds?: (number | null); + notes?: (string | null); +}; + +export type RacesPublic = { + data: Array; + count: number; +}; + +export type RacesPublicWithDistance = { + data: Array; + count: number; +}; + +export type RacesPublicWithExplanation = { + data: Array; + count: number; +}; + +export type RaceStatusEnum = 'draft' | 'published' | 'registration_open' | 'registration_closed' | 'completed' | 'cancelled'; + +export type RaceTagCreate = { + name: string; + slug: string; + translations?: ({ + [key: string]: unknown; +} | null); +}; + +/** + * Update translations for a race + */ +export type RaceTranslationUpdate = { + language: string; + name?: (string | null); + description?: (string | null); + location?: (string | null); +}; + +export type RaceUpdate = { + name?: (string | null); + description?: (string | null); + event_start_date?: (string | null); + event_end_date?: (string | null); + location?: (string | null); + city?: (string | null); + state?: (string | null); + country?: (string | null); + province_code?: (string | null); + ward_code?: (string | null); + country_code?: (string | null); + province_name?: (string | null); + ward_name?: (string | null); + registration_start?: (string | null); + registration_end?: (string | null); + status?: (RaceStatusEnum | null); + is_active?: (boolean | null); + base_price?: (number | null); + currency?: (string | null); + race_metadata?: ({ + [key: string]: unknown; +} | null); + latitude?: (number | null); + longitude?: (number | null); + terrain_type?: (TerrainEnum | null); + difficulty_level?: (DifficultyEnum | null); + elevation_gain_m?: (number | null); + is_certified?: (boolean | null); + gpx_file_url?: (string | null); + website_url?: (string | null); + tag_ids?: (Array<(string)> | null); +}; + +export type RegistrationStatusEnum = 'pending' | 'confirmed' | 'cancelled' | 'waitlist'; + +export type ResultStatusEnum = 'finished' | 'dnf' | 'dns' | 'dq'; + +export type RoleCreate = { + name: string; + description?: (string | null); +}; + +export type RolePublic = { + name: string; + description?: (string | null); + id: string; + created_at?: (string | null); +}; + +export type RolesPublic = { + data: Array; + count: number; +}; + +export type RoleUpdate = { + name?: (string | null); + description?: (string | null); +}; + +export type TagPublic = { + name: string; + slug: string; + translations?: ({ + [key: string]: unknown; +} | null); + id: string; +}; + +export type TagsPublic = { + data: Array; + count: number; +}; + +export type TagSuggestion = { + tags: Array<(string)>; +}; + +/** + * Update translations for a tag + */ +export type TagTranslationUpdate = { + language: string; + name?: (string | null); +}; + +export type TerrainEnum = 'road' | 'trail' | 'track' | 'mixed'; + export type Token = { access_token: string; token_type?: string; @@ -70,6 +807,49 @@ export type UserCreate = { password: string; }; +export type UserProfileCreate = { + fitness_level?: (FitnessEnum | null); + distance_preference?: (DistancePrefEnum | null); + terrain_preference?: (TerrainEnum | null); + home_latitude?: (number | null); + home_longitude?: (number | null); + home_city?: (string | null); + weekly_mileage_km?: (number | null); + goal_race_date?: (string | null); + bio?: (string | null); + is_onboarded?: boolean; +}; + +export type UserProfilePublic = { + fitness_level?: (FitnessEnum | null); + distance_preference?: (DistancePrefEnum | null); + terrain_preference?: (TerrainEnum | null); + home_latitude?: (number | null); + home_longitude?: (number | null); + home_city?: (string | null); + weekly_mileage_km?: (number | null); + goal_race_date?: (string | null); + bio?: (string | null); + is_onboarded?: boolean; + id: string; + user_id: string; + created_at: string; + updated_at: string; +}; + +export type UserProfileUpdate = { + fitness_level?: (FitnessEnum | null); + distance_preference?: (DistancePrefEnum | null); + terrain_preference?: (TerrainEnum | null); + home_latitude?: (number | null); + home_longitude?: (number | null); + home_city?: (string | null); + weekly_mileage_km?: (number | null); + goal_race_date?: (string | null); + bio?: (string | null); + is_onboarded?: (boolean | null); +}; + export type UserPublic = { email: string; is_active?: boolean; @@ -77,6 +857,15 @@ export type UserPublic = { full_name?: (string | null); id: string; created_at?: (string | null); + roles?: Array; +}; + +export type UserRaceInteractionPublic = { + id: string; + user_id: string; + race_id: string; + action: InteractionTypeEnum; + created_at: string; }; export type UserRegister = { @@ -113,6 +902,40 @@ export type ValidationError = { }; }; +export type WardPublic = { + code: string; + name: string; + name_en?: (string | null); + full_name?: (string | null); + full_name_en?: (string | null); + code_name?: (string | null); + province_code?: (string | null); + administrative_unit_id?: (number | null); +}; + +export type WardPublicWithDetails = { + code: string; + name: string; + name_en?: (string | null); + full_name?: (string | null); + full_name_en?: (string | null); + code_name?: (string | null); + province_code?: (string | null); + administrative_unit_id?: (number | null); + administrative_unit?: (AdministrativeUnitPublic | null); +}; + +export type WardsPublic = { + data: Array; + count: number; +}; + +export type AdminReindexRaceEmbeddingsData = { + batchSize?: number; +}; + +export type AdminReindexRaceEmbeddingsResponse = (Message); + export type ItemsReadItemsData = { limit?: number; skip?: number; @@ -171,12 +994,491 @@ export type LoginRecoverPasswordHtmlContentData = { export type LoginRecoverPasswordHtmlContentResponse = (string); +export type MediaReadMediaAssetsData = { + contentId?: (string | null); + contentType?: (string | null); + isPublic?: boolean; + kind?: (string | null); + limit?: number; + skip?: number; +}; + +export type MediaReadMediaAssetsResponse = (MediaAssetsPublic); + +export type MediaUploadMediaAssetData = { + formData: Body_media_upload_media_asset; +}; + +export type MediaUploadMediaAssetResponse = (MediaAssetPublic); + +export type MediaReadMediaFileData = { + mediaId: string; +}; + +export type MediaReadMediaFileResponse = (unknown); + +export type MediaUpdateMediaAssetData = { + mediaId: string; + requestBody: MediaAssetUpdate; +}; + +export type MediaUpdateMediaAssetResponse = (MediaAssetPublic); + +export type MediaDeleteMediaAssetData = { + mediaId: string; +}; + +export type MediaDeleteMediaAssetResponse = (Message); + export type PrivateCreateUserData = { requestBody: PrivateUserCreate; }; export type PrivateCreateUserResponse = (UserPublic); +export type ProfilesGetMyProfileResponse = (UserProfilePublic); + +export type ProfilesUpsertMyProfileData = { + requestBody: UserProfileCreate; +}; + +export type ProfilesUpsertMyProfileResponse = (UserProfilePublic); + +export type ProfilesDeleteMyProfileResponse = (unknown); + +export type ProfilesUpdateMyProfileData = { + requestBody: UserProfileUpdate; +}; + +export type ProfilesUpdateMyProfileResponse = (UserProfilePublic); + +export type ProfilesGetMySavedRacesResponse = (RacesPublic); + +export type ProfilesSaveRaceData = { + raceId: string; +}; + +export type ProfilesSaveRaceResponse = (UserRaceInteractionPublic); + +export type ProfilesUnsaveRaceData = { + raceId: string; +}; + +export type ProfilesUnsaveRaceResponse = (unknown); + +export type ProfilesTrackRaceViewData = { + raceId: string; +}; + +export type ProfilesTrackRaceViewResponse = (UserRaceInteractionPublic); + +export type ProvincesReadAdministrativeRegionsResponse = (Array); + +export type ProvincesReadAdministrativeUnitsResponse = (Array); + +export type ProvincesReadProvincesData = { + limit?: number; + skip?: number; +}; + +export type ProvincesReadProvincesResponse = (ProvincesPublic); + +export type ProvincesReadProvinceData = { + provinceCode: string; +}; + +export type ProvincesReadProvinceResponse = (ProvincePublicWithDetails); + +export type ProvincesReadWardsByProvinceData = { + limit?: number; + provinceCode: string; + skip?: number; +}; + +export type ProvincesReadWardsByProvinceResponse = (WardsPublic); + +export type ProvincesReadWardData = { + wardCode: string; +}; + +export type ProvincesReadWardResponse = (WardPublicWithDetails); + +export type RaceAttributesReadRaceAttributesData = { + isPublic?: (boolean | null); + raceId: string; +}; + +export type RaceAttributesReadRaceAttributesResponse = (RaceAttributesPublic); + +export type RaceAttributesCreateRaceAttributeData = { + requestBody: RaceAttributeCreate; +}; + +export type RaceAttributesCreateRaceAttributeResponse = (RaceAttributePublic); + +export type RaceAttributesReadRaceAttributeData = { + attributeId: string; +}; + +export type RaceAttributesReadRaceAttributeResponse = (RaceAttributePublic); + +export type RaceAttributesUpdateRaceAttributeData = { + attributeId: string; + requestBody: RaceAttributeUpdate; +}; + +export type RaceAttributesUpdateRaceAttributeResponse = (RaceAttributePublic); + +export type RaceAttributesDeleteRaceAttributeData = { + attributeId: string; +}; + +export type RaceAttributesDeleteRaceAttributeResponse = (Message); + +export type RaceCategoriesReadRaceCategoriesData = { + limit?: number; + raceId: string; + skip?: number; +}; + +export type RaceCategoriesReadRaceCategoriesResponse = (RaceCategoriesPublic); + +export type RaceCategoriesCreateRaceCategoryData = { + requestBody: RaceCategoryCreate; +}; + +export type RaceCategoriesCreateRaceCategoryResponse = (RaceCategoryPublic); + +export type RaceCategoriesReadRaceCategoryData = { + categoryId: string; +}; + +export type RaceCategoriesReadRaceCategoryResponse = (RaceCategoryPublicWithDetails); + +export type RaceCategoriesUpdateRaceCategoryData = { + categoryId: string; + requestBody: RaceCategoryUpdate; +}; + +export type RaceCategoriesUpdateRaceCategoryResponse = (RaceCategoryPublic); + +export type RaceCategoriesDeleteRaceCategoryData = { + categoryId: string; +}; + +export type RaceCategoriesDeleteRaceCategoryResponse = (Message); + +export type RaceCategoriesUpdateCategoryTranslationsData = { + categoryId: string; + requestBody: CategoryTranslationUpdate; +}; + +export type RaceCategoriesUpdateCategoryTranslationsResponse = (RaceCategoryPublic); + +export type RaceCategoriesGetCategoryTranslationsData = { + categoryId: string; +}; + +export type RaceCategoriesGetCategoryTranslationsResponse = ({ + [key: string]: unknown; +}); + +export type RaceRegistrationsReadRaceRegistrationsData = { + categoryId?: (string | null); + limit?: number; + raceId?: (string | null); + skip?: number; +}; + +export type RaceRegistrationsReadRaceRegistrationsResponse = (RaceRegistrationsPublic); + +export type RaceRegistrationsCreateRaceRegistrationData = { + requestBody: RaceRegistrationCreate; +}; + +export type RaceRegistrationsCreateRaceRegistrationResponse = (RaceRegistrationPublic); + +export type RaceRegistrationsReadMyRegistrationsData = { + limit?: number; + skip?: number; +}; + +export type RaceRegistrationsReadMyRegistrationsResponse = (RaceRegistrationsPublic); + +export type RaceRegistrationsReadRaceRegistrationData = { + registrationId: string; +}; + +export type RaceRegistrationsReadRaceRegistrationResponse = (RaceRegistrationPublicWithDetails); + +export type RaceRegistrationsUpdateRaceRegistrationData = { + registrationId: string; + requestBody: RaceRegistrationUpdate; +}; + +export type RaceRegistrationsUpdateRaceRegistrationResponse = (RaceRegistrationPublic); + +export type RaceRegistrationsDeleteRaceRegistrationData = { + registrationId: string; +}; + +export type RaceRegistrationsDeleteRaceRegistrationResponse = (Message); + +export type RaceResultsReadRaceResultsData = { + limit?: number; + raceId: string; + skip?: number; +}; + +export type RaceResultsReadRaceResultsResponse = (RaceResultsPublic); + +export type RaceResultsCreateRaceResultData = { + requestBody: RaceResultCreate; +}; + +export type RaceResultsCreateRaceResultResponse = (RaceResultPublic); + +export type RaceResultsReadRaceResultData = { + resultId: string; +}; + +export type RaceResultsReadRaceResultResponse = (RaceResultPublic); + +export type RaceResultsUpdateRaceResultData = { + requestBody: RaceResultUpdate; + resultId: string; +}; + +export type RaceResultsUpdateRaceResultResponse = (RaceResultPublic); + +export type RaceResultsDeleteRaceResultData = { + resultId: string; +}; + +export type RaceResultsDeleteRaceResultResponse = (Message); + +export type RaceResultsReadRaceResultByRegistrationData = { + registrationId: string; +}; + +export type RaceResultsReadRaceResultByRegistrationResponse = (RaceResultPublic); + +export type RacesSearchRacesData = { + dateFrom?: (string | null); + dateTo?: (string | null); + difficulty?: (DifficultyEnum | null); + distanceMaxKm?: (number | null); + distanceMinKm?: (number | null); + lat?: (number | null); + limit?: number; + lon?: (number | null); + /** + * Filter by province code + */ + provinceCode?: (string | null); + /** + * Full-text search query + */ + q?: (string | null); + radiusKm?: (number | null); + skip?: number; + sort?: string; + status?: (RaceStatusEnum | null); + tagSlugs?: (Array<(string)> | null); + terrain?: (TerrainEnum | null); + /** + * Filter by ward code + */ + wardCode?: (string | null); +}; + +export type RacesSearchRacesResponse = (RacesPublic); + +export type RacesGetNearbyRacesData = { + lat: number; + limit?: number; + lon: number; + radiusKm?: number; +}; + +export type RacesGetNearbyRacesResponse = (RacesPublicWithDistance); + +export type RacesGetTrendingRacesData = { + days?: number; + limit?: number; +}; + +export type RacesGetTrendingRacesResponse = (RacesPublic); + +export type RacesGetRecommendedRacesData = { + limit?: number; +}; + +export type RacesGetRecommendedRacesResponse = (RacesPublicWithExplanation); + +export type RacesReadMyOrganizedRacesData = { + limit?: number; + skip?: number; +}; + +export type RacesReadMyOrganizedRacesResponse = (RacesPublic); + +export type RacesReadRacesData = { + limit?: number; + organizerId?: (string | null); + skip?: number; +}; + +export type RacesReadRacesResponse = (RacesPublic); + +export type RacesCreateRaceData = { + requestBody: RaceCreate; +}; + +export type RacesCreateRaceResponse = (RacePublic); + +export type RacesGenerateRaceDetailsData = { + requestBody: RaceNameInput; +}; + +export type RacesGenerateRaceDetailsResponse = (AIRaceSuggestion); + +export type RacesReadRaceData = { + raceId: string; +}; + +export type RacesReadRaceResponse = (RacePublicWithDetails); + +export type RacesUpdateRaceData = { + raceId: string; + requestBody: RaceUpdate; +}; + +export type RacesUpdateRaceResponse = (RacePublic); + +export type RacesDeleteRaceData = { + raceId: string; +}; + +export type RacesDeleteRaceResponse = (Message); + +export type RacesGetSimilarRacesData = { + limit?: number; + raceId: string; +}; + +export type RacesGetSimilarRacesResponse = (RacesPublic); + +export type RacesAutoTagRaceData = { + raceId: string; +}; + +export type RacesAutoTagRaceResponse = (TagSuggestion); + +export type RacesEnhanceRaceDescriptionData = { + raceId: string; +}; + +export type RacesEnhanceRaceDescriptionResponse = (DescriptionSuggestion); + +export type RacesAskRaceQuestionData = { + raceId: string; + requestBody: AskRequest; +}; + +export type RacesAskRaceQuestionResponse = (RaceAnswer); + +export type RacesUpdateRaceTranslationsData = { + raceId: string; + requestBody: RaceTranslationUpdate; +}; + +export type RacesUpdateRaceTranslationsResponse = (RacePublic); + +export type RacesGetRaceTranslationsData = { + raceId: string; +}; + +export type RacesGetRaceTranslationsResponse = ({ + [key: string]: unknown; +}); + +export type RolesReadRolesData = { + limit?: number; + skip?: number; +}; + +export type RolesReadRolesResponse = (RolesPublic); + +export type RolesCreateRoleData = { + requestBody: RoleCreate; +}; + +export type RolesCreateRoleResponse = (RolePublic); + +export type RolesReadRoleData = { + roleId: string; +}; + +export type RolesReadRoleResponse = (RolePublic); + +export type RolesUpdateRoleData = { + requestBody: RoleUpdate; + roleId: string; +}; + +export type RolesUpdateRoleResponse = (RolePublic); + +export type RolesDeleteRoleData = { + roleId: string; +}; + +export type RolesDeleteRoleResponse = (Message); + +export type RolesAssignRoleToUserData = { + roleId: string; + userId: string; +}; + +export type RolesAssignRoleToUserResponse = (UserPublic); + +export type RolesRemoveRoleFromUserData = { + roleId: string; + userId: string; +}; + +export type RolesRemoveRoleFromUserResponse = (UserPublic); + +export type TagsListTagsResponse = (TagsPublic); + +export type TagsCreateTagData = { + requestBody: RaceTagCreate; +}; + +export type TagsCreateTagResponse = (TagPublic); + +export type TagsSetTagsForRaceData = { + raceId: string; + requestBody: Array<(string)>; +}; + +export type TagsSetTagsForRaceResponse = (Array); + +export type TagsUpdateTagTranslationsData = { + requestBody: TagTranslationUpdate; + tagId: string; +}; + +export type TagsUpdateTagTranslationsResponse = (TagPublic); + +export type TagsGetTagTranslationsData = { + tagId: string; +}; + +export type TagsGetTagTranslationsResponse = ({ + [key: string]: unknown; +}); + export type UsersReadUsersData = { limit?: number; skip?: number; diff --git a/frontend/src/components/Admin/CategoryTranslationManager.tsx b/frontend/src/components/Admin/CategoryTranslationManager.tsx new file mode 100644 index 0000000000..1af5b3fe0f --- /dev/null +++ b/frontend/src/components/Admin/CategoryTranslationManager.tsx @@ -0,0 +1,103 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { RaceCategoriesService } from "@/client" +import type { RaceCategoryPublic, CategoryTranslationUpdate } from "@/client" +import { TranslationEditor, type AllTranslations, type TranslationField } from "./TranslationEditor" +import useCustomToast from "@/hooks/useCustomToast" +import { Skeleton } from "@/components/ui/skeleton" + +interface CategoryTranslationManagerProps { + categoryId: string + category: RaceCategoryPublic +} + +const CATEGORY_TRANSLATION_FIELDS: TranslationField[] = [ + { + name: "name", + label: "Category Name", + type: "input", + maxLength: 100, + required: true, + }, + { + name: "description", + label: "Description", + type: "textarea", + }, +] + +export function CategoryTranslationManager({ categoryId, category }: CategoryTranslationManagerProps) { + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + + // Fetch current translations + const { data: translations, isLoading } = useQuery({ + queryKey: ["category-translations", categoryId], + queryFn: () => RaceCategoriesService.getCategoryTranslations({ categoryId }), + }) + + const updateTranslationMutation = useMutation({ + mutationFn: async ({ language, data }: { language: string; data: Partial }) => { + return RaceCategoriesService.updateCategoryTranslations({ + categoryId, + requestBody: { + language, + name: data.name, + description: data.description, + }, + }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["category-translations", categoryId] }) + queryClient.invalidateQueries({ queryKey: ["race-categories"] }) + showSuccessToast("Category translations updated successfully") + }, + onError: (error: any) => { + const detail = error.body?.detail || "Failed to update category translations" + showErrorToast(detail) + }, + }) + + const handleSave = async (allTranslations: AllTranslations) => { + // Save each language separately + const languages = Object.keys(allTranslations) + + for (const language of languages) { + const data = allTranslations[language] + if (data && (data.name || data.description)) { + await updateTranslationMutation.mutateAsync({ language, data }) + } + } + } + + if (isLoading) { + return ( +
+ + +
+ ) + } + + // Build initial translations + const initialTranslations: AllTranslations = (translations as AllTranslations) || {} + + // Ensure default language has values from the category object + if (!initialTranslations["vi"]) { + initialTranslations["vi"] = { + name: category.name, + description: category.description || "", + } + } + + return ( + + ) +} diff --git a/frontend/src/components/Admin/RaceTranslationManager.tsx b/frontend/src/components/Admin/RaceTranslationManager.tsx new file mode 100644 index 0000000000..c5d4a3fa0c --- /dev/null +++ b/frontend/src/components/Admin/RaceTranslationManager.tsx @@ -0,0 +1,112 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { RacesService } from "@/client" +import type { RacePublic, RaceTranslationUpdate } from "@/client" +import { TranslationEditor, type AllTranslations, type TranslationField } from "./TranslationEditor" +import useCustomToast from "@/hooks/useCustomToast" +import { Skeleton } from "@/components/ui/skeleton" + +interface RaceTranslationManagerProps { + raceId: string + race: RacePublic +} + +const RACE_TRANSLATION_FIELDS: TranslationField[] = [ + { + name: "name", + label: "Race Name", + type: "input", + maxLength: 255, + required: true, + }, + { + name: "description", + label: "Description", + type: "textarea", + maxLength: 2000, + }, + { + name: "location", + label: "Location", + type: "input", + maxLength: 255, + }, +] + +export function RaceTranslationManager({ raceId, race }: RaceTranslationManagerProps) { + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + + // Fetch current translations + const { data: translations, isLoading } = useQuery({ + queryKey: ["race-translations", raceId], + queryFn: () => RacesService.getRaceTranslations({ raceId }), + }) + + const updateTranslationMutation = useMutation({ + mutationFn: async ({ language, data }: { language: string; data: Partial }) => { + return RacesService.updateRaceTranslations({ + raceId, + requestBody: { + language, + name: data.name, + description: data.description, + location: data.location, + }, + }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["race-translations", raceId] }) + queryClient.invalidateQueries({ queryKey: ["races", raceId] }) + showSuccessToast("Translations updated successfully") + }, + onError: (error: any) => { + const detail = error.body?.detail || "Failed to update translations" + showErrorToast(detail) + }, + }) + + const handleSave = async (allTranslations: AllTranslations) => { + // Save each language separately + const languages = Object.keys(allTranslations) + + for (const language of languages) { + const data = allTranslations[language] + if (data && (data.name || data.description || data.location)) { + await updateTranslationMutation.mutateAsync({ language, data }) + } + } + } + + if (isLoading) { + return ( +
+ + +
+ ) + } + + // Build initial translations from race data and fetched translations + const initialTranslations: AllTranslations = (translations as AllTranslations) || {} + + // Ensure default language has values from the race object + if (!initialTranslations[race.default_language || "vi"]) { + initialTranslations[race.default_language || "vi"] = { + name: race.name, + description: race.description || "", + location: race.location || "", + } + } + + return ( + + ) +} diff --git a/frontend/src/components/Admin/TagTranslationManager.tsx b/frontend/src/components/Admin/TagTranslationManager.tsx new file mode 100644 index 0000000000..5d8a664a0e --- /dev/null +++ b/frontend/src/components/Admin/TagTranslationManager.tsx @@ -0,0 +1,96 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { TagsService } from "@/client" +import type { TagPublic, TagTranslationUpdate } from "@/client" +import { TranslationEditor, type AllTranslations, type TranslationField } from "./TranslationEditor" +import useCustomToast from "@/hooks/useCustomToast" +import { Skeleton } from "@/components/ui/skeleton" + +interface TagTranslationManagerProps { + tagId: string + tag: TagPublic +} + +const TAG_TRANSLATION_FIELDS: TranslationField[] = [ + { + name: "name", + label: "Tag Name", + type: "input", + maxLength: 50, + required: true, + }, +] + +export function TagTranslationManager({ tagId, tag }: TagTranslationManagerProps) { + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + + // Fetch current translations + const { data: translations, isLoading } = useQuery({ + queryKey: ["tag-translations", tagId], + queryFn: () => TagsService.getTagTranslations({ tagId }), + }) + + const updateTranslationMutation = useMutation({ + mutationFn: async ({ language, data }: { language: string; data: Partial }) => { + return TagsService.updateTagTranslations({ + tagId, + requestBody: { + language, + name: data.name, + }, + }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tag-translations", tagId] }) + queryClient.invalidateQueries({ queryKey: ["tags"] }) + showSuccessToast("Tag translations updated successfully") + }, + onError: (error: any) => { + const detail = error.body?.detail || "Failed to update tag translations" + showErrorToast(detail) + }, + }) + + const handleSave = async (allTranslations: AllTranslations) => { + // Save each language separately + const languages = Object.keys(allTranslations) + + for (const language of languages) { + const data = allTranslations[language] + if (data && data.name) { + await updateTranslationMutation.mutateAsync({ language, data }) + } + } + } + + if (isLoading) { + return ( +
+ + +
+ ) + } + + // Build initial translations + const initialTranslations: AllTranslations = (translations as AllTranslations) || {} + + // Ensure default language has values from the tag object + if (!initialTranslations["vi"]) { + initialTranslations["vi"] = { + name: tag.name, + } + } + + return ( + + ) +} diff --git a/frontend/src/components/Admin/TranslationEditor.tsx b/frontend/src/components/Admin/TranslationEditor.tsx new file mode 100644 index 0000000000..2980e63135 --- /dev/null +++ b/frontend/src/components/Admin/TranslationEditor.tsx @@ -0,0 +1,167 @@ +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Globe, Save } from "lucide-react" + +export interface TranslationField { + name: string + label: string + type: "input" | "textarea" + maxLength?: number + required?: boolean +} + +export interface LanguageTranslations { + [key: string]: string +} + +export interface AllTranslations { + [language: string]: LanguageTranslations +} + +interface TranslationEditorProps { + fields: TranslationField[] + currentTranslations: AllTranslations + defaultLanguage?: string + supportedLanguages?: Array<{ code: string; name: string; nativeName: string }> + onSave: (translations: AllTranslations) => Promise + isSaving?: boolean + title?: string + description?: string +} + +const DEFAULT_LANGUAGES = [ + { code: "vi", name: "Vietnamese", nativeName: "Tiếng Việt" }, + { code: "en", name: "English", nativeName: "English" }, +] + +export function TranslationEditor({ + fields, + currentTranslations, + defaultLanguage = "vi", + supportedLanguages = DEFAULT_LANGUAGES, + onSave, + isSaving = false, + title = "Manage Translations", + description = "Edit content in multiple languages", +}: TranslationEditorProps) { + const { t } = useTranslation() + const [translations, setTranslations] = useState( + currentTranslations || {} + ) + const [activeLanguage, setActiveLanguage] = useState(defaultLanguage) + + const handleFieldChange = ( + language: string, + fieldName: string, + value: string + ) => { + setTranslations((prev) => ({ + ...prev, + [language]: { + ...(prev[language] || {}), + [fieldName]: value, + }, + })) + } + + const handleSave = async () => { + await onSave(translations) + } + + const getFieldValue = (language: string, fieldName: string): string => { + return translations[language]?.[fieldName] || "" + } + + return ( + + +
+
+ + + {title} + + {description} +
+ +
+
+ + + + {supportedLanguages.map((lang) => ( + + {lang.nativeName} + + ))} + + {supportedLanguages.map((lang) => ( + +
+ {lang.code === defaultLanguage ? ( +

+ Default language: This is the primary language for this content. +

+ ) : ( +

+ Translation to {lang.name}: Translate the content from the default language. +

+ )} +
+ {fields.map((field) => ( +
+ + {field.type === "input" ? ( + + handleFieldChange(lang.code, field.name, e.target.value) + } + maxLength={field.maxLength} + placeholder={`Enter ${field.label.toLowerCase()} in ${lang.name}`} + /> + ) : ( +