diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..a163efa49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# Claude Code +.claude/ + +# Old folder — contents moved to client/ +/book-my-venue/ + +# Mock server — not pushed to repo +mock-server/ + +# Node +node_modules/ +npm-debug.log +yarn-error.log + +# Angular +dist/ +.angular/cache/ +/tmp +/out-tsc + +# IDEs +.idea/ +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/mcp.json +*.sublime-workspace + +# System +.DS_Store +Thumbs.db + +# Misc +coverage/ +*.log + + +# ========================================== +# ☕ Spring Boot & Maven Base Defaults +# ========================================== +**/target/ +**.target/ +*.jar +*.war +*.ear +*.zip +*.tar.gz +*.rar + +# Maven wrapper files (Kept ignored to prevent build rule conflicts) +**/mvnw +**/mvnw.cmd +**/.mvn/wrapper/maven-wrapper.properties +**/.mvn/wrapper/maven-wrapper.jar + +# ========================================== +# 💻 IDE Configurations (IntelliJ / VS Code / Eclipse) +# ========================================== +**/.idea/ +**/*.iml +**/out/ +**/.vscode/ +**/.project +**/.classpath +**/.settings/ +**/.factorypath + +# ========================================== +# 🔒 Environment & OS files +# ========================================== +**/.env +**/.env.local +**/.DS_Store +**/Thumbs.db diff --git a/BACKEND_API_CONTRACT.md b/BACKEND_API_CONTRACT.md new file mode 100644 index 000000000..1ae484d75 --- /dev/null +++ b/BACKEND_API_CONTRACT.md @@ -0,0 +1,906 @@ +# Book My Venue - Backend API Contract + +> This document defines the complete API contract that the Angular frontend expects. All endpoints, request/response formats, headers, and enums are documented here. + +--- + +## Base Configuration + +- **Base URL (Dev):** `http://localhost:3000/api` +- **Base URL (Prod):** `https://api.bookmyvenue.com/api` +- **Content-Type:** `application/json` + +--- + +## Authentication + +### JWT Token Structure + +Tokens must be JWT and include a `role` field in the payload. + +```json +{ + "id": "user-id", + "email": "user@example.com", + "role": "user | vendor | admin", + "iat": 1234567890, + "exp": 1234567890 +} +``` + +### Auth Endpoints (Public - No Token Required) + +#### User Auth + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/auth/user/login` | User login | +| POST | `/auth/user/signup` | User registration | + +**Login Request:** +```json +{ + "email": "string (required)", + "password": "string (required)" +} +``` + +**Signup Request:** +```json +{ + "firstName": "string (required)", + "lastName": "string (required)", + "email": "string (required, valid email)", + "phone": "string (required, 10 digits)", + "password": "string (required, min 8 chars)" +} +``` + +#### Vendor Auth + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/auth/vendor/login` | Vendor login | +| POST | `/auth/vendor/signup` | Vendor registration | + +**Login Request:** +```json +{ + "email": "string (required)", + "password": "string (required)" +} +``` + +**Signup Request:** +```json +{ + "businessName": "string (required)", + "email": "string (required, valid email)", + "phone": "string (required, 10 digits)", + "password": "string (required, min 8 chars)" +} +``` + +#### Admin Auth + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/auth/admin/login` | Admin login (no signup - admins are seeded) | + +**Login Request:** +```json +{ + "email": "string (required)", + "password": "string (required)" +} +``` + +#### Common Auth + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/auth/logout` | Logout (invalidate token) | +| POST | `/auth/refresh-token` | Refresh access token | +| POST | `/auth/forgot-password` | Send password reset email | + +**Refresh Token Request:** +```json +{ + "refreshToken": "string (required)" +} +``` + +**Forgot Password Request:** +```json +{ + "email": "string (required)" +} +``` + +### Auth Response Format (Login & Signup) + +```json +{ + "token": "jwt-access-token", + "refreshToken": "refresh-token", + "user": { + "id": "string", + "email": "string", + "firstName": "string", + "lastName": "string", + "role": "user | vendor | admin" + } +} +``` + +--- + +## Headers on Protected Routes + +Every authenticated request from the frontend includes: + +``` +Authorization: Bearer +``` + +Additionally, portal-specific headers are sent: + +| Portal | Extra Header | +|--------|-------------| +| User | _(none)_ | +| Vendor | `X-Portal: vendor` | +| Admin | `X-Portal: admin` | + +**Backend must validate** that the JWT `role` matches the `X-Portal` header (where present) to prevent cross-portal access. + +--- + +## Standard Response Formats + +### Success - Single Item + +```json +{ + "data": { ... }, + "message": "Success" +} +``` + +### Success - Paginated List + +```json +{ + "data": [ ... ], + "total": 100, + "page": 1, + "limit": 10, + "totalPages": 10 +} +``` + +### Error + +```json +{ + "error": { + "message": "Human-readable error message", + "statusCode": 400 + } +} +``` + +### HTTP Status Codes Used + +| Code | Meaning | +|------|---------| +| 200 | Success | +| 201 | Created | +| 400 | Bad Request (validation errors) | +| 401 | Unauthorized (missing/expired token) | +| 403 | Forbidden (wrong role/portal) | +| 404 | Not Found | +| 422 | Unprocessable Entity | +| 500 | Internal Server Error | + +--- + +## Enums + +These values must be used consistently between frontend and backend. + +### UserRole + +| Value | Description | +|-------|-------------| +| `user` | Regular user | +| `vendor` | Venue vendor | +| `admin` | Platform admin | + +### BookingStatus + +| Value | Description | +|-------|-------------| +| `pending` | Awaiting vendor approval | +| `confirmed` | Approved by vendor | +| `cancelled` | Cancelled by user | +| `completed` | Event completed | +| `rejected` | Rejected by vendor | + +### VenueStatus + +| Value | Description | +|-------|-------------| +| `active` | Live and bookable | +| `inactive` | Hidden by vendor | +| `pending` | Awaiting admin approval | +| `rejected` | Rejected by admin | + +--- + +## API Endpoints by Portal + +--- + +### User Portal + +> All endpoints require `Authorization: Bearer ` with `role: user` + +#### Profile + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/users/profile` | Get current user's profile | +| PUT | `/users/profile` | Update profile | + +**GET `/users/profile` Response:** +```json +{ + "data": { + "id": "string", + "firstName": "string", + "lastName": "string", + "email": "string", + "phone": "string", + "role": "user", + "createdAt": "ISO 8601 date string", + "updatedAt": "ISO 8601 date string" + } +} +``` + +**PUT `/users/profile` Request:** +```json +{ + "firstName": "string (required)", + "lastName": "string (required)", + "phone": "string (required, 10 digits)" +} +``` + +#### Venues (Public Browsing) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/venues` | List all active venues (paginated, filterable) | +| GET | `/venues/:id` | Get venue details by ID | + +**GET `/venues` Query Parameters:** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `page` | number | 1 | Page number | +| `limit` | number | 10 | Items per page | +| `search` | string | — | Search by name or description | +| `city` | string | — | Filter by city | +| `minPrice` | number | — | Minimum price per hour | +| `maxPrice` | number | — | Maximum price per hour | +| `capacity` | number | — | Minimum guest capacity | + +**GET `/venues` Response:** +```json +{ + "data": [ + { + "id": "string", + "name": "string", + "description": "string", + "address": "string", + "city": "string", + "state": "string", + "zipCode": "string", + "capacity": "number", + "pricePerHour": "number", + "amenities": ["string"], + "images": ["string (URL)"], + "status": "active", + "vendorId": "string", + "vendorName": "string", + "rating": "number (optional)", + "createdAt": "ISO 8601", + "updatedAt": "ISO 8601" + } + ], + "total": 100, + "page": 1, + "limit": 10, + "totalPages": 10 +} +``` + +**GET `/venues/:id` Response:** +```json +{ + "data": { + "id": "string", + "name": "string", + "description": "string", + "address": "string", + "city": "string", + "state": "string", + "zipCode": "string", + "capacity": "number", + "pricePerHour": "number", + "amenities": ["string"], + "images": ["string (URL)"], + "status": "active", + "vendorId": "string", + "vendorName": "string", + "rating": "number (optional)", + "createdAt": "ISO 8601", + "updatedAt": "ISO 8601" + } +} +``` + +#### Bookings + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/bookings/user` | Get current user's bookings | +| GET | `/bookings/:id` | Get booking details | +| POST | `/bookings` | Create a new booking | +| PUT | `/bookings/:id/cancel` | Cancel a booking | + +**GET `/bookings/user` Response:** +```json +{ + "data": [ + { + "id": "string", + "venueId": "string", + "venueName": "string", + "userId": "string", + "eventDate": "YYYY-MM-DD", + "startTime": "HH:mm", + "endTime": "HH:mm", + "guestCount": "number", + "totalAmount": "number", + "notes": "string (optional)", + "status": "pending | confirmed | cancelled | completed | rejected", + "createdAt": "ISO 8601", + "updatedAt": "ISO 8601" + } + ], + "total": 10, + "page": 1, + "limit": 10, + "totalPages": 1 +} +``` + +**POST `/bookings` Request:** +```json +{ + "venueId": "string (required)", + "eventDate": "YYYY-MM-DD (required, must be future date)", + "startTime": "HH:mm (required)", + "endTime": "HH:mm (required)", + "guestCount": "number (required, min 1)", + "notes": "string (optional)" +} +``` + +**POST `/bookings` Response:** +```json +{ + "data": { + "id": "string", + "venueId": "string", + "venueName": "string", + "userId": "string", + "eventDate": "YYYY-MM-DD", + "startTime": "HH:mm", + "endTime": "HH:mm", + "guestCount": "number", + "totalAmount": "number (calculated: hours x pricePerHour)", + "notes": "string", + "status": "pending", + "createdAt": "ISO 8601" + }, + "message": "Booking created successfully" +} +``` + +--- + +### Vendor Portal + +> All endpoints require `Authorization: Bearer ` with `role: vendor` and `X-Portal: vendor` header + +#### Profile + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/vendors/profile` | Get vendor profile | +| PUT | `/vendors/profile` | Update vendor profile | + +**GET `/vendors/profile` Response:** +```json +{ + "data": { + "id": "string", + "businessName": "string", + "email": "string", + "phone": "string", + "role": "vendor", + "isVerified": "boolean", + "createdAt": "ISO 8601", + "updatedAt": "ISO 8601" + } +} +``` + +**PUT `/vendors/profile` Request:** +```json +{ + "businessName": "string (required)", + "phone": "string (required)" +} +``` + +#### Venue Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/vendors/venues` | Get vendor's own venues | +| POST | `/vendors/venues` | Create a new venue | +| PUT | `/vendors/venues/:id` | Update a venue | +| DELETE | `/vendors/venues/:id` | Delete a venue | + +**POST `/vendors/venues` Request:** +```json +{ + "name": "string (required)", + "description": "string (required)", + "address": "string (required)", + "city": "string (required)", + "state": "string (required)", + "zipCode": "string (required)", + "capacity": "number (required, min 1)", + "pricePerHour": "number (required, min 0)", + "amenities": ["string (optional)"], + "images": ["string URL (optional)"] +} +``` + +**PUT `/vendors/venues/:id` Request:** +```json +{ + "name": "string", + "description": "string", + "address": "string", + "city": "string", + "state": "string", + "zipCode": "string", + "capacity": "number", + "pricePerHour": "number", + "amenities": ["string"], + "images": ["string URL"] +} +``` + +#### Booking Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/vendors/bookings` | Get bookings for vendor's venues | +| PUT | `/vendors/bookings/:id/approve` | Approve a pending booking | +| PUT | `/vendors/bookings/:id/reject` | Reject a pending booking | + +**GET `/vendors/bookings` Response:** +```json +{ + "data": [ + { + "id": "string", + "venueId": "string", + "venueName": "string", + "userId": "string", + "userName": "string", + "eventDate": "YYYY-MM-DD", + "startTime": "HH:mm", + "endTime": "HH:mm", + "guestCount": "number", + "totalAmount": "number", + "notes": "string", + "status": "pending | confirmed | cancelled | completed | rejected", + "createdAt": "ISO 8601" + } + ], + "total": 20, + "page": 1, + "limit": 10, + "totalPages": 2 +} +``` + +#### Analytics + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/vendors/analytics` | Get vendor analytics | + +**GET `/vendors/analytics` Query Parameters:** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `period` | string | `month` | `week`, `month`, or `year` | + +**GET `/vendors/analytics` Response:** +```json +{ + "data": { + "totalVenues": "number", + "totalBookings": "number", + "totalRevenue": "number", + "pendingBookings": "number", + "revenueOverTime": [ + { "date": "string", "amount": "number" } + ], + "bookingTrends": [ + { "date": "string", "count": "number" } + ], + "topVenues": [ + { "venueId": "string", "name": "string", "bookings": "number", "revenue": "number" } + ], + "occupancyRate": "number (percentage)" + } +} +``` + +--- + +### Admin Portal + +> All endpoints require `Authorization: Bearer ` with `role: admin` and `X-Portal: admin` header + +#### User Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/admin/users` | List all users (paginated) | +| PUT | `/admin/users/:id/status` | Toggle user active/inactive | + +**GET `/admin/users` Query Parameters:** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `page` | number | 1 | Page number | +| `limit` | number | 10 | Items per page | + +**GET `/admin/users` Response:** +```json +{ + "data": [ + { + "id": "string", + "firstName": "string", + "lastName": "string", + "email": "string", + "phone": "string", + "role": "user", + "isActive": "boolean", + "createdAt": "ISO 8601" + } + ], + "total": 50, + "page": 1, + "limit": 10, + "totalPages": 5 +} +``` + +**PUT `/admin/users/:id/status` Request:** +```json +{ + "isActive": "boolean" +} +``` + +#### Vendor Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/admin/vendors` | List all vendors (paginated) | +| PUT | `/admin/vendors/:id/status` | Toggle vendor status | + +**GET `/admin/vendors` Response:** +```json +{ + "data": [ + { + "id": "string", + "businessName": "string", + "email": "string", + "phone": "string", + "isVerified": "boolean", + "isActive": "boolean", + "totalVenues": "number", + "createdAt": "ISO 8601" + } + ], + "total": 20, + "page": 1, + "limit": 10, + "totalPages": 2 +} +``` + +**PUT `/admin/vendors/:id/status` Request:** +```json +{ + "isActive": "boolean", + "isVerified": "boolean" +} +``` + +#### Venue Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/admin/venues` | List all venues (paginated) | +| PUT | `/admin/venues/:id/status` | Approve/reject/deactivate venue | + +**GET `/admin/venues` Response:** +```json +{ + "data": [ + { + "id": "string", + "name": "string", + "vendorName": "string", + "city": "string", + "capacity": "number", + "pricePerHour": "number", + "status": "active | inactive | pending | rejected", + "createdAt": "ISO 8601" + } + ], + "total": 30, + "page": 1, + "limit": 10, + "totalPages": 3 +} +``` + +**PUT `/admin/venues/:id/status` Request:** +```json +{ + "status": "active | inactive | pending | rejected" +} +``` + +#### Booking Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/admin/bookings` | List all bookings (paginated) | + +**GET `/admin/bookings` Response:** +```json +{ + "data": [ + { + "id": "string", + "venueName": "string", + "userName": "string", + "vendorName": "string", + "eventDate": "YYYY-MM-DD", + "startTime": "HH:mm", + "endTime": "HH:mm", + "guestCount": "number", + "totalAmount": "number", + "status": "pending | confirmed | cancelled | completed | rejected", + "createdAt": "ISO 8601" + } + ], + "total": 100, + "page": 1, + "limit": 10, + "totalPages": 10 +} +``` + +#### Analytics + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/admin/analytics` | Platform-wide analytics | + +**GET `/admin/analytics` Query Parameters:** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `period` | string | `month` | `week`, `month`, or `year` | + +**GET `/admin/analytics` Response:** +```json +{ + "data": { + "totalUsers": "number", + "totalVendors": "number", + "totalVenues": "number", + "totalBookings": "number", + "totalRevenue": "number", + "revenueOverTime": [ + { "date": "string", "amount": "number" } + ], + "userGrowth": [ + { "date": "string", "count": "number" } + ], + "bookingTrends": [ + { "date": "string", "count": "number" } + ], + "topVenues": [ + { "venueId": "string", "name": "string", "bookings": "number", "revenue": "number" } + ] + } +} +``` + +#### Platform Settings + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/admin/settings` | Get platform settings | +| PUT | `/admin/settings` | Update platform settings | + +**GET `/admin/settings` Response:** +```json +{ + "data": { + "siteName": "string", + "supportEmail": "string", + "commissionRate": "number (percentage, e.g. 10 for 10%)" + } +} +``` + +**PUT `/admin/settings` Request:** +```json +{ + "siteName": "string (required)", + "supportEmail": "string (required, valid email)", + "commissionRate": "number (required, 0-100)" +} +``` + +--- + +## CORS Configuration + +The backend must allow the following origins: + +- **Dev:** `http://localhost:4200` (Angular dev server) +- **Prod:** Your production frontend domain + +Required CORS headers: +``` +Access-Control-Allow-Origin: +Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS +Access-Control-Allow-Headers: Content-Type, Authorization, X-Portal +Access-Control-Allow-Credentials: true +``` + +--- + +## Database Schema Suggestions + +### Users Table +| Column | Type | Notes | +|--------|------|-------| +| id | UUID / String | Primary key | +| firstName | String | Required | +| lastName | String | Required | +| email | String | Unique, required | +| phone | String | Required | +| password | String | Hashed, required | +| role | Enum | `user`, `vendor`, `admin` | +| isActive | Boolean | Default: true | +| createdAt | DateTime | Auto-generated | +| updatedAt | DateTime | Auto-updated | + +### Vendors Table (extends or separate from Users) +| Column | Type | Notes | +|--------|------|-------| +| id | UUID / String | Primary key | +| businessName | String | Required | +| email | String | Unique, required | +| phone | String | Required | +| password | String | Hashed, required | +| isVerified | Boolean | Default: false | +| isActive | Boolean | Default: true | +| createdAt | DateTime | Auto-generated | +| updatedAt | DateTime | Auto-updated | + +### Venues Table +| Column | Type | Notes | +|--------|------|-------| +| id | UUID / String | Primary key | +| vendorId | UUID / String | Foreign key to Vendors | +| name | String | Required | +| description | Text | Required | +| address | String | Required | +| city | String | Required | +| state | String | Required | +| zipCode | String | Required | +| capacity | Integer | Required, min 1 | +| pricePerHour | Decimal | Required, min 0 | +| amenities | JSON / Array | Optional | +| images | JSON / Array | URLs | +| status | Enum | `active`, `inactive`, `pending`, `rejected` | +| createdAt | DateTime | Auto-generated | +| updatedAt | DateTime | Auto-updated | + +### Bookings Table +| Column | Type | Notes | +|--------|------|-------| +| id | UUID / String | Primary key | +| venueId | UUID / String | Foreign key to Venues | +| userId | UUID / String | Foreign key to Users | +| eventDate | Date | Required, future date | +| startTime | String | HH:mm format | +| endTime | String | HH:mm format | +| guestCount | Integer | Required, min 1 | +| totalAmount | Decimal | Calculated: hours x pricePerHour | +| notes | Text | Optional | +| status | Enum | `pending`, `confirmed`, `cancelled`, `completed`, `rejected` | +| createdAt | DateTime | Auto-generated | +| updatedAt | DateTime | Auto-updated | + +### Settings Table +| Column | Type | Notes | +|--------|------|-------| +| id | UUID / String | Primary key | +| siteName | String | Default: "Book My Venue" | +| supportEmail | String | Default: "support@bookmyvenue.com" | +| commissionRate | Decimal | Default: 10 (percentage) | + +--- + +## Seed Data + +Create at least one admin user on first run: + +```json +{ + "email": "admin@bookmyvenue.com", + "password": "Admin@123", + "role": "admin", + "firstName": "Super", + "lastName": "Admin" +} +``` + +--- + +## Notes + +- All dates should be in ISO 8601 format (`2025-01-15T10:30:00.000Z`) +- Event dates are `YYYY-MM-DD` format +- Times are `HH:mm` 24-hour format +- Passwords must be hashed (bcrypt recommended) +- Token expiry: access token ~15 min, refresh token ~7 days +- Pagination defaults: `page=1`, `limit=10` +- The frontend handles all validation on its end, but the backend should also validate +- Image handling: the frontend sends image URLs — implement file upload separately and return URLs diff --git a/client/.editorconfig b/client/.editorconfig new file mode 100644 index 000000000..f166060da --- /dev/null +++ b/client/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 000000000..854acd5fc --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,44 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/mcp.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings +__screenshots__/ + +# System files +.DS_Store +Thumbs.db diff --git a/client/.postcssrc.json b/client/.postcssrc.json new file mode 100644 index 000000000..e092dc7c1 --- /dev/null +++ b/client/.postcssrc.json @@ -0,0 +1,5 @@ +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} diff --git a/client/.prettierrc b/client/.prettierrc new file mode 100644 index 000000000..d6c16d7ee --- /dev/null +++ b/client/.prettierrc @@ -0,0 +1,12 @@ +{ + "printWidth": 100, + "singleQuote": true, + "overrides": [ + { + "files": "*.html", + "options": { + "parser": "angular" + } + } + ] +} diff --git a/client/README.md b/client/README.md new file mode 100644 index 000000000..4fb03fa8a --- /dev/null +++ b/client/README.md @@ -0,0 +1,59 @@ +# BookMyVenue + +This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.12. + +## Development server + +To start a local development server, run: + +```bash +ng serve +``` + +Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. + +## Code scaffolding + +Angular CLI includes powerful code scaffolding tools. To generate a new component, run: + +```bash +ng generate component component-name +``` + +For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: + +```bash +ng generate --help +``` + +## Building + +To build the project run: + +```bash +ng build +``` + +This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. + +## Running unit tests + +To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: + +```bash +ng test +``` + +## Running end-to-end tests + +For end-to-end (e2e) testing, run: + +```bash +ng e2e +``` + +Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. + +## Additional Resources + +For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/client/angular.json b/client/angular.json new file mode 100644 index 000000000..40e2d5843 --- /dev/null +++ b/client/angular.json @@ -0,0 +1,74 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm", + "analytics": false + }, + "newProjectRoot": "projects", + "projects": { + "book-my-venue": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "book-my-venue:build:production" + }, + "development": { + "buildTarget": "book-my-venue:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test" + } + } + } + } +} diff --git a/client/eslint.config.js b/client/eslint.config.js new file mode 100644 index 000000000..097821b3b --- /dev/null +++ b/client/eslint.config.js @@ -0,0 +1,16 @@ +const angular = require("@angular-eslint/schematics"); + +module.exports = [ + { + files: ["**/*.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/explicit-function-return-type": "warn", + "no-console": ["warn", { allow: ["warn", "error"] }], + }, + }, + { + files: ["**/*.html"], + rules: {}, + }, +]; diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 000000000..31b966c53 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,10194 @@ +{ + "name": "book-my-venue", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "book-my-venue", + "version": "0.0.0", + "dependencies": { + "@angular/common": "^21.2.0", + "@angular/compiler": "^21.2.0", + "@angular/core": "^21.2.0", + "@angular/forms": "^21.2.0", + "@angular/platform-browser": "^21.2.0", + "@angular/router": "^21.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular-eslint/schematics": "^21.4.0", + "@angular/build": "^21.2.12", + "@angular/cli": "^21.2.12", + "@angular/compiler-cli": "^21.2.0", + "@tailwindcss/postcss": "^4.3.0", + "eslint": "^10.4.0", + "jsdom": "^28.0.0", + "postcss": "^8.5.15", + "prettier": "^3.8.1", + "tailwindcss": "^4.3.0", + "typescript": "~5.9.2", + "vitest": "^4.0.8" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@algolia/abtesting": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", + "integrity": "sha512-Dkj0BgPiLAaim9sbQ97UKDFHJE/880wgStAM18U++NaJ/2Cws34J5731ovJifr6E3Pv4T2CqvMXf8qLCC417Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.48.1.tgz", + "integrity": "sha512-LV5qCJdj+/m9I+Aj91o+glYszrzd7CX6NgKaYdTOj4+tUYfbS62pwYgUfZprYNayhkQpVFcrW8x8ZlIHpS23Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.48.1.tgz", + "integrity": "sha512-/AVoMqHhPm14CcHq7mwB+bUJbfCv+jrxlNvRjXAuO+TQa+V37N8k1b0ijaRBPdmSjULMd8KtJbQyUyabXOu6Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.48.1.tgz", + "integrity": "sha512-VXO+qu2Ep6ota28ktvBm3sG53wUHS2n7bgLWmce5jTskdlCD0/JrV4tnBm1l7qpla1CeoQb8D7ShFhad+UoSOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.48.1.tgz", + "integrity": "sha512-zl+Qyb0nLg+Y5YvKp1Ij+u9OaPaKg2/EPzTwKNiVyOHnQJlFxmXyUZL1EInczAZsEY8hVpPCLtNfhMhfxluXKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.48.1.tgz", + "integrity": "sha512-r89Qf9Oo9mKWQXumRu/1LtvVJAmEDpn8mHZMc485pRfQUMAwSSrsnaw1tQ3sszqzEgAr1c7rw6fjBI+zrAXTOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.48.1.tgz", + "integrity": "sha512-TPKNPKfghKG/bMSc7mQYD9HxHRUkBZA4q1PEmHgICaSeHQscGqL4wBrKkhfPlDV1uYBKW02pbFMUhsOt7p4ZpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.48.1.tgz", + "integrity": "sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.48.1.tgz", + "integrity": "sha512-/RFq3TqtXDUUawwic/A9xylA2P3LDMO8dNhphHAUOU51b1ZLHrmZ6YYJm3df1APz7xLY1aht6okCQf+/vmrV9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.48.1.tgz", + "integrity": "sha512-Of0jTeAZRyRhC7XzDSjJef0aBkgRcvRAaw0ooYRlOw57APii7lZdq+layuNdeL72BRq1snaJhoMMwkmLIpJScw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.48.1.tgz", + "integrity": "sha512-bE7JcpFXzxF5zHwj/vkl2eiCBvyR1zQ7aoUdO+GDXxGp0DGw7nI0p8Xj6u8VmRQ+RDuPcICFQcCwRIJT5tDJFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.48.1.tgz", + "integrity": "sha512-MK3wZ2koLDnvH/AmqIF1EKbJlhRS5j74OZGkLpxI4rYvNi9Jn/C7vb5DytBnQ4KUWts7QsmbdwHkxY5txQHXVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.48.1.tgz", + "integrity": "sha512-2oDT43Y5HWRSIQMPQI4tA/W+TN/N2tjggZCUsqQV440kxzzoPGsvv9QP1GhQ4CoDa+yn6ygUsGp6Dr+a9sPPSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.48.1.tgz", + "integrity": "sha512-xcaCqbhupVWhuBP1nwbk1XNvwrGljozutEiLx06mvqDf3o8cHyEgQSHS4fKJM+UAggaWVnnFW+Nne5aQ8SUJXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@angular-devkit/architect": { + "version": "0.2102.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.12.tgz", + "integrity": "sha512-w9FSMHYeeHkk0kRSAOCvNqEVyOHqpC1SUf3iN7tDnXBOA0dtc6JYvJU7O4joiwf7wMPZDK8LKc/6eu8/Tx87Fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.12", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.12.tgz", + "integrity": "sha512-nXms0jVWwHOJK+z6vHvhw7HYFBelxh2gEnkij0OQMABXZN5hoUlTD0DDP1lYR7hQNi8Yb2Ar0UN9ihyUFVM5Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.12.tgz", + "integrity": "sha512-29xe6C9nwHejV9zBcu0js7NmzLWuCFzBGBTmL6eD4JN1NcxEZ/nO1JuaGINjPjzb/UDXPZIqEwHbnFNcGS5v1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.12", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.21", + "ora": "9.3.0", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-21.4.0.tgz", + "integrity": "sha512-/3H4BPbQ1BHJkkrUsfusZtmHc+qiFWBBZ9UDPWah4xZMjflexOK9U4GYeH7nMjcuyqFnIlMMeJJNwNLGt/hmdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-eslint/eslint-plugin": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-21.4.0.tgz", + "integrity": "sha512-mow2DMj+xBvGl5t7jzC34R8YfbHbaGNyCNFzpovtl9qc0JbuqLyg6htmt8xb05f8ZjATOr4nz0ESt6HV4c51hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "@angular-eslint/utils": "21.4.0", + "ts-api-utils": "^2.1.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-21.4.0.tgz", + "integrity": "sha512-sJEHx2WYnvOgPpzP1eHnUdRS06zgKmRxbiIR0JiCcaSen5iv1HlsMieXy//FS9TtNW+abHOy4UtDuGuSPflPFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "@angular-eslint/utils": "21.4.0", + "aria-query": "5.3.2", + "axobject-query": "4.1.0" + }, + "peerDependencies": { + "@angular-eslint/template-parser": "21.4.0", + "@typescript-eslint/types": "^7.11.0 || ^8.0.0", + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/schematics": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-21.4.0.tgz", + "integrity": "sha512-crD6Hfxs7x5bN9FCqTZI7uVSiGvprfCS3MCPOpyIQl87bRr/9aNhnicJ3ROUHv+2A713BgPHIgiCII/bxzrfPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": ">= 21.0.0 < 22.0.0", + "@angular-devkit/schematics": ">= 21.0.0 < 22.0.0", + "@angular-eslint/eslint-plugin": "21.4.0", + "@angular-eslint/eslint-plugin-template": "21.4.0", + "ignore": "7.0.5", + "semver": "7.7.4", + "strip-json-comments": "3.1.1" + }, + "peerDependencies": { + "@angular/cli": ">= 21.0.0 < 22.0.0" + } + }, + "node_modules/@angular-eslint/template-parser": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-21.4.0.tgz", + "integrity": "sha512-BaUSLSyS+43fzDoJkTMkGqNdCXq3fGnUZsfXTmrlZPJf5AYFbgAlAPGZXDJyoNWw43fux+DafdlrlKcYUSgSIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.4.0", + "eslint-scope": "9.1.2" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/utils": { + "version": "21.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-21.4.0.tgz", + "integrity": "sha512-7pi+Ga7QmdH5Ig/diau6fR5L4yubgKr9TOjdCg7OeuE/zo0O3osTCNT6JOodzS/iQM1kSCJFDoIBKFeUOttiNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.4.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular/build": { + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.12.tgz", + "integrity": "sha512-zYfo21RuldDWXnshuPfWYtmh5ltlO9+XFHpNObdIInQTFxKD6grLNVNOblFFpi+oIIm4Km+CGSXvBHs/aH0ufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.2102.12", + "@babel/core": "7.29.0", + "@babel/helper-annotate-as-pure": "7.27.3", + "@babel/helper-split-export-declaration": "7.24.7", + "@inquirer/confirm": "5.1.21", + "@vitejs/plugin-basic-ssl": "2.1.4", + "beasties": "0.4.1", + "browserslist": "^4.26.0", + "esbuild": "0.27.3", + "https-proxy-agent": "7.0.6", + "istanbul-lib-instrument": "6.0.3", + "jsonc-parser": "3.3.1", + "listr2": "9.0.5", + "magic-string": "0.30.21", + "mrmime": "2.0.1", + "parse5-html-rewriting-stream": "8.0.0", + "picomatch": "4.0.4", + "piscina": "5.1.4", + "rolldown": "1.0.0-rc.4", + "sass": "1.97.3", + "semver": "7.7.4", + "source-map-support": "0.5.21", + "tinyglobby": "0.2.15", + "undici": "7.24.4", + "vite": "7.3.2", + "watchpack": "2.5.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "lmdb": "3.5.1" + }, + "peerDependencies": { + "@angular/compiler": "^21.0.0", + "@angular/compiler-cli": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/localize": "^21.0.0", + "@angular/platform-browser": "^21.0.0", + "@angular/platform-server": "^21.0.0", + "@angular/service-worker": "^21.0.0", + "@angular/ssr": "^21.2.12", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "^21.0.0", + "postcss": "^8.4.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=5.9 <6.0", + "vitest": "^4.0.8" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@angular/localize": { + "optional": true + }, + "@angular/platform-browser": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "karma": { + "optional": true + }, + "less": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tailwindcss": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@angular/cli": { + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.12.tgz", + "integrity": "sha512-oLEL1C1fI39b1eQo5f2cyQhQfE+QMv7dm8z2MmxbP7YR7jAdQPVfGU8CXECR5g7mrYi9WgvIRKB+9Oeq2aH6Jw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@angular-devkit/architect": "0.2102.12", + "@angular-devkit/core": "21.2.12", + "@angular-devkit/schematics": "21.2.12", + "@inquirer/prompts": "7.10.1", + "@listr2/prompt-adapter-inquirer": "3.0.5", + "@modelcontextprotocol/sdk": "1.26.0", + "@schematics/angular": "21.2.12", + "@yarnpkg/lockfile": "1.1.0", + "algoliasearch": "5.48.1", + "ini": "6.0.0", + "jsonc-parser": "3.3.1", + "listr2": "9.0.5", + "npm-package-arg": "13.0.2", + "pacote": "21.3.1", + "parse5-html-rewriting-stream": "8.0.0", + "semver": "7.7.4", + "yargs": "18.0.0", + "zod": "4.3.6" + }, + "bin": { + "ng": "bin/ng.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/common": { + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.14.tgz", + "integrity": "sha512-J6K7cE7uKOKmg4+sxLeGfsmaYDjP5l1XCiMMI0WPT0t68uxLk8g3MzV5Trqfb6ZnRxWcfp9c4c+XxAvMBB7ymA==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "21.2.14", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/compiler": { + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.14.tgz", + "integrity": "sha512-8mqgwRYfn2Z1vg/5YVt60dDBattnZL45nNJd2vTMwAiDTzhWhgKgRWKOeVL0aj2JqHeHiwuIlrLnz46acJMulQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@angular/compiler-cli": { + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.14.tgz", + "integrity": "sha512-h+WQfPKFxaDfDhMqUUdOQ1TsDMccav8kLFERmKTRfD4MNOczSMpOMyeXJHCL0Rq4I8WDQvaBJGMG7DXRDefSog==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "7.29.0", + "@jridgewell/sourcemap-codec": "^1.4.14", + "chokidar": "^5.0.0", + "convert-source-map": "^1.5.1", + "reflect-metadata": "^0.2.0", + "semver": "^7.0.0", + "tslib": "^2.3.0", + "yargs": "^18.0.0" + }, + "bin": { + "ng-xi18n": "bundles/src/bin/ng_xi18n.js", + "ngc": "bundles/src/bin/ngc.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/compiler": "21.2.14", + "typescript": ">=5.9 <6.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@angular/core": { + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.14.tgz", + "integrity": "sha512-Z1Ivjh7L2lT//8LA7vQ3tj7Rg6wl2XRA5kPSAukgn8u0Yu0XxG8NE8KG0Eypb3v9CEcbwATwpgnxzbJFZ8TFcw==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/compiler": "21.2.14", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } + } + }, + "node_modules/@angular/forms": { + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.14.tgz", + "integrity": "sha512-HQYIybyMt0CrI31rW6vXbiDsSM2DDtTcOVeT/nWDRNCoqBrREDg8rVsm2Y+fUMsiQVJNa6dCXPwvYhjzJ4r7ug==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/common": "21.2.14", + "@angular/core": "21.2.14", + "@angular/platform-browser": "21.2.14", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/platform-browser": { + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.14.tgz", + "integrity": "sha512-34tBwxh86yN2YifBDhCesm6N+nn9WcbuXjRwfo0mTme15OZ/zt56yw7v1mcK3UFLegIIALtsIgpXXrPWWQoKkA==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/animations": "21.2.14", + "@angular/common": "21.2.14", + "@angular/core": "21.2.14" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, + "node_modules/@angular/router": { + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.14.tgz", + "integrity": "sha512-Yo3LdgcqkfMu2/Ycl8o/4QjCBqZhtA+a7B8JVdW5cWdrpFTxKCOrzm+YRUMuIFmH5nzSv9oGnUuz64uk1+7r5Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/common": "21.2.14", + "@angular/core": "21.2.14", + "@angular/platform-browser": "21.2.14", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@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.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@gar/promise-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@harperfast/extended-iterable": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", + "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@listr2/prompt-adapter-inquirer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz", + "integrity": "sha512-WELs+hj6xcilkloBXYf9XXK8tYEnKsgLj01Xl5ONUJpKjmT5hGVUzNUS5tooUxs7pGMrw+jFD/41WpqW4V3LDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@inquirer/prompts": ">= 3 < 8", + "listr2": "9.0.5" + } + }, + "node_modules/@lmdb/lmdb-darwin-arm64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.1.tgz", + "integrity": "sha512-tpfN4kKrrMpQ+If1l8bhmoNkECJi0iOu6AEdrTJvWVC+32sLxTARX5Rsu579mPImRP9YFWfWgeRQ5oav7zApQQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-darwin-x64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.5.1.tgz", + "integrity": "sha512-+a2tTfc3rmWhLAolFUWRgJtpSuu+Fw/yjn4rF406NMxhfjbMuiOUTDRvRlMFV+DzyjkwnokisskHbCWkS3Ly5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.5.1.tgz", + "integrity": "sha512-0EgcE6reYr8InjD7V37EgXcYrloqpxVPINy3ig1MwDSbl6LF/vXTYRH9OE1Ti1D8YZnB35ZH9aTcdfSb5lql2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.5.1.tgz", + "integrity": "sha512-aoERa5B6ywXdyFeYGQ1gbQpkMkDbEo45qVoXE5QpIRavqjnyPwjOulMkmkypkmsbJ5z4Wi0TBztON8agCTG0Vg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-x64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.5.1.tgz", + "integrity": "sha512-SqNDY1+vpji7bh0sFH5wlWyFTOzjbDOl0/kB5RLLYDAFyd/uw3n7wyrmas3rYPpAW7z18lMOi1yKlTPv967E3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-win32-arm64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.5.1.tgz", + "integrity": "sha512-50v0O1Lt37cwrmR9vWZK5hRW0Aw+KEmxJJ75fge/zIYdvNKB/0bSMSVR5Uc2OV9JhosIUyklOmrEvavwNJ8D6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lmdb/lmdb-win32-x64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.5.1.tgz", + "integrity": "sha512-qwosvPyl+zpUlp3gRb7UcJ3H8S28XHCzkv0Y0EgQToXjQP91ZD67EHSCDmaLjtKhe+GVIW5om1KUpzVLA0l6pg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/nice": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", + "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.1.1", + "@napi-rs/nice-android-arm64": "1.1.1", + "@napi-rs/nice-darwin-arm64": "1.1.1", + "@napi-rs/nice-darwin-x64": "1.1.1", + "@napi-rs/nice-freebsd-x64": "1.1.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/nice-linux-arm64-gnu": "1.1.1", + "@napi-rs/nice-linux-arm64-musl": "1.1.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", + "@napi-rs/nice-linux-s390x-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-musl": "1.1.1", + "@napi-rs/nice-openharmony-arm64": "1.1.1", + "@napi-rs/nice-win32-arm64-msvc": "1.1.1", + "@napi-rs/nice-win32-ia32-msvc": "1.1.1", + "@napi-rs/nice-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", + "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", + "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", + "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-openharmony-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", + "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@npmcli/agent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", + "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", + "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@npmcli/git/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@npmcli/git/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", + "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", + "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", + "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", + "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", + "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.113.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.113.0.tgz", + "integrity": "sha512-Tp3XmgxwNQ9pEN9vxgJBAqdRamHibi76iowQ38O2I4PMpcvNRQNVsU2n1x1nv9yh0XoTrGFzf7cZSGxmixxrhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.4.tgz", + "integrity": "sha512-vRq9f4NzvbdZavhQbjkJBx7rRebDKYR9zHfO/Wg486+I7bSecdUapzCm5cyXoK+LHokTxgSq7A5baAXUZkIz0w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.4.tgz", + "integrity": "sha512-kFgEvkWLqt3YCgKB5re9RlIrx9bRsvyVUnaTakEpOPuLGzLpLapYxE9BufJNvPg8GjT6mB1alN4yN1NjzoeM8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.4.tgz", + "integrity": "sha512-JXmaOJGsL/+rsmMfutcDjxWM2fTaVgCHGoXS7nE8Z3c9NAYjGqHvXrAhMUZvMpHS/k7Mg+X7n/MVKb7NYWKKww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.4.tgz", + "integrity": "sha512-ep3Catd6sPnHTM0P4hNEvIv5arnDvk01PfyJIJ+J3wVCG1eEaPo09tvFqdtcaTrkwQy0VWR24uz+cb4IsK53Qw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.4.tgz", + "integrity": "sha512-LwA5ayKIpnsgXJEwWc3h8wPiS33NMIHd9BhsV92T8VetVAbGe2qXlJwNVDGHN5cOQ22R9uYvbrQir2AB+ntT2w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.4.tgz", + "integrity": "sha512-AC1WsGdlV1MtGay/OQ4J9T7GRadVnpYRzTcygV1hKnypbYN20Yh4t6O1Sa2qRBMqv1etulUknqXjc3CTIsBu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.4.tgz", + "integrity": "sha512-lU+6rgXXViO61B4EudxtVMXSOfiZONR29Sys5VGSetUY7X8mg9FCKIIjcPPj8xNDeYzKl+H8F/qSKOBVFJChCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.4.tgz", + "integrity": "sha512-DZaN1f0PGp/bSvKhtw50pPsnln4T13ycDq1FrDWRiHmWt1JeW+UtYg9touPFf8yt993p8tS2QjybpzKNTxYEwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.4.tgz", + "integrity": "sha512-RnGxwZLN7fhMMAItnD6dZ7lvy+TI7ba+2V54UF4dhaWa/p8I/ys1E73KO6HmPmgz92ZkfD8TXS1IMV8+uhbR9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.4.tgz", + "integrity": "sha512-6lcI79+X8klGiGd8yHuTgQRjuuJYNggmEml+RsyN596P23l/zf9FVmJ7K0KVKkFAeYEdg0iMUKyIxiV5vebDNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.4.tgz", + "integrity": "sha512-wz7ohsKCAIWy91blZ/1FlpPdqrsm1xpcEOQVveWoL6+aSPKL4VUcoYmmzuLTssyZxRpEwzuIxL/GDsvpjaBtOw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.4.tgz", + "integrity": "sha512-cfiMrfuWCIgsFmcVG0IPuO6qTRHvF7NuG3wngX1RZzc6dU8FuBFb+J3MIR5WrdTNozlumfgL4cvz+R4ozBCvsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.4.tgz", + "integrity": "sha512-p6UeR9y7ht82AH57qwGuFYn69S6CZ7LLKdCKy/8T3zS9VTrJei2/CGsTUV45Da4Z9Rbhc7G4gyWQ/Ioamqn09g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.4.tgz", + "integrity": "sha512-1BrrmTu0TWfOP1riA8uakjFc9bpIUGzVKETsOtzY39pPga8zELGDl8eu1Dx7/gjM5CAz14UknsUMpBO8L+YntQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@schematics/angular": { + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.12.tgz", + "integrity": "sha512-eHoAbxd6Kdw9YIQeZO/6lBXTmKKi10t4WTujY8CM5v4qv1zoJu9yiwVeQp9y3e7/Sybz5Ec3m4FmQ0Tw8iVDiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.12", + "@angular-devkit/schematics": "21.2.12", + "jsonc-parser": "3.3.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@sigstore/bundle": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", + "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/core": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.1.tgz", + "integrity": "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.1.tgz", + "integrity": "sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.1.tgz", + "integrity": "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.2.tgz", + "integrity": "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.1.tgz", + "integrity": "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "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" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", + "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "postcss": "^8.5.10", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", + "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", + "integrity": "sha512-HXciTXN/sDBYWgeAD4V4s0DN0g72x5mlxQhHxtYu3Tt8BLa6MzcJZUyDVFCdtjNs3bfENVHVzOsmooTVuNgAAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/algoliasearch": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.48.1.tgz", + "integrity": "sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.14.1", + "@algolia/client-abtesting": "5.48.1", + "@algolia/client-analytics": "5.48.1", + "@algolia/client-common": "5.48.1", + "@algolia/client-insights": "5.48.1", + "@algolia/client-personalization": "5.48.1", + "@algolia/client-query-suggestions": "5.48.1", + "@algolia/client-search": "5.48.1", + "@algolia/ingestion": "1.48.1", + "@algolia/monitoring": "1.48.1", + "@algolia/recommend": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/beasties": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.1.tgz", + "integrity": "sha512-2Imdcw3LznDuxAbJM26RHniOLAzE6WgrK8OuvVXCQtNBS8rsnD9zsSEa3fHl4hHpUY7BYTlrpvtPVbvu9G6neg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "css-select": "^6.0.0", + "css-what": "^7.0.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "htmlparser2": "^10.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.49", + "postcss-media-query-parser": "^0.2.3", + "postcss-safe-parser": "^7.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-walk": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", + "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "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" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/lmdb": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.5.1.tgz", + "integrity": "sha512-NYHA0MRPjvNX+vSw8Xxg6FLKxzAG+e7Pt8RqAQA/EehzHVXq9SxDqJIN3JL1hK0dweb884y8kIh6rkWvPyg9Wg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@harperfast/extended-iterable": "^1.0.3", + "msgpackr": "^1.11.2", + "node-addon-api": "^6.1.0", + "node-gyp-build-optional-packages": "5.2.2", + "ordered-binary": "^1.5.3", + "weak-lru-cache": "^1.2.2" + }, + "bin": { + "download-lmdb-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@lmdb/lmdb-darwin-arm64": "3.5.1", + "@lmdb/lmdb-darwin-x64": "3.5.1", + "@lmdb/lmdb-linux-arm": "3.5.1", + "@lmdb/lmdb-linux-arm64": "3.5.1", + "@lmdb/lmdb-linux-x64": "3.5.1", + "@lmdb/lmdb-win32-arm64": "3.5.1", + "@lmdb/lmdb-win32-x64": "3.5.1" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-fetch-happen": { + "version": "15.0.5", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", + "integrity": "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", + "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "dev": true, + "license": "MIT", + "optional": true, + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-gyp": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", + "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-bundled": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", + "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-install-checks": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", + "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-package-arg": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", + "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-packlist": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", + "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", + "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", + "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.1", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ordered-binary": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", + "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pacote": { + "version": "21.3.1", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.3.1.tgz", + "integrity": "sha512-O0EDXi85LF4AzdjG74GUwEArhdvawi/YOHcsW6IijKNj7wm8IvEWNF5GnfuxNpQ/ZpO3L37+v8hqdVh8GgWYhg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.0.tgz", + "integrity": "sha512-wzh11mj8KKkno1pZEu+l2EVeWsuKDfR5KNWZOTsslfUX8lPDZx77m9T0kIoAVkFtD1nx6YF8oh4BnPHvxMtNMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0", + "parse5": "^8.0.0", + "parse5-sax-parser": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-sax-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", + "integrity": "sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/piscina": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.1.4.tgz", + "integrity": "sha512-7uU4ZnKeQq22t9AsmHGD2w4OYQGonwFnTypDypaWi7Qr2EvQIFVtG8J5D/3bE7W123Wdc9+v4CZDu5hJXVCtBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.x" + }, + "optionalDependencies": { + "@napi-rs/nice": "^1.0.4" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.4.tgz", + "integrity": "sha512-V2tPDUrY3WSevrvU2E41ijZlpF+5PbZu4giH+VpNraaadsJGHa4fR6IFwsocVwEXDoAdIv5qgPPxgrvKAOIPtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.113.0", + "@rolldown/pluginutils": "1.0.0-rc.4" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.4", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.4", + "@rolldown/binding-darwin-x64": "1.0.0-rc.4", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.4", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.4", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.4", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.4", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.4", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.4", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.4", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.4", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.4" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "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.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sigstore": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", + "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", + "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.0.tgz", + "integrity": "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.0" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.0.tgz", + "integrity": "sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "peer": true + }, + "node_modules/tuf-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "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 + } + } + }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/weak-lru-cache": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 000000000..ecf3608f2 --- /dev/null +++ b/client/package.json @@ -0,0 +1,37 @@ +{ + "name": "book-my-venue", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "private": true, + "packageManager": "npm@10.9.2", + "dependencies": { + "@angular/common": "^21.2.0", + "@angular/compiler": "^21.2.0", + "@angular/core": "^21.2.0", + "@angular/forms": "^21.2.0", + "@angular/platform-browser": "^21.2.0", + "@angular/router": "^21.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular-eslint/schematics": "^21.4.0", + "@angular/build": "^21.2.12", + "@angular/cli": "^21.2.12", + "@angular/compiler-cli": "^21.2.0", + "@tailwindcss/postcss": "^4.3.0", + "eslint": "^10.4.0", + "jsdom": "^28.0.0", + "postcss": "^8.5.15", + "prettier": "^3.8.1", + "tailwindcss": "^4.3.0", + "typescript": "~5.9.2", + "vitest": "^4.0.8" + } +} diff --git a/client/public/favicon.ico b/client/public/favicon.ico new file mode 100644 index 000000000..57614f9c9 Binary files /dev/null and b/client/public/favicon.ico differ diff --git a/client/src/app/admin/features/analytics/analytics.component.css b/client/src/app/admin/features/analytics/analytics.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/admin/features/analytics/analytics.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/admin/features/analytics/analytics.component.html b/client/src/app/admin/features/analytics/analytics.component.html new file mode 100644 index 000000000..94ec01b71 --- /dev/null +++ b/client/src/app/admin/features/analytics/analytics.component.html @@ -0,0 +1,30 @@ +
+
+

Platform Analytics

+
+ @for (p of ['week', 'month', 'year']; track p) { + + } +
+
+
+
+

Revenue Overview

+
Chart placeholder
+
+
+

User Growth

+
Chart placeholder
+
+
+

Booking Trends

+
Chart placeholder
+
+
+

Top Venues

+
Chart placeholder
+
+
+
diff --git a/client/src/app/admin/features/analytics/analytics.component.spec.ts b/client/src/app/admin/features/analytics/analytics.component.spec.ts new file mode 100644 index 000000000..1b74ea85d --- /dev/null +++ b/client/src/app/admin/features/analytics/analytics.component.spec.ts @@ -0,0 +1,7 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AnalyticsComponent } from './analytics.component'; +describe('Admin AnalyticsComponent', () => { + let fixture: ComponentFixture; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [AnalyticsComponent] }).compileComponents(); fixture = TestBed.createComponent(AnalyticsComponent); fixture.detectChanges(); }); + it('should create', () => { expect(fixture.componentInstance).toBeTruthy(); }); +}); diff --git a/client/src/app/admin/features/analytics/analytics.component.ts b/client/src/app/admin/features/analytics/analytics.component.ts new file mode 100644 index 000000000..e6cab1d44 --- /dev/null +++ b/client/src/app/admin/features/analytics/analytics.component.ts @@ -0,0 +1,13 @@ +import { Component, signal, ChangeDetectionStrategy } from '@angular/core'; +import { TitleCasePipe } from '@angular/common'; + +@Component({ + selector: 'app-admin-analytics', standalone: true, + imports: [TitleCasePipe], + templateUrl: './analytics.component.html', styleUrl: './analytics.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AnalyticsComponent { + readonly period = signal<'week' | 'month' | 'year'>('month'); + setPeriod(period: 'week' | 'month' | 'year'): void { this.period.set(period); } +} diff --git a/client/src/app/admin/features/bookings/bookings.component.css b/client/src/app/admin/features/bookings/bookings.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/admin/features/bookings/bookings.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/admin/features/bookings/bookings.component.html b/client/src/app/admin/features/bookings/bookings.component.html new file mode 100644 index 000000000..0ab3d5ca4 --- /dev/null +++ b/client/src/app/admin/features/bookings/bookings.component.html @@ -0,0 +1,44 @@ +
+

Manage Bookings

+ @if (loading()) { } @else if (bookings().length === 0) { + + } @else { +
+ + + + + + + + + + + + + @for (booking of bookings(); track booking.id) { + + + + + + + + + } + +
Venue IDDateSlot IDAmountStatusActions
{{ booking.venueId }}{{ booking.bookingDate | dateFormat }}{{ booking.slotTemplateId }}{{ booking.totalAmount | currencyFormat }} + {{ booking.status }} + + + +
+
+ } +
\ No newline at end of file diff --git a/client/src/app/admin/features/bookings/bookings.component.spec.ts b/client/src/app/admin/features/bookings/bookings.component.spec.ts new file mode 100644 index 000000000..a6c6a12f7 --- /dev/null +++ b/client/src/app/admin/features/bookings/bookings.component.spec.ts @@ -0,0 +1,7 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BookingsComponent } from './bookings.component'; +describe('Admin BookingsComponent', () => { + let fixture: ComponentFixture; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [BookingsComponent] }).compileComponents(); fixture = TestBed.createComponent(BookingsComponent); fixture.detectChanges(); }); + it('should create', () => { expect(fixture.componentInstance).toBeTruthy(); }); +}); diff --git a/client/src/app/admin/features/bookings/bookings.component.ts b/client/src/app/admin/features/bookings/bookings.component.ts new file mode 100644 index 000000000..ca623d175 --- /dev/null +++ b/client/src/app/admin/features/bookings/bookings.component.ts @@ -0,0 +1,38 @@ +import { Component, inject, OnInit, signal, ChangeDetectionStrategy } from '@angular/core'; +import { BookingService } from '../../../vendor/services/booking.service'; +import { LoaderComponent } from '../../../shared/components/loader/loader.component'; +import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component'; +import { DateFormatPipe } from '../../../shared/pipes/date-format.pipe'; +import { CurrencyFormatPipe } from '../../../shared/pipes/currency-format.pipe'; +import { Booking } from '../../../shared/models/booking.model'; + +@Component({ + selector: 'app-admin-bookings', standalone: true, + imports: [LoaderComponent, EmptyStateComponent, DateFormatPipe, CurrencyFormatPipe], + templateUrl: './bookings.component.html', styleUrl: './bookings.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BookingsComponent implements OnInit { + private readonly bookingService = inject(BookingService); + + readonly bookings = this.bookingService.bookings; + readonly loading = this.bookingService.loading; + readonly currentPage = signal(1); + readonly totalPages = signal(1); + + ngOnInit(): void { + this.bookingService.loadVendorBookings(); + } + + onPageChange(page: number): void { + this.currentPage.set(page); + } + + onApprove(id: number): void { + this.bookingService.updateBookingStatus(id, 'confirmed'); + } + + onReject(id: number): void { + this.bookingService.updateBookingStatus(id, 'rejected'); + } +} \ No newline at end of file diff --git a/client/src/app/admin/features/dashboard/dashboard.component.css b/client/src/app/admin/features/dashboard/dashboard.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/admin/features/dashboard/dashboard.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/admin/features/dashboard/dashboard.component.html b/client/src/app/admin/features/dashboard/dashboard.component.html new file mode 100644 index 000000000..d79ccb874 --- /dev/null +++ b/client/src/app/admin/features/dashboard/dashboard.component.html @@ -0,0 +1,49 @@ +
+

Admin Dashboard

+ @if (venueService.dashboard(); as stats) { +
+ @for (stat of [ + { label: 'Users', value: stats.totalUsers, color: 'indigo' }, + { label: 'Vendors', value: stats.totalVendors, color: 'green' }, + { label: 'Total Venues', value: stats.totalVenues, color: 'blue' }, + { label: 'Bookings', value: stats.totalBookings, color: 'amber' } + ]; track stat.label) { +
+

{{ stat.label }}

+

{{ stat.value }}

+
+ } +
+
+ @for (stat of [ + { label: 'Pending Approval', value: stats.pendingVenues, color: 'yellow' }, + { label: 'Approved Venues', value: stats.approvedVenues, color: 'green' }, + { label: 'Rejected Venues', value: stats.rejectedVenues, color: 'red' } + ]; track stat.label) { +
+

{{ stat.label }}

+

{{ stat.value }}

+
+ } +
+ } @else { +
+ @for (i of [1,2,3,4]; track i) { +
+
+
+
+ } +
+ } +
+
+

Recent Activity

+

Activity feed placeholder

+
+
+

System Health

+

System metrics placeholder

+
+
+
diff --git a/client/src/app/admin/features/dashboard/dashboard.component.spec.ts b/client/src/app/admin/features/dashboard/dashboard.component.spec.ts new file mode 100644 index 000000000..a7fb79a79 --- /dev/null +++ b/client/src/app/admin/features/dashboard/dashboard.component.spec.ts @@ -0,0 +1,11 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { DashboardComponent } from './dashboard.component'; +describe('Admin DashboardComponent', () => { + let component: DashboardComponent; let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [DashboardComponent], providers: [provideRouter([])] }).compileComponents(); + fixture = TestBed.createComponent(DashboardComponent); component = fixture.componentInstance; fixture.detectChanges(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); +}); diff --git a/client/src/app/admin/features/dashboard/dashboard.component.ts b/client/src/app/admin/features/dashboard/dashboard.component.ts new file mode 100644 index 000000000..6bc520d23 --- /dev/null +++ b/client/src/app/admin/features/dashboard/dashboard.component.ts @@ -0,0 +1,17 @@ +import { Component, inject, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { AdminVenueService } from '../../services/venue.service'; + +@Component({ + selector: 'app-admin-dashboard', + standalone: true, + templateUrl: './dashboard.component.html', + styleUrl: './dashboard.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DashboardComponent implements OnInit { + readonly venueService = inject(AdminVenueService); + + ngOnInit(): void { + this.venueService.loadDashboard(); + } +} diff --git a/client/src/app/admin/features/forgot-password/forgot-password.component.css b/client/src/app/admin/features/forgot-password/forgot-password.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/admin/features/forgot-password/forgot-password.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/admin/features/forgot-password/forgot-password.component.html b/client/src/app/admin/features/forgot-password/forgot-password.component.html new file mode 100644 index 000000000..b92816cc2 --- /dev/null +++ b/client/src/app/admin/features/forgot-password/forgot-password.component.html @@ -0,0 +1,18 @@ +
+
+
+

Forgot Password

+

Reset your admin password

+
+
+
+ +
+ +

+ Back to login +

+
+
+
diff --git a/client/src/app/admin/features/forgot-password/forgot-password.component.spec.ts b/client/src/app/admin/features/forgot-password/forgot-password.component.spec.ts new file mode 100644 index 000000000..7be314c0b --- /dev/null +++ b/client/src/app/admin/features/forgot-password/forgot-password.component.spec.ts @@ -0,0 +1,12 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { ForgotPasswordComponent } from './forgot-password.component'; +describe('Admin ForgotPasswordComponent', () => { + let component: ForgotPasswordComponent; let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [ForgotPasswordComponent], providers: [provideRouter([]), provideHttpClient()] }).compileComponents(); + fixture = TestBed.createComponent(ForgotPasswordComponent); component = fixture.componentInstance; fixture.detectChanges(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); +}); diff --git a/client/src/app/admin/features/forgot-password/forgot-password.component.ts b/client/src/app/admin/features/forgot-password/forgot-password.component.ts new file mode 100644 index 000000000..5d47b6ca1 --- /dev/null +++ b/client/src/app/admin/features/forgot-password/forgot-password.component.ts @@ -0,0 +1,30 @@ +import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { AuthService } from '../../../shared/services/auth.service'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; +import { InputComponent } from '../../../shared/components/input/input.component'; + +@Component({ + selector: 'app-admin-forgot-password', standalone: true, + imports: [ReactiveFormsModule, RouterLink, ButtonComponent, InputComponent], + templateUrl: './forgot-password.component.html', styleUrl: './forgot-password.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ForgotPasswordComponent { + private readonly fb = inject(FormBuilder); + private readonly authService = inject(AuthService); + private readonly router = inject(Router); + readonly loading = this.authService.loading; + readonly form = this.fb.nonNullable.group({ email: ['', [Validators.required, Validators.email]] }); + + onSubmit(): void { + if (this.form.valid) { + this.authService.forgotPassword(this.form.getRawValue(), () => { + this.router.navigate(['/reset-password']); + }); + } else { + this.form.markAllAsTouched(); + } + } +} diff --git a/client/src/app/admin/features/login/login.component.css b/client/src/app/admin/features/login/login.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/admin/features/login/login.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/admin/features/login/login.component.html b/client/src/app/admin/features/login/login.component.html new file mode 100644 index 000000000..64741c735 --- /dev/null +++ b/client/src/app/admin/features/login/login.component.html @@ -0,0 +1,82 @@ +
+ + +
+ + +
+ + +
+ + +
+
+ + + +
+

BookMyVenue

+

Admin Control Panel

+
+ + +
+ +
+
+ + +
+ +
+ + +
+ + + + + + @if (showVerifyButton()) { + + } + +
+ + +

+ © 2026 BookMyVenue · All activity is logged and monitored +

+
+
diff --git a/client/src/app/admin/features/login/login.component.spec.ts b/client/src/app/admin/features/login/login.component.spec.ts new file mode 100644 index 000000000..97e88177e --- /dev/null +++ b/client/src/app/admin/features/login/login.component.spec.ts @@ -0,0 +1,12 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { LoginComponent } from './login.component'; +describe('Admin LoginComponent', () => { + let component: LoginComponent; let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [LoginComponent], providers: [provideRouter([]), provideHttpClient()] }).compileComponents(); + fixture = TestBed.createComponent(LoginComponent); component = fixture.componentInstance; fixture.detectChanges(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); +}); diff --git a/client/src/app/admin/features/login/login.component.ts b/client/src/app/admin/features/login/login.component.ts new file mode 100644 index 000000000..0016cfcc6 --- /dev/null +++ b/client/src/app/admin/features/login/login.component.ts @@ -0,0 +1,48 @@ +import { Component, inject, ChangeDetectionStrategy, computed } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { AuthService } from '../../../shared/services/auth.service'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; +import { InputComponent } from '../../../shared/components/input/input.component'; + +@Component({ + selector: 'app-admin-login', + standalone: true, + imports: [ReactiveFormsModule, RouterLink, ButtonComponent, InputComponent], + templateUrl: './login.component.html', + styleUrl: './login.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LoginComponent { + private readonly fb = inject(FormBuilder); + private readonly router = inject(Router); + private readonly authService = inject(AuthService); + readonly loading = this.authService.loading; + readonly pendingVerificationEmail = this.authService.pendingVerificationEmail; + + readonly showVerifyButton = computed(() => this.pendingVerificationEmail() !== null); + + readonly form = this.fb.nonNullable.group({ + email: ['', [Validators.required, Validators.email]], + password: ['', [Validators.required, Validators.minLength(6)]], + }); + + onSubmit(): void { + if (this.form.valid) { this.authService.adminLogin(this.form.getRawValue()); } + else { this.form.markAllAsTouched(); } + } + + onVerify(): void { + const email = this.pendingVerificationEmail(); + if (email) { + this.authService.clearPendingVerification(); + this.authService.resendOtp({ email }).subscribe({ + next: () => { + this.router.navigate(['/verify-email'], { + queryParams: { email, portal: 'admin' }, + }); + }, + }); + } + } +} diff --git a/client/src/app/admin/features/settings/settings.component.css b/client/src/app/admin/features/settings/settings.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/admin/features/settings/settings.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/admin/features/settings/settings.component.html b/client/src/app/admin/features/settings/settings.component.html new file mode 100644 index 000000000..1912744bb --- /dev/null +++ b/client/src/app/admin/features/settings/settings.component.html @@ -0,0 +1,15 @@ +
+

Platform Settings

+
+
+
+ + + +
+
+ +
+
+
+
diff --git a/client/src/app/admin/features/settings/settings.component.spec.ts b/client/src/app/admin/features/settings/settings.component.spec.ts new file mode 100644 index 000000000..bfc8f97c6 --- /dev/null +++ b/client/src/app/admin/features/settings/settings.component.spec.ts @@ -0,0 +1,7 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SettingsComponent } from './settings.component'; +describe('Admin SettingsComponent', () => { + let fixture: ComponentFixture; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [SettingsComponent] }).compileComponents(); fixture = TestBed.createComponent(SettingsComponent); fixture.detectChanges(); }); + it('should create', () => { expect(fixture.componentInstance).toBeTruthy(); }); +}); diff --git a/client/src/app/admin/features/settings/settings.component.ts b/client/src/app/admin/features/settings/settings.component.ts new file mode 100644 index 000000000..a78519dd0 --- /dev/null +++ b/client/src/app/admin/features/settings/settings.component.ts @@ -0,0 +1,28 @@ +import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; +import { InputComponent } from '../../../shared/components/input/input.component'; + +@Component({ + selector: 'app-admin-settings', standalone: true, + imports: [ReactiveFormsModule, ButtonComponent, InputComponent], + templateUrl: './settings.component.html', styleUrl: './settings.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SettingsComponent { + private readonly fb = inject(FormBuilder); + readonly saving = signal(false); + + readonly form = this.fb.nonNullable.group({ + siteName: ['BookMyVenue', [Validators.required]], + supportEmail: ['support@bookmyvenue.com', [Validators.required, Validators.email]], + commissionRate: [10, [Validators.required, Validators.min(0), Validators.max(100)]], + }); + + onSubmit(): void { + if (this.form.valid) { + this.saving.set(true); + setTimeout(() => this.saving.set(false), 1000); + } + } +} diff --git a/client/src/app/admin/features/users/users.component.css b/client/src/app/admin/features/users/users.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/admin/features/users/users.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/admin/features/users/users.component.html b/client/src/app/admin/features/users/users.component.html new file mode 100644 index 000000000..cf1edb501 --- /dev/null +++ b/client/src/app/admin/features/users/users.component.html @@ -0,0 +1,31 @@ +
+

Users Management

+ @if (userService.loading()) { } + @else if (userService.users().length === 0) { + + } @else { +
+ + + + + + + + + + + @for (user of userService.users(); track user.id) { + + + + + + + } + +
NameEmailPhoneJoined
{{ user.name }}{{ user.email }}{{ user.phone }}{{ user.createdAt | date:'mediumDate' }}
+
+ + } +
diff --git a/client/src/app/admin/features/users/users.component.spec.ts b/client/src/app/admin/features/users/users.component.spec.ts new file mode 100644 index 000000000..9e204b559 --- /dev/null +++ b/client/src/app/admin/features/users/users.component.spec.ts @@ -0,0 +1,10 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UsersComponent } from './users.component'; +describe('Admin UsersComponent', () => { + let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [UsersComponent] }).compileComponents(); + fixture = TestBed.createComponent(UsersComponent); fixture.detectChanges(); + }); + it('should create', () => { expect(fixture.componentInstance).toBeTruthy(); }); +}); diff --git a/client/src/app/admin/features/users/users.component.ts b/client/src/app/admin/features/users/users.component.ts new file mode 100644 index 000000000..d086bea3c --- /dev/null +++ b/client/src/app/admin/features/users/users.component.ts @@ -0,0 +1,20 @@ +import { Component, inject, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { LoaderComponent } from '../../../shared/components/loader/loader.component'; +import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component'; +import { PaginationComponent } from '../../../shared/components/pagination/pagination.component'; +import { AdminUserService } from '../../services/user.service'; +import { DatePipe } from '@angular/common'; + +@Component({ + selector: 'app-admin-users', standalone: true, + imports: [LoaderComponent, EmptyStateComponent, PaginationComponent,DatePipe], + templateUrl: './users.component.html', styleUrl: './users.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UsersComponent implements OnInit { + readonly userService = inject(AdminUserService); + + ngOnInit(): void { this.userService.loadUsers(); } + + onPageChange(page: number): void { this.userService.loadUsers(page - 1); } +} diff --git a/client/src/app/admin/features/vendors/vendors.component.css b/client/src/app/admin/features/vendors/vendors.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/admin/features/vendors/vendors.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/admin/features/vendors/vendors.component.html b/client/src/app/admin/features/vendors/vendors.component.html new file mode 100644 index 000000000..94748cfe9 --- /dev/null +++ b/client/src/app/admin/features/vendors/vendors.component.html @@ -0,0 +1,31 @@ +
+

Vendors Management

+ @if (vendorService.loading()) { } + @else if (vendorService.vendors().length === 0) { + + } @else { +
+ + + + + + + + + + + @for (vendor of vendorService.vendors(); track vendor.id) { + + + + + + + } + +
NameEmailPhoneJoined
{{ vendor.name }}{{ vendor.email }}{{ vendor.phone }}{{ vendor.createdAt | date:'mediumDate' }}
+
+ + } +
diff --git a/client/src/app/admin/features/vendors/vendors.component.spec.ts b/client/src/app/admin/features/vendors/vendors.component.spec.ts new file mode 100644 index 000000000..eec4337f5 --- /dev/null +++ b/client/src/app/admin/features/vendors/vendors.component.spec.ts @@ -0,0 +1,7 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { VendorsComponent } from './vendors.component'; +describe('Admin VendorsComponent', () => { + let fixture: ComponentFixture; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [VendorsComponent] }).compileComponents(); fixture = TestBed.createComponent(VendorsComponent); fixture.detectChanges(); }); + it('should create', () => { expect(fixture.componentInstance).toBeTruthy(); }); +}); diff --git a/client/src/app/admin/features/vendors/vendors.component.ts b/client/src/app/admin/features/vendors/vendors.component.ts new file mode 100644 index 000000000..c2366609b --- /dev/null +++ b/client/src/app/admin/features/vendors/vendors.component.ts @@ -0,0 +1,20 @@ +import { Component, inject, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { LoaderComponent } from '../../../shared/components/loader/loader.component'; +import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component'; +import { PaginationComponent } from '../../../shared/components/pagination/pagination.component'; +import { AdminVendorService } from '../../services/vendor.service'; +import { DatePipe } from '@angular/common'; + +@Component({ + selector: 'app-admin-vendors', standalone: true, + imports: [LoaderComponent, EmptyStateComponent, PaginationComponent,DatePipe], + templateUrl: './vendors.component.html', styleUrl: './vendors.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VendorsComponent implements OnInit { + readonly vendorService = inject(AdminVendorService); + + ngOnInit(): void { this.vendorService.loadVendors(); } + + onPageChange(page: number): void { this.vendorService.loadVendors(page - 1); } +} diff --git a/client/src/app/admin/features/venues/venues.component.css b/client/src/app/admin/features/venues/venues.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/admin/features/venues/venues.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/admin/features/venues/venues.component.html b/client/src/app/admin/features/venues/venues.component.html new file mode 100644 index 000000000..b4d95b308 --- /dev/null +++ b/client/src/app/admin/features/venues/venues.component.html @@ -0,0 +1,55 @@ +
+

Venues Management

+ @if (venueService.loading()) { + + } @else if (venueService.venues().length === 0) { + + } @else { +
+ + + + + + + + + + + + + @for (venue of venueService.venues(); track venue.id) { + + + + + + + + + } + +
NameDistrictCapacityOwnerStatusActions
{{ venue.name }}{{ venue.district }}{{ venue.capacity }}{{ venue.ownerName }} + + {{ venue.status }} + + + @if (venue.status === 'PENDING_APPROVAL') { + + + } +
+
+ } +
diff --git a/client/src/app/admin/features/venues/venues.component.spec.ts b/client/src/app/admin/features/venues/venues.component.spec.ts new file mode 100644 index 000000000..030e205b4 --- /dev/null +++ b/client/src/app/admin/features/venues/venues.component.spec.ts @@ -0,0 +1,7 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { VenuesComponent } from './venues.component'; +describe('Admin VenuesComponent', () => { + let fixture: ComponentFixture; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [VenuesComponent] }).compileComponents(); fixture = TestBed.createComponent(VenuesComponent); fixture.detectChanges(); }); + it('should create', () => { expect(fixture.componentInstance).toBeTruthy(); }); +}); diff --git a/client/src/app/admin/features/venues/venues.component.ts b/client/src/app/admin/features/venues/venues.component.ts new file mode 100644 index 000000000..173762f75 --- /dev/null +++ b/client/src/app/admin/features/venues/venues.component.ts @@ -0,0 +1,34 @@ +import { Component, inject, OnInit, signal, ChangeDetectionStrategy } from '@angular/core'; +import { LoaderComponent } from '../../../shared/components/loader/loader.component'; +import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component'; +import { AdminVenueService } from '../../services/venue.service'; + +@Component({ + selector: 'app-admin-venues', + standalone: true, + imports: [LoaderComponent, EmptyStateComponent], + templateUrl: './venues.component.html', + styleUrl: './venues.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VenuesComponent implements OnInit { + readonly venueService = inject(AdminVenueService); + readonly actingOnId = signal(null); + + ngOnInit(): void { + this.venueService.loadAllVenues(); + } + + approve(id: number): void { + console.log('id',id); + if (this.actingOnId() !== null) return; + this.actingOnId.set(id); + this.venueService.approveVenue(id, () => this.actingOnId.set(null)); + } + + reject(id: number): void { + if (this.actingOnId() !== null) return; + this.actingOnId.set(id); + this.venueService.rejectVenue(id, () => this.actingOnId.set(null)); + } +} diff --git a/client/src/app/admin/guards/admin-auth.guard.ts b/client/src/app/admin/guards/admin-auth.guard.ts new file mode 100644 index 000000000..46df52d43 --- /dev/null +++ b/client/src/app/admin/guards/admin-auth.guard.ts @@ -0,0 +1,15 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../../shared/services/auth.service'; +import { UserRole } from '../../shared/enums/user-role.enum'; + +export const adminAuthGuard: CanActivateFn = () => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isPortalAuthenticated(UserRole.Admin)) { + return true; + } + router.navigate(['/admin/login']); + return false; +}; diff --git a/client/src/app/admin/layouts/admin-layout/admin-layout.component.css b/client/src/app/admin/layouts/admin-layout/admin-layout.component.css new file mode 100644 index 000000000..065254a3f --- /dev/null +++ b/client/src/app/admin/layouts/admin-layout/admin-layout.component.css @@ -0,0 +1 @@ +:host { display: block; height: 100vh; } diff --git a/client/src/app/admin/layouts/admin-layout/admin-layout.component.html b/client/src/app/admin/layouts/admin-layout/admin-layout.component.html new file mode 100644 index 000000000..2ac7fe338 --- /dev/null +++ b/client/src/app/admin/layouts/admin-layout/admin-layout.component.html @@ -0,0 +1,54 @@ +
+ +
+
+

Admin Panel

+
+ {{ currentUser()?.name }} +
+ {{ currentUser()?.name?.charAt(0) || 'A' }} +
+
+
+
+
+

© 2026 BookMyVenue Admin

+
+
+
diff --git a/client/src/app/admin/layouts/admin-layout/admin-layout.component.spec.ts b/client/src/app/admin/layouts/admin-layout/admin-layout.component.spec.ts new file mode 100644 index 000000000..5ecf25b2c --- /dev/null +++ b/client/src/app/admin/layouts/admin-layout/admin-layout.component.spec.ts @@ -0,0 +1,12 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { AdminLayoutComponent } from './admin-layout.component'; +describe('AdminLayoutComponent', () => { + let component: AdminLayoutComponent; let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [AdminLayoutComponent], providers: [provideRouter([]), provideHttpClient()] }).compileComponents(); + fixture = TestBed.createComponent(AdminLayoutComponent); component = fixture.componentInstance; fixture.detectChanges(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); +}); diff --git a/client/src/app/admin/layouts/admin-layout/admin-layout.component.ts b/client/src/app/admin/layouts/admin-layout/admin-layout.component.ts new file mode 100644 index 000000000..5b9e0efb6 --- /dev/null +++ b/client/src/app/admin/layouts/admin-layout/admin-layout.component.ts @@ -0,0 +1,30 @@ +import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core'; +import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; +import { AuthService } from '../../../shared/services/auth.service'; + +@Component({ + selector: 'app-admin-layout', + standalone: true, + imports: [RouterOutlet, RouterLink, RouterLinkActive], + templateUrl: './admin-layout.component.html', + styleUrl: './admin-layout.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AdminLayoutComponent { + private readonly authService = inject(AuthService); + readonly currentUser = this.authService.currentUser; + readonly sidebarOpen = signal(true); + + readonly navItems = [ + { label: 'Dashboard', path: '/admin/dashboard', icon: 'dashboard' }, + { label: 'Users', path: '/admin/users', icon: 'users' }, + { label: 'Vendors', path: '/admin/vendors', icon: 'vendors' }, + { label: 'Venues', path: '/admin/venues', icon: 'venues' }, + { label: 'Bookings', path: '/admin/bookings', icon: 'bookings' }, + { label: 'Analytics', path: '/admin/analytics', icon: 'analytics' }, + { label: 'Settings', path: '/admin/settings', icon: 'settings' }, + ]; + + toggleSidebar(): void { this.sidebarOpen.update(v => !v); } + logout(): void { this.authService.logout(); } +} diff --git a/client/src/app/admin/models/index.ts b/client/src/app/admin/models/index.ts new file mode 100644 index 000000000..e43d566fc --- /dev/null +++ b/client/src/app/admin/models/index.ts @@ -0,0 +1,13 @@ +export * from '../../shared/models/user.model'; +export * from '../../shared/models/vendor.model'; +export * from '../../shared/models/venue.model'; +export * from '../../shared/models/booking.model'; + +export interface AdminUser { + id: string; + name: string; + email: string; + phone: string; + role: string; + createdAt: string; +} diff --git a/client/src/app/admin/repositories/analytics.repository.ts b/client/src/app/admin/repositories/analytics.repository.ts new file mode 100644 index 000000000..64f939f24 --- /dev/null +++ b/client/src/app/admin/repositories/analytics.repository.ts @@ -0,0 +1,27 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; +import { ApiResponse } from '../../shared/models/api-response.model'; + +export interface AdminAnalyticsData { + totalRevenue: number; + userGrowth: { date: string; count: number }[]; + bookingTrends: { date: string; count: number }[]; + topVenues: { name: string; bookings: number }[]; +} + +@Injectable({ providedIn: 'root' }) +export class AdminAnalyticsRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + getAnalytics(period: string): Observable> { + return this.http.get>(`${this.apiUrl}${API_ENDPOINTS.ADMIN.ANALYTICS}`, { params: { period } }); + } + + getDashboardStats(): Observable> { + return this.http.get>(`${this.apiUrl}${API_ENDPOINTS.ADMIN.DASHBOARD}`); + } +} diff --git a/client/src/app/admin/repositories/booking.repository.ts b/client/src/app/admin/repositories/booking.repository.ts new file mode 100644 index 000000000..df273a321 --- /dev/null +++ b/client/src/app/admin/repositories/booking.repository.ts @@ -0,0 +1,18 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; +import { Booking } from '../../shared/models/booking.model'; +import { PaginationResponse } from '../../shared/models/api-response.model'; + +@Injectable({ providedIn: 'root' }) +export class AdminBookingRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + getBookings(page = 1, limit = 10): Observable> { + const params = new HttpParams().set('page', page).set('limit', limit); + return this.http.get>(`${this.apiUrl}${API_ENDPOINTS.ADMIN.BOOKINGS}`, { params }); + } +} diff --git a/client/src/app/admin/repositories/user.repository.ts b/client/src/app/admin/repositories/user.repository.ts new file mode 100644 index 000000000..66543efbf --- /dev/null +++ b/client/src/app/admin/repositories/user.repository.ts @@ -0,0 +1,18 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; +import { SpringPage } from '../../shared/models/api-response.model'; +import { AdminUser } from '../models'; + +@Injectable({ providedIn: 'root' }) +export class AdminUserRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + getUsers(page = 0, size = 10): Observable> { + const params = new HttpParams().set('page', page).set('size', size); + return this.http.get>(`${this.apiUrl}${API_ENDPOINTS.ADMIN.USERS}`, { params }); + } +} diff --git a/client/src/app/admin/repositories/vendor.repository.ts b/client/src/app/admin/repositories/vendor.repository.ts new file mode 100644 index 000000000..2aae0ccc2 --- /dev/null +++ b/client/src/app/admin/repositories/vendor.repository.ts @@ -0,0 +1,18 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; +import { SpringPage } from '../../shared/models/api-response.model'; +import { AdminUser } from '../models'; + +@Injectable({ providedIn: 'root' }) +export class AdminVendorRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + getVendors(page = 0, size = 10): Observable> { + const params = new HttpParams().set('page', page).set('size', size); + return this.http.get>(`${this.apiUrl}${API_ENDPOINTS.ADMIN.VENDORS}`, { params }); + } +} diff --git a/client/src/app/admin/repositories/venue.repository.ts b/client/src/app/admin/repositories/venue.repository.ts new file mode 100644 index 000000000..fb200a21c --- /dev/null +++ b/client/src/app/admin/repositories/venue.repository.ts @@ -0,0 +1,62 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; + +export interface AdminVenue { + id: number; + name: string; + district: string; + address: string; + capacity: number; + ownerName: string; + ownerEmail: string; + status?: string; +} + +export interface DashboardStats { + totalUsers: number; + totalVendors: number; + totalVenues: number; + pendingVenues: number; + approvedVenues: number; + rejectedVenues: number; + totalBookings: number; +} + +export interface SpringPage { + content: T[]; + totalElements: number; + totalPages: number; + number: number; + size: number; +} + +@Injectable({ providedIn: 'root' }) +export class AdminVenueRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + getDashboard(): Observable { + return this.http.get(`${this.apiUrl}${API_ENDPOINTS.ADMIN.DASHBOARD}`); + } + + getAllVenues(page = 0, size = 10): Observable> { + const params = new HttpParams().set('page', page).set('size', size); + return this.http.get>(`${this.apiUrl}${API_ENDPOINTS.ADMIN.VENUES}`, { params }); + } + + getPendingVenues(page = 0, size = 10): Observable> { + const params = new HttpParams().set('page', page).set('size', size); + return this.http.get>(`${this.apiUrl}${API_ENDPOINTS.ADMIN.PENDING_VENUES}`, { params }); + } + + approveVenue(id: number): Observable { + return this.http.patch(`${this.apiUrl}${API_ENDPOINTS.ADMIN.VENUE_APPROVE(id)}`, {}); + } + + rejectVenue(id: number): Observable { + return this.http.patch(`${this.apiUrl}${API_ENDPOINTS.ADMIN.VENUE_REJECT(id)}`, {}); + } +} diff --git a/client/src/app/admin/routes/admin.routes.ts b/client/src/app/admin/routes/admin.routes.ts new file mode 100644 index 000000000..31632077c --- /dev/null +++ b/client/src/app/admin/routes/admin.routes.ts @@ -0,0 +1,32 @@ +import { Routes } from '@angular/router'; +import { ROUTE_PATHS } from '../../shared/constants/route-paths.constant'; +import { adminAuthGuard } from '../guards/admin-auth.guard'; +import { guestGuard } from '../../shared/guards/guest.guard'; + +export const ADMIN_ROUTES: Routes = [ + { + path: ROUTE_PATHS.ADMIN.LOGIN, + canActivate: [guestGuard], + loadComponent: () => import('../features/login/login.component').then(m => m.LoginComponent), + }, + { + path: ROUTE_PATHS.ADMIN.FORGOT_PASSWORD, + canActivate: [guestGuard], + loadComponent: () => import('../features/forgot-password/forgot-password.component').then(m => m.ForgotPasswordComponent), + }, + { + path: '', + loadComponent: () => import('../layouts/admin-layout/admin-layout.component').then(m => m.AdminLayoutComponent), + canActivate: [adminAuthGuard], + children: [ + { path: ROUTE_PATHS.ADMIN.DASHBOARD, loadComponent: () => import('../features/dashboard/dashboard.component').then(m => m.DashboardComponent) }, + { path: ROUTE_PATHS.ADMIN.USERS, loadComponent: () => import('../features/users/users.component').then(m => m.UsersComponent) }, + { path: ROUTE_PATHS.ADMIN.VENDORS, loadComponent: () => import('../features/vendors/vendors.component').then(m => m.VendorsComponent) }, + { path: ROUTE_PATHS.ADMIN.VENUES, loadComponent: () => import('../features/venues/venues.component').then(m => m.VenuesComponent) }, + { path: ROUTE_PATHS.ADMIN.BOOKINGS, loadComponent: () => import('../features/bookings/bookings.component').then(m => m.BookingsComponent) }, + { path: ROUTE_PATHS.ADMIN.ANALYTICS, loadComponent: () => import('../features/analytics/analytics.component').then(m => m.AnalyticsComponent) }, + { path: ROUTE_PATHS.ADMIN.SETTINGS, loadComponent: () => import('../features/settings/settings.component').then(m => m.SettingsComponent) }, + { path: '', redirectTo: ROUTE_PATHS.ADMIN.DASHBOARD, pathMatch: 'full' }, + ], + }, +]; diff --git a/client/src/app/admin/services/analytics.service.ts b/client/src/app/admin/services/analytics.service.ts new file mode 100644 index 000000000..8d838dd89 --- /dev/null +++ b/client/src/app/admin/services/analytics.service.ts @@ -0,0 +1,20 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { AdminAnalyticsRepository, AdminAnalyticsData } from '../repositories/analytics.repository'; +import { NotificationService } from '../../shared/services/notification.service'; + +@Injectable({ providedIn: 'root' }) +export class AdminAnalyticsService { + private readonly analyticsRepository = inject(AdminAnalyticsRepository); + private readonly notification = inject(NotificationService); + + readonly data = signal(null); + readonly loading = signal(false); + + loadAnalytics(period: string): void { + this.loading.set(true); + this.analyticsRepository.getAnalytics(period).subscribe({ + next: (r) => { this.data.set(r.data); this.loading.set(false); }, + error: () => { this.notification.error('Failed to load analytics'); this.loading.set(false); }, + }); + } +} diff --git a/client/src/app/admin/services/booking.service.ts b/client/src/app/admin/services/booking.service.ts new file mode 100644 index 000000000..1f86f139d --- /dev/null +++ b/client/src/app/admin/services/booking.service.ts @@ -0,0 +1,22 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { AdminBookingRepository } from '../repositories/booking.repository'; +import { Booking } from '../../shared/models/booking.model'; +import { NotificationService } from '../../shared/services/notification.service'; + +@Injectable({ providedIn: 'root' }) +export class AdminBookingService { + private readonly bookingRepository = inject(AdminBookingRepository); + private readonly notification = inject(NotificationService); + + readonly bookings = signal([]); + readonly loading = signal(false); + readonly totalPages = signal(1); + + loadBookings(page = 1): void { + this.loading.set(true); + this.bookingRepository.getBookings(page).subscribe({ + next: (r) => { this.bookings.set(r.data); this.totalPages.set(r.totalPages); this.loading.set(false); }, + error: () => { this.notification.error('Failed to load bookings'); this.loading.set(false); }, + }); + } +} diff --git a/client/src/app/admin/services/user.service.ts b/client/src/app/admin/services/user.service.ts new file mode 100644 index 000000000..440d0a5ee --- /dev/null +++ b/client/src/app/admin/services/user.service.ts @@ -0,0 +1,22 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { AdminUserRepository } from '../repositories/user.repository'; +import { AdminUser } from '../models'; +import { NotificationService } from '../../shared/services/notification.service'; + +@Injectable({ providedIn: 'root' }) +export class AdminUserService { + private readonly userRepository = inject(AdminUserRepository); + private readonly notification = inject(NotificationService); + + readonly users = signal([]); + readonly loading = signal(false); + readonly totalPages = signal(1); + + loadUsers(page = 0): void { + this.loading.set(true); + this.userRepository.getUsers(page).subscribe({ + next: (r) => { this.users.set(r.content); this.totalPages.set(r.totalPages); this.loading.set(false); }, + error: () => { this.notification.error('Failed to load users'); this.loading.set(false); }, + }); + } +} diff --git a/client/src/app/admin/services/vendor.service.ts b/client/src/app/admin/services/vendor.service.ts new file mode 100644 index 000000000..620460d5f --- /dev/null +++ b/client/src/app/admin/services/vendor.service.ts @@ -0,0 +1,22 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { AdminVendorRepository } from '../repositories/vendor.repository'; +import { AdminUser } from '../models'; +import { NotificationService } from '../../shared/services/notification.service'; + +@Injectable({ providedIn: 'root' }) +export class AdminVendorService { + private readonly vendorRepository = inject(AdminVendorRepository); + private readonly notification = inject(NotificationService); + + readonly vendors = signal([]); + readonly loading = signal(false); + readonly totalPages = signal(1); + + loadVendors(page = 0): void { + this.loading.set(true); + this.vendorRepository.getVendors(page).subscribe({ + next: (r) => { this.vendors.set(r.content); this.totalPages.set(r.totalPages); this.loading.set(false); }, + error: () => { this.notification.error('Failed to load vendors'); this.loading.set(false); }, + }); + } +} diff --git a/client/src/app/admin/services/venue.service.ts b/client/src/app/admin/services/venue.service.ts new file mode 100644 index 000000000..eaba6b469 --- /dev/null +++ b/client/src/app/admin/services/venue.service.ts @@ -0,0 +1,52 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { AdminVenueRepository, AdminVenue, DashboardStats } from '../repositories/venue.repository'; +import { NotificationService } from '../../shared/services/notification.service'; + +@Injectable({ providedIn: 'root' }) +export class AdminVenueService { + private readonly venueRepository = inject(AdminVenueRepository); + private readonly notification = inject(NotificationService); + + readonly dashboard = signal(null); + readonly venues = signal([]); + readonly pendingVenues = signal([]); + readonly loading = signal(false); + readonly totalPages = signal(1); + + loadDashboard(): void { + this.venueRepository.getDashboard().subscribe({ + next: (stats) => this.dashboard.set(stats), + error: () => this.notification.error('Failed to load dashboard'), + }); + } + + loadAllVenues(page = 0): void { + this.loading.set(true); + this.venueRepository.getAllVenues(page).subscribe({ + next: (r) => { this.venues.set(r.content); this.totalPages.set(r.totalPages); this.loading.set(false); }, + error: () => { this.notification.error('Failed to load venues'); this.loading.set(false); }, + }); + } + + loadPendingVenues(page = 0): void { + this.loading.set(true); + this.venueRepository.getPendingVenues(page).subscribe({ + next: (r) => { this.pendingVenues.set(r.content); this.totalPages.set(r.totalPages); this.loading.set(false); }, + error: () => { this.notification.error('Failed to load pending venues'); this.loading.set(false); }, + }); + } + + approveVenue(id: number, onDone?: () => void): void { + this.venueRepository.approveVenue(id).subscribe({ + next: () => { this.notification.success('Venue approved'); this.loadAllVenues(); onDone?.(); }, + error: () => { this.notification.error('Failed to approve venue'); onDone?.(); }, + }); + } + + rejectVenue(id: number, onDone?: () => void): void { + this.venueRepository.rejectVenue(id).subscribe({ + next: () => { this.notification.success('Venue rejected'); this.loadAllVenues(); onDone?.(); }, + error: () => { this.notification.error('Failed to reject venue'); onDone?.(); }, + }); + } +} diff --git a/client/src/app/app.config.ts b/client/src/app/app.config.ts new file mode 100644 index 000000000..871355b8a --- /dev/null +++ b/client/src/app/app.config.ts @@ -0,0 +1,26 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; +import { provideHttpClient, withInterceptors, withXsrfConfiguration } from '@angular/common/http'; +import { routes } from './core/app.routes'; +import { appProviders } from './core/providers/app-providers'; +import { errorInterceptor } from './shared/interceptors/error.interceptor'; +import { userAuthInterceptor } from './user/interceptors/user-auth.interceptor'; + + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes, withComponentInputBinding()), + provideHttpClient( + withInterceptors([ + userAuthInterceptor, + errorInterceptor, + ]), + withXsrfConfiguration({ + cookieName: 'XSRF-TOKEN', + headerName: 'X-XSRF-TOKEN', + }) + ), + ...appProviders, + ], +}; diff --git a/client/src/app/app.css b/client/src/app/app.css new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/app.html b/client/src/app/app.html new file mode 100644 index 000000000..cb44d97ec --- /dev/null +++ b/client/src/app/app.html @@ -0,0 +1,2 @@ + + diff --git a/client/src/app/app.routes.ts b/client/src/app/app.routes.ts new file mode 100644 index 000000000..b7d282fdc --- /dev/null +++ b/client/src/app/app.routes.ts @@ -0,0 +1 @@ +export { routes } from './core/app.routes'; diff --git a/client/src/app/app.spec.ts b/client/src/app/app.spec.ts new file mode 100644 index 000000000..5427e1a58 --- /dev/null +++ b/client/src/app/app.spec.ts @@ -0,0 +1,23 @@ +import { TestBed } from '@angular/core/testing'; +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it('should render title', async () => { + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Hello, book-my-venue'); + }); +}); diff --git a/client/src/app/app.ts b/client/src/app/app.ts new file mode 100644 index 000000000..81874a460 --- /dev/null +++ b/client/src/app/app.ts @@ -0,0 +1,12 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { NotificationComponent } from './shared/components/notification/notification.component'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, NotificationComponent], + templateUrl: './app.html', + styleUrl: './app.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class App {} diff --git a/client/src/app/core/app.routes.ts b/client/src/app/core/app.routes.ts new file mode 100644 index 000000000..77aa35590 --- /dev/null +++ b/client/src/app/core/app.routes.ts @@ -0,0 +1,52 @@ +import { Routes } from '@angular/router'; +import { ROUTE_PATHS } from '../shared/constants/route-paths.constant'; +import { guestGuard } from '../shared/guards/guest.guard'; + +export const routes: Routes = [ + { + path: '', + loadComponent: () => import('../landing/landing.component').then(m => m.LandingComponent), + pathMatch: 'full', + }, + { + path: 'login', + canActivate: [guestGuard], + loadComponent: () => import('../user/features/login/login.component').then(m => m.LoginComponent), + }, + { + path: 'signup', + canActivate: [guestGuard], + loadComponent: () => import('../user/features/signup/signup.component').then(m => m.SignupComponent), + }, + { + path: 'forgot-password', + canActivate: [guestGuard], + loadComponent: () => import('../user/features/forgot-password/forgot-password.component').then(m => m.ForgotPasswordComponent), + }, + { + path: 'reset-password', + canActivate: [guestGuard], + loadComponent: () => import('../user/features/reset-password/reset-password.component').then(m => m.ResetPasswordComponent), + }, + { + path: 'verify-email', + canActivate: [guestGuard], + loadComponent: () => import('../shared/features/verify-email/verify-email.component').then(m => m.VerifyEmailComponent), + }, + { + path: ROUTE_PATHS.USER.BASE, + loadChildren: () => import('../user/routes/user.routes').then(m => m.USER_ROUTES), + }, + { + path: ROUTE_PATHS.VENDOR.BASE, + loadChildren: () => import('../vendor/routes/vendor.routes').then(m => m.VENDOR_ROUTES), + }, + { + path: ROUTE_PATHS.ADMIN.BASE, + loadChildren: () => import('../admin/routes/admin.routes').then(m => m.ADMIN_ROUTES), + }, + { + path: '**', + redirectTo: '', + }, +]; diff --git a/client/src/app/core/config/environment.prod.ts b/client/src/app/core/config/environment.prod.ts new file mode 100644 index 000000000..6d8ac42ca --- /dev/null +++ b/client/src/app/core/config/environment.prod.ts @@ -0,0 +1,6 @@ +export const environment = { + production: true, + apiUrl: 'https://api.bookmyvenue.com/api', + tokenKey: 'bmv_token', + refreshTokenKey: 'bmv_refresh_token', +}; diff --git a/client/src/app/core/config/environment.ts b/client/src/app/core/config/environment.ts new file mode 100644 index 000000000..2c1d93ceb --- /dev/null +++ b/client/src/app/core/config/environment.ts @@ -0,0 +1,11 @@ +export const environment = { + production: false, + apiUrl: 'http://localhost:8080/api', + tokenKey: 'bmv_token', + refreshTokenKey: 'bmv_refresh_token', + razorpayKeyId: 'rzp_test_T3DBrC7nq10MPn', + cloudinary:{ + cloudName:'dbigsiz1p', + uploadPreset:'book_my_venue', + }, +}; diff --git a/client/src/app/core/providers/app-providers.ts b/client/src/app/core/providers/app-providers.ts new file mode 100644 index 000000000..98b634880 --- /dev/null +++ b/client/src/app/core/providers/app-providers.ts @@ -0,0 +1,9 @@ +import { Provider } from '@angular/core'; +import { environment } from '../config/environment'; +import { API_URL, TOKEN_KEY, REFRESH_TOKEN_KEY } from '../tokens/injection-tokens'; + +export const appProviders: Provider[] = [ + { provide: API_URL, useValue: environment.apiUrl }, + { provide: TOKEN_KEY, useValue: environment.tokenKey }, + { provide: REFRESH_TOKEN_KEY, useValue: environment.refreshTokenKey }, +]; diff --git a/client/src/app/core/tokens/injection-tokens.ts b/client/src/app/core/tokens/injection-tokens.ts new file mode 100644 index 000000000..8a9185a86 --- /dev/null +++ b/client/src/app/core/tokens/injection-tokens.ts @@ -0,0 +1,5 @@ +import { InjectionToken } from '@angular/core'; + +export const API_URL = new InjectionToken('API_URL'); +export const TOKEN_KEY = new InjectionToken('TOKEN_KEY'); +export const REFRESH_TOKEN_KEY = new InjectionToken('REFRESH_TOKEN_KEY'); diff --git a/client/src/app/landing/landing.component.css b/client/src/app/landing/landing.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/landing/landing.component.html b/client/src/app/landing/landing.component.html new file mode 100644 index 000000000..74cd137b8 --- /dev/null +++ b/client/src/app/landing/landing.component.html @@ -0,0 +1,287 @@ + + + + +
+ + +
+ + +
+
+ +
+ + +
+ + India's Premier Venue Platform +
+ + +

+ Book Venues That Make
+ Moments Unforgettable +

+ + +

+ Discover stunning spaces for weddings, corporate events & celebrations. Instant availability. Transparent pricing. +

+ + +
+
+
+ + + + + +
+
+ + + + +
+ + Search + +
+
+ + +
+ @for (cat of categories; track cat) { + + {{ cat }} + + } +
+ + +
+ @for (stat of stats; track stat.label) { +
+

{{ stat.value }}

+

{{ stat.label }}

+
+ } +
+
+ + +
+ Scroll +
+
+
+ + +
+ +
+ + +
+
+
+

Why BookMyVenue

+

Everything You Need to Plan Your Event

+

From discovery to booking, we've built the most complete venue experience in India.

+
+ +
+ @for (feature of features; track feature.title) { +
+
+ + + +
+

{{ feature.title }}

+

{{ feature.desc }}

+
+ } +
+
+
+ + +
+
+
+

Simple Process

+

Book in 3 Simple Steps

+
+ +
+ + + + @for (step of steps; track step.number) { +
+
+ {{ step.number }} +
+

{{ step.title }}

+

{{ step.desc }}

+
+ } +
+
+
+ + +
+
+
+

For Venue Owners

+

List Your Venue,
Grow Your Business

+

Join hundreds of venue owners earning more with BookMyVenue. Free listing, powerful dashboard, instant notifications.

+ +
+
+ + + diff --git a/client/src/app/landing/landing.component.spec.ts b/client/src/app/landing/landing.component.spec.ts new file mode 100644 index 000000000..77211cb46 --- /dev/null +++ b/client/src/app/landing/landing.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { LandingComponent } from './landing.component'; + +describe('LandingComponent', () => { + let component: LandingComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LandingComponent, RouterTestingModule], + }).compileComponents(); + + fixture = TestBed.createComponent(LandingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/landing/landing.component.ts b/client/src/app/landing/landing.component.ts new file mode 100644 index 000000000..44b8abf02 --- /dev/null +++ b/client/src/app/landing/landing.component.ts @@ -0,0 +1,66 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-landing', + standalone: true, + imports: [RouterLink], + templateUrl: './landing.component.html', + styleUrl: './landing.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LandingComponent { + readonly categories = ['Weddings', 'Corporate Events', 'Birthday Parties', 'Conferences', 'Social Gatherings', 'Product Launches']; + + readonly stats = [ + { value: '500+', label: 'Premium Venues' }, + { value: '10K+', label: 'Events Hosted' }, + { value: '50+', label: 'Cities Covered' }, + { value: '4.8★', label: 'Average Rating' }, + ]; + + readonly featuredVenues = [ + { name: 'The Grand Ballroom', category: 'Banquet Hall', district: 'Ernakulam', capacity: 500, price: '₹25,000/slot', image: 'https://images.unsplash.com/photo-1519167758481-83f550bb49b3?w=600&q=80' }, + { name: 'Skyline Rooftop Terrace', category: 'Rooftop', district: 'Thrissur', capacity: 150, price: '₹12,000/slot', image: 'https://images.unsplash.com/photo-1533174072545-7a4b6ad7a6c3?w=600&q=80' }, + { name: 'Garden Pavilion', category: 'Outdoor', district: 'Kozhikode', capacity: 300, price: '₹18,000/slot', image: 'https://images.unsplash.com/photo-1464366400600-7168b8af9bc3?w=600&q=80' }, + ]; + + readonly features = [ + { + title: 'Smart Search', + desc: 'Filter venues by city, capacity, price range, and amenities to find your perfect match instantly.', + icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z', + }, + { + title: 'Instant Booking', + desc: 'Book your venue in minutes with our streamlined checkout. No phone calls, no waiting.', + icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', + }, + { + title: 'Transparent Pricing', + desc: 'See real-time per-slot pricing with zero hidden charges. Compare venues side by side.', + icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z', + }, + { + title: 'Verified Venues', + desc: 'Every venue is verified by our team. Read genuine reviews from past event organizers.', + icon: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z', + }, + { + title: 'Real-time Availability', + desc: 'Check slot availability instantly. No more back-and-forth calls with venue managers.', + icon: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z', + }, + { + title: 'Vendor Dashboard', + desc: 'Vendors get powerful analytics, booking management, and revenue tracking tools built in.', + icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z', + }, + ]; + + readonly steps = [ + { number: '01', title: 'Search & Discover', desc: 'Browse hundreds of venues. Filter by location, capacity, budget, and event type.' }, + { number: '02', title: 'Compare & Choose', desc: 'View photos, amenities, reviews, and pricing. Pick the venue that fits your vision.' }, + { number: '03', title: 'Book & Celebrate', desc: 'Confirm your slot, complete the booking, and get ready for your perfect event.' }, + ]; +} diff --git a/client/src/app/shared/components/button/button.component.css b/client/src/app/shared/components/button/button.component.css new file mode 100644 index 000000000..48bb062ea --- /dev/null +++ b/client/src/app/shared/components/button/button.component.css @@ -0,0 +1,3 @@ +:host { + display: inline-block; +} diff --git a/client/src/app/shared/components/button/button.component.html b/client/src/app/shared/components/button/button.component.html new file mode 100644 index 000000000..a5463a4c1 --- /dev/null +++ b/client/src/app/shared/components/button/button.component.html @@ -0,0 +1,14 @@ + diff --git a/client/src/app/shared/components/button/button.component.spec.ts b/client/src/app/shared/components/button/button.component.spec.ts new file mode 100644 index 000000000..463d2625c --- /dev/null +++ b/client/src/app/shared/components/button/button.component.spec.ts @@ -0,0 +1,34 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ButtonComponent } from './button.component'; + +describe('ButtonComponent', () => { + let component: ButtonComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ButtonComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ButtonComponent); + fixture.componentRef.setInput('label', 'Test'); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the label', () => { + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + expect(button.textContent?.trim()).toContain('Test'); + }); + + it('should be disabled when disabled input is true', () => { + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + expect(button.disabled).toBeTrue(); + }); +}); diff --git a/client/src/app/shared/components/button/button.component.ts b/client/src/app/shared/components/button/button.component.ts new file mode 100644 index 000000000..9a2d1aba9 --- /dev/null +++ b/client/src/app/shared/components/button/button.component.ts @@ -0,0 +1,44 @@ +import { Component, input, output, computed, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'app-button', + standalone: true, + templateUrl: './button.component.html', + styleUrl: './button.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ButtonComponent { + readonly label = input.required(); + readonly type = input<'button' | 'submit' | 'reset'>('button'); + readonly variant = input<'primary' | 'secondary' | 'danger' | 'outline' | 'ghost'>('primary'); + readonly size = input<'sm' | 'md' | 'lg'>('md'); + readonly disabled = input(false); + readonly loading = input(false); + readonly fullWidth = input(false); + + readonly clicked = output(); + + readonly buttonClasses = computed(() => { + const base = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'; + const variants: Record = { + primary: 'bg-brand-500 text-white hover:bg-brand-600 focus:ring-brand-500 shadow-sm', + secondary: 'bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500', + danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', + outline: 'border border-gray-200 text-gray-700 bg-white hover:bg-gray-50 focus:ring-accent-500', + ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-accent-500', + }; + const sizes: Record = { + sm: 'px-3 py-1.5 text-xs', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-3 text-base', + }; + const width = this.fullWidth() ? 'w-full' : ''; + return `${base} ${variants[this.variant()]} ${sizes[this.size()]} ${width}`; + }); + + onClick(): void { + if (!this.disabled() && !this.loading()) { + this.clicked.emit(); + } + } +} diff --git a/client/src/app/shared/components/confirm-dialog/confirm-dialog.component.css b/client/src/app/shared/components/confirm-dialog/confirm-dialog.component.css new file mode 100644 index 000000000..92d692cdd --- /dev/null +++ b/client/src/app/shared/components/confirm-dialog/confirm-dialog.component.css @@ -0,0 +1,3 @@ +:host { + display: contents; +} diff --git a/client/src/app/shared/components/confirm-dialog/confirm-dialog.component.html b/client/src/app/shared/components/confirm-dialog/confirm-dialog.component.html new file mode 100644 index 000000000..1f4ad9b4b --- /dev/null +++ b/client/src/app/shared/components/confirm-dialog/confirm-dialog.component.html @@ -0,0 +1,38 @@ +@if (isOpen()) { +
+
+
+
+ @if (variant() === 'danger') { +
+ + + +
+ } @else { +
+ + + +
+ } +

{{ title() }}

+
+

{{ message() }}

+
+ + +
+
+
+} diff --git a/client/src/app/shared/components/confirm-dialog/confirm-dialog.component.spec.ts b/client/src/app/shared/components/confirm-dialog/confirm-dialog.component.spec.ts new file mode 100644 index 000000000..31146c197 --- /dev/null +++ b/client/src/app/shared/components/confirm-dialog/confirm-dialog.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ConfirmDialogComponent } from './confirm-dialog.component'; + +describe('ConfirmDialogComponent', () => { + let component: ConfirmDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ConfirmDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ConfirmDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not render when closed', () => { + const overlay = fixture.nativeElement.querySelector('.fixed'); + expect(overlay).toBeNull(); + }); +}); diff --git a/client/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts b/client/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts new file mode 100644 index 000000000..3f0a6a76d --- /dev/null +++ b/client/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts @@ -0,0 +1,28 @@ +import { Component, input, output, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'app-confirm-dialog', + standalone: true, + templateUrl: './confirm-dialog.component.html', + styleUrl: './confirm-dialog.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfirmDialogComponent { + readonly isOpen = input(false); + readonly title = input('Confirm Action'); + readonly message = input('Are you sure you want to proceed?'); + readonly confirmLabel = input('Confirm'); + readonly cancelLabel = input('Cancel'); + readonly variant = input<'danger' | 'warning' | 'info'>('danger'); + + readonly confirmed = output(); + readonly cancelled = output(); + + onConfirm(): void { + this.confirmed.emit(); + } + + onCancel(): void { + this.cancelled.emit(); + } +} diff --git a/client/src/app/shared/components/empty-state/empty-state.component.css b/client/src/app/shared/components/empty-state/empty-state.component.css new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/client/src/app/shared/components/empty-state/empty-state.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/client/src/app/shared/components/empty-state/empty-state.component.html b/client/src/app/shared/components/empty-state/empty-state.component.html new file mode 100644 index 000000000..e1d3b912f --- /dev/null +++ b/client/src/app/shared/components/empty-state/empty-state.component.html @@ -0,0 +1,36 @@ +
+
+ @switch (icon()) { + @case ('search') { + + + + } + @case ('calendar') { + + + + } + @case ('users') { + + + + } + @default { + + + + } + } +
+

{{ title() }}

+

{{ message() }}

+ @if (actionLabel()) { + + } +
diff --git a/client/src/app/shared/components/empty-state/empty-state.component.spec.ts b/client/src/app/shared/components/empty-state/empty-state.component.spec.ts new file mode 100644 index 000000000..b98fc8b1a --- /dev/null +++ b/client/src/app/shared/components/empty-state/empty-state.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { EmptyStateComponent } from './empty-state.component'; + +describe('EmptyStateComponent', () => { + let component: EmptyStateComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EmptyStateComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(EmptyStateComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display default title', () => { + const title = fixture.nativeElement.querySelector('h3'); + expect(title.textContent).toContain('No data found'); + }); +}); diff --git a/client/src/app/shared/components/empty-state/empty-state.component.ts b/client/src/app/shared/components/empty-state/empty-state.component.ts new file mode 100644 index 000000000..c16f8e7ae --- /dev/null +++ b/client/src/app/shared/components/empty-state/empty-state.component.ts @@ -0,0 +1,21 @@ +import { Component, input, output, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'app-empty-state', + standalone: true, + templateUrl: './empty-state.component.html', + styleUrl: './empty-state.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EmptyStateComponent { + readonly title = input('No data found'); + readonly message = input('There are no items to display at the moment.'); + readonly icon = input<'search' | 'inbox' | 'calendar' | 'users'>('inbox'); + readonly actionLabel = input(''); + + readonly actionClicked = output(); + + onAction(): void { + this.actionClicked.emit(); + } +} diff --git a/client/src/app/shared/components/input/input.component.css b/client/src/app/shared/components/input/input.component.css new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/client/src/app/shared/components/input/input.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/client/src/app/shared/components/input/input.component.html b/client/src/app/shared/components/input/input.component.html new file mode 100644 index 000000000..f39f06d15 --- /dev/null +++ b/client/src/app/shared/components/input/input.component.html @@ -0,0 +1,26 @@ +
+ @if (label()) { + + } + + @if (error()) { +

{{ error() }}

+ } + @if (hint() && !error()) { +

{{ hint() }}

+ } +
diff --git a/client/src/app/shared/components/input/input.component.spec.ts b/client/src/app/shared/components/input/input.component.spec.ts new file mode 100644 index 000000000..f48a773d0 --- /dev/null +++ b/client/src/app/shared/components/input/input.component.spec.ts @@ -0,0 +1,35 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { InputComponent } from './input.component'; + +describe('InputComponent', () => { + let component: InputComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InputComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(InputComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display label when provided', () => { + fixture.componentRef.setInput('label', 'Email'); + fixture.detectChanges(); + const label = fixture.nativeElement.querySelector('label'); + expect(label.textContent).toContain('Email'); + }); + + it('should show error message', () => { + fixture.componentRef.setInput('error', 'Required field'); + fixture.detectChanges(); + const error = fixture.nativeElement.querySelector('p'); + expect(error.textContent).toContain('Required field'); + }); +}); diff --git a/client/src/app/shared/components/input/input.component.ts b/client/src/app/shared/components/input/input.component.ts new file mode 100644 index 000000000..008cac10c --- /dev/null +++ b/client/src/app/shared/components/input/input.component.ts @@ -0,0 +1,59 @@ +import { Component, input, output, signal, ChangeDetectionStrategy, forwardRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +@Component({ + selector: 'app-input', + standalone: true, + templateUrl: './input.component.html', + styleUrl: './input.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => InputComponent), + multi: true, + }, + ], +}) +export class InputComponent implements ControlValueAccessor { + readonly label = input(''); + readonly placeholder = input(''); + readonly type = input<'text' | 'email' | 'password' | 'number' | 'tel' | 'date' | 'time'>('text'); + readonly error = input(''); + readonly hint = input(''); + readonly required = input(false); + readonly disabled = input(false); + + readonly value = signal(''); + readonly focused = signal(false); + + private onChange: (value: string) => void = () => {}; + private onTouched: () => void = () => {}; + + writeValue(value: string): void { + this.value.set(value || ''); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + onInput(event: Event): void { + const target = event.target as HTMLInputElement; + this.value.set(target.value); + this.onChange(target.value); + } + + onBlur(): void { + this.focused.set(false); + this.onTouched(); + } + + onFocus(): void { + this.focused.set(true); + } +} diff --git a/client/src/app/shared/components/loader/loader.component.css b/client/src/app/shared/components/loader/loader.component.css new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/client/src/app/shared/components/loader/loader.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/client/src/app/shared/components/loader/loader.component.html b/client/src/app/shared/components/loader/loader.component.html new file mode 100644 index 000000000..8eba81fa3 --- /dev/null +++ b/client/src/app/shared/components/loader/loader.component.html @@ -0,0 +1,17 @@ +@if (overlay()) { +
+
+
+ @if (message()) { +

{{ message() }}

+ } +
+
+} @else { +
+
+ @if (message()) { +

{{ message() }}

+ } +
+} diff --git a/client/src/app/shared/components/loader/loader.component.spec.ts b/client/src/app/shared/components/loader/loader.component.spec.ts new file mode 100644 index 000000000..5f536fa25 --- /dev/null +++ b/client/src/app/shared/components/loader/loader.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LoaderComponent } from './loader.component'; + +describe('LoaderComponent', () => { + let component: LoaderComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LoaderComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(LoaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/loader/loader.component.ts b/client/src/app/shared/components/loader/loader.component.ts new file mode 100644 index 000000000..d6b9e0a86 --- /dev/null +++ b/client/src/app/shared/components/loader/loader.component.ts @@ -0,0 +1,23 @@ +import { Component, input, computed, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'app-loader', + standalone: true, + templateUrl: './loader.component.html', + styleUrl: './loader.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LoaderComponent { + readonly size = input<'sm' | 'md' | 'lg'>('md'); + readonly overlay = input(false); + readonly message = input(''); + + readonly spinnerClass = computed(() => { + const sizes: Record = { + sm: 'w-6 h-6', + md: 'w-10 h-10', + lg: 'w-16 h-16', + }; + return `${sizes[this.size()]} border-4 border-gray-200 border-t-indigo-600 rounded-full animate-spin`; + }); +} diff --git a/client/src/app/shared/components/modal/modal.component.css b/client/src/app/shared/components/modal/modal.component.css new file mode 100644 index 000000000..92d692cdd --- /dev/null +++ b/client/src/app/shared/components/modal/modal.component.css @@ -0,0 +1,3 @@ +:host { + display: contents; +} diff --git a/client/src/app/shared/components/modal/modal.component.html b/client/src/app/shared/components/modal/modal.component.html new file mode 100644 index 000000000..ef59ff470 --- /dev/null +++ b/client/src/app/shared/components/modal/modal.component.html @@ -0,0 +1,23 @@ +@if (isOpen()) { +
+
+
+
+

{{ title() }}

+ +
+
+ +
+
+
+} diff --git a/client/src/app/shared/components/modal/modal.component.spec.ts b/client/src/app/shared/components/modal/modal.component.spec.ts new file mode 100644 index 000000000..afdabab00 --- /dev/null +++ b/client/src/app/shared/components/modal/modal.component.spec.ts @@ -0,0 +1,34 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ModalComponent } from './modal.component'; + +describe('ModalComponent', () => { + let component: ModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ModalComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not render when closed', () => { + const overlay = fixture.nativeElement.querySelector('.fixed'); + expect(overlay).toBeNull(); + }); + + it('should render when open', () => { + fixture.componentRef.setInput('isOpen', true); + fixture.componentRef.setInput('title', 'Test Modal'); + fixture.detectChanges(); + const title = fixture.nativeElement.querySelector('h3'); + expect(title.textContent).toContain('Test Modal'); + }); +}); diff --git a/client/src/app/shared/components/modal/modal.component.ts b/client/src/app/shared/components/modal/modal.component.ts new file mode 100644 index 000000000..4dc53372f --- /dev/null +++ b/client/src/app/shared/components/modal/modal.component.ts @@ -0,0 +1,34 @@ +import { Component, input, output, computed, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'app-modal', + standalone: true, + templateUrl: './modal.component.html', + styleUrl: './modal.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ModalComponent { + readonly isOpen = input(false); + readonly title = input(''); + readonly size = input<'sm' | 'md' | 'lg' | 'xl'>('md'); + + readonly closed = output(); + + readonly sizeClass = computed(() => { + const sizes: Record = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + xl: 'max-w-xl', + }; + return sizes[this.size()]; + }); + + onBackdropClick(): void { + this.closed.emit(); + } + + onClose(): void { + this.closed.emit(); + } +} diff --git a/client/src/app/shared/components/notification/notification.component.css b/client/src/app/shared/components/notification/notification.component.css new file mode 100644 index 000000000..9215f3560 --- /dev/null +++ b/client/src/app/shared/components/notification/notification.component.css @@ -0,0 +1,139 @@ +.notification-container { + position: fixed; + top: 1.5rem; + right: 1.5rem; + z-index: 100; + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 24rem; + width: calc(100% - 3rem); + pointer-events: none; +} + +.notification-toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: 0.875rem; + padding: 1rem; + border-radius: 1rem; + border-width: 1px; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.03); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.toast-icon { + flex-shrink: 0; + width: 1.25rem; + height: 1.25rem; +} + +.toast-icon svg { + width: 100%; + height: 100%; +} + +.toast-content { + flex-grow: 1; +} + +.toast-message { + margin: 0; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25rem; + color: #1f2937; +} + +.toast-close { + flex-shrink: 0; + width: 1.25rem; + height: 1.25rem; + padding: 0; + background: transparent; + border: none; + color: #9ca3af; + cursor: pointer; + border-radius: 0.375rem; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.toast-close:hover { + color: #4b5563; + background-color: rgba(0, 0, 0, 0.05); +} + +.toast-close svg { + width: 1rem; + height: 1rem; +} + +/* Success Toast */ +.notification-toast.success { + background-color: rgba(236, 253, 245, 0.9); + border-color: rgba(16, 185, 129, 0.2); +} +.notification-toast.success .toast-icon { + color: #059669; +} +.notification-toast.success .toast-message { + color: #065f46; +} + +/* Error Toast */ +.notification-toast.error { + background-color: rgba(254, 242, 242, 0.9); + border-color: rgba(239, 68, 68, 0.2); +} +.notification-toast.error .toast-icon { + color: #dc2626; +} +.notification-toast.error .toast-message { + color: #991b1b; +} + +/* Warning Toast */ +.notification-toast.warning { + background-color: rgba(255, 251, 235, 0.9); + border-color: rgba(245, 158, 11, 0.2); +} +.notification-toast.warning .toast-icon { + color: #d97706; +} +.notification-toast.warning .toast-message { + color: #92400e; +} + +/* Info Toast */ +.notification-toast.info { + background-color: rgba(240, 249, 255, 0.9); + border-color: rgba(14, 165, 233, 0.2); +} +.notification-toast.info .toast-icon { + color: #0284c7; +} +.notification-toast.info .toast-message { + color: #075985; +} + +/* Animations */ +@keyframes slide-in-right { + from { + opacity: 0; + transform: translateX(1.5rem); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.animate-slide-in { + animation: slide-in-right 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} diff --git a/client/src/app/shared/components/notification/notification.component.html b/client/src/app/shared/components/notification/notification.component.html new file mode 100644 index 000000000..2f4cd9e83 --- /dev/null +++ b/client/src/app/shared/components/notification/notification.component.html @@ -0,0 +1,47 @@ +
+ @for (notification of notifications(); track notification.id) { + + } +
diff --git a/client/src/app/shared/components/notification/notification.component.ts b/client/src/app/shared/components/notification/notification.component.ts new file mode 100644 index 000000000..d55e61318 --- /dev/null +++ b/client/src/app/shared/components/notification/notification.component.ts @@ -0,0 +1,20 @@ +import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; +import { NgClass } from '@angular/common'; +import { NotificationService } from '../../services/notification.service'; + +@Component({ + selector: 'app-notification', + standalone: true, + imports: [NgClass], + templateUrl: './notification.component.html', + styleUrl: './notification.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NotificationComponent { + readonly notificationService = inject(NotificationService); + readonly notifications = this.notificationService.notifications; + + dismiss(id: string): void { + this.notificationService.dismiss(id); + } +} diff --git a/client/src/app/shared/components/pagination/pagination.component.css b/client/src/app/shared/components/pagination/pagination.component.css new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/client/src/app/shared/components/pagination/pagination.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/client/src/app/shared/components/pagination/pagination.component.html b/client/src/app/shared/components/pagination/pagination.component.html new file mode 100644 index 000000000..b865e5786 --- /dev/null +++ b/client/src/app/shared/components/pagination/pagination.component.html @@ -0,0 +1,34 @@ +
+

+ Showing page {{ currentPage() }} of {{ totalPages() }} ({{ totalItems() }} items) +

+ +
diff --git a/client/src/app/shared/components/pagination/pagination.component.spec.ts b/client/src/app/shared/components/pagination/pagination.component.spec.ts new file mode 100644 index 000000000..7dec530c8 --- /dev/null +++ b/client/src/app/shared/components/pagination/pagination.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PaginationComponent } from './pagination.component'; + +describe('PaginationComponent', () => { + let component: PaginationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PaginationComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PaginationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should disable previous on first page', () => { + expect(component.hasPrevious()).toBeFalse(); + }); +}); diff --git a/client/src/app/shared/components/pagination/pagination.component.ts b/client/src/app/shared/components/pagination/pagination.component.ts new file mode 100644 index 000000000..d7e743cd8 --- /dev/null +++ b/client/src/app/shared/components/pagination/pagination.component.ts @@ -0,0 +1,59 @@ +import { Component, input, output, computed, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'app-pagination', + standalone: true, + templateUrl: './pagination.component.html', + styleUrl: './pagination.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PaginationComponent { + readonly currentPage = input(1); + readonly totalPages = input(1); + readonly totalItems = input(0); + readonly pageSize = input(10); + + readonly pageChanged = output(); + + readonly pages = computed(() => { + const total = this.totalPages(); + const current = this.currentPage(); + const pages: (number | '...')[] = []; + + if (total <= 7) { + for (let i = 1; i <= total; i++) pages.push(i); + } else { + pages.push(1); + if (current > 3) pages.push('...'); + for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) { + pages.push(i); + } + if (current < total - 2) pages.push('...'); + pages.push(total); + } + + return pages; + }); + + readonly hasPrevious = computed(() => this.currentPage() > 1); + readonly hasNext = computed(() => this.currentPage() < this.totalPages()); + + goToPage(page: number | '...'): void { + if (page === '...') return; + if (page >= 1 && page <= this.totalPages()) { + this.pageChanged.emit(page); + } + } + + previousPage(): void { + if (this.hasPrevious()) { + this.pageChanged.emit(this.currentPage() - 1); + } + } + + nextPage(): void { + if (this.hasNext()) { + this.pageChanged.emit(this.currentPage() + 1); + } + } +} diff --git a/client/src/app/shared/components/table/table.component.css b/client/src/app/shared/components/table/table.component.css new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/client/src/app/shared/components/table/table.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/client/src/app/shared/components/table/table.component.html b/client/src/app/shared/components/table/table.component.html new file mode 100644 index 000000000..1b5c1ac79 --- /dev/null +++ b/client/src/app/shared/components/table/table.component.html @@ -0,0 +1,54 @@ +
+ + + + @for (col of columns(); track col.key) { + + } + + + + @if (loading()) { + + + + } @else if (data().length === 0) { + + + + } @else { + @for (item of data(); track $index; let odd = $odd) { + + @if (rowTemplate()) { + + } @else { + @for (col of columns(); track col.key) { + + } + } + + } + } + +
+ {{ col.label }} + @if (col.sortable) { + + + + } +
+ Loading... +
+ No data available +
{{ getCellValue(item, col.key) }}
+
diff --git a/client/src/app/shared/components/table/table.component.spec.ts b/client/src/app/shared/components/table/table.component.spec.ts new file mode 100644 index 000000000..e8d27b1be --- /dev/null +++ b/client/src/app/shared/components/table/table.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TableComponent, TableColumn } from './table.component'; + +describe('TableComponent', () => { + let component: TableComponent; + let fixture: ComponentFixture; + + const columns: TableColumn[] = [ + { key: 'name', label: 'Name' }, + { key: 'email', label: 'Email' }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TableComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TableComponent); + fixture.componentRef.setInput('columns', columns); + fixture.componentRef.setInput('data', []); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show no data message when empty', () => { + const cell = fixture.nativeElement.querySelector('td'); + expect(cell.textContent).toContain('No data available'); + }); +}); diff --git a/client/src/app/shared/components/table/table.component.ts b/client/src/app/shared/components/table/table.component.ts new file mode 100644 index 000000000..c3e91509c --- /dev/null +++ b/client/src/app/shared/components/table/table.component.ts @@ -0,0 +1,51 @@ +import { Component, input, output, ChangeDetectionStrategy, TemplateRef, contentChild } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; + +export interface TableColumn { + key: string; + label: string; + sortable?: boolean; + width?: string; +} + +@Component({ + selector: 'app-table', + standalone: true, + imports: [NgTemplateOutlet], + templateUrl: './table.component.html', + styleUrl: './table.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TableComponent> { + readonly columns = input.required(); + readonly data = input.required(); + readonly loading = input(false); + readonly striped = input(true); + + readonly rowClicked = output(); + readonly sortChanged = output<{ key: string; direction: 'asc' | 'desc' }>(); + + readonly rowTemplate = contentChild>('rowTemplate'); + + private sortKey = ''; + private sortDirection: 'asc' | 'desc' = 'asc'; + + onSort(column: TableColumn): void { + if (!column.sortable) return; + if (this.sortKey === column.key) { + this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + this.sortKey = column.key; + this.sortDirection = 'asc'; + } + this.sortChanged.emit({ key: this.sortKey, direction: this.sortDirection }); + } + + onRowClick(item: T): void { + this.rowClicked.emit(item); + } + + getCellValue(item: T, key: string): unknown { + return (item as Record)[key]; + } +} diff --git a/client/src/app/shared/constants/api-endpoints.constant.ts b/client/src/app/shared/constants/api-endpoints.constant.ts new file mode 100644 index 000000000..f7ba76df6 --- /dev/null +++ b/client/src/app/shared/constants/api-endpoints.constant.ts @@ -0,0 +1,66 @@ +export const API_ENDPOINTS = { + AUTH: { + LOGIN: '/v1/auth/login', + SIGNUP: '/v1/auth/register', + LOGOUT: '/v1/auth/logout', + REFRESH_TOKEN: '/v1/auth/refresh-token', + FORGOT_PASSWORD: '/v1/auth/forgot-password', + RESET_PASSWORD: '/v1/auth/reset-password', + VERIFY_EMAIL: '/v1/auth/verify-email', + RESEND_VERIFICATION: '/v1/auth/resend-verification', + ADMIN_LOGIN: '/v1/auth/login', + }, + CATEGORIES: { + BASE: '/v1/categories', + }, + USERS: { + BASE: '/v1/users', + PROFILE: '/v1/users/profile', + BY_ID: (id: string) => `/v1/users/${id}`, + }, + VENDORS: { + BASE: '/vendors', + PROFILE: '/vendors/profile', + BY_ID: (id: string) => `/vendors/${id}`, + ANALYTICS: '/vendors/analytics', + }, + VENUES: { + // Public endpoints (no auth required) + PUBLIC_BASE: '/v1/venues', + PUBLIC_BY_ID: (id: string) => `/v1/venues/${id}`, + AVAILABILITY: (venueId: string) => `/v1/venues/${venueId}/availability`, + + // Vendor endpoints (require VENDOR role) + + VENDOR_BASE: '/v1/vendor/venues', + VENDOR_BY_ID: (id: string) => `/v1/vendor/venues/${id}`, + SLOTS_BY_VENUE: (venueId: string) => `/v1/venues/${venueId}/slots`, + DELETE_SLOT: (templateId: number) => `/v1/venues/slots/${templateId}`, + }, + BOOKINGS: { + BASE: '/bookings', + BY_ID: (id: string) => `/bookings/${id}`, + BY_USER: (userId: string) => `/users/${userId}/bookings`, + BY_VENUE: (venueId: string) => `/venues/${venueId}/bookings`, + BY_VENDOR: '/vendor/bookings', + MY_BOOKINGS: '/v1/bookings/my-bookings', + CREATE_WITH_SLOT: (venueId: string, slotId: number) => `/v1/venues/${venueId}/slots/${slotId}/bookings`, + CANCEL: (bookingId: string) => `/v1/bookings/${bookingId}/cancel`, + + }, + ADMIN: { + DASHBOARD: '/v1/admin/dashboard', + USERS: '/v1/admin/users', + VENDORS: '/v1/admin/vendors', + VENUES: '/v1/admin/venues', + PENDING_VENUES: '/v1/admin/venues/pending', + VENUE_APPROVE: (id: number) => `/v1/admin/venues/${id}/approve`, + VENUE_REJECT: (id: number) => `/v1/admin/venues/${id}/reject`, + BOOKINGS: '/v1/admin/bookings', + ANALYTICS: '/v1/admin/analytics', + }, + PAYMENTS: { + CREATE: (bookingId: number) => `/v1/payments/${bookingId}`, + VERIFY: '/v1/payments/verify', + }, +} as const; diff --git a/client/src/app/shared/constants/auth-errors.constant.ts b/client/src/app/shared/constants/auth-errors.constant.ts new file mode 100644 index 000000000..9eb1de783 --- /dev/null +++ b/client/src/app/shared/constants/auth-errors.constant.ts @@ -0,0 +1,8 @@ +export const AUTH_ERRORS = { + INVALID_OTP: 'INVALID_OTP', + EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED', + OTP_EXPIRED: 'OTP_EXPIRED', + EMAIL_ALREADY_EXISTS: 'EMAIL_ALREADY_EXISTS', +} as const; + +export type AuthErrorCode = typeof AUTH_ERRORS[keyof typeof AUTH_ERRORS]; \ No newline at end of file diff --git a/client/src/app/shared/constants/index.ts b/client/src/app/shared/constants/index.ts new file mode 100644 index 000000000..de6edb234 --- /dev/null +++ b/client/src/app/shared/constants/index.ts @@ -0,0 +1,3 @@ +export * from './api-endpoints.constant'; +export * from './route-paths.constant'; +export * from './auth-errors.constant'; diff --git a/client/src/app/shared/constants/route-paths.constant.ts b/client/src/app/shared/constants/route-paths.constant.ts new file mode 100644 index 000000000..d8f2f585f --- /dev/null +++ b/client/src/app/shared/constants/route-paths.constant.ts @@ -0,0 +1,42 @@ +export const ROUTE_PATHS = { + USER: { + BASE: 'user', + LOGIN: 'login', + SIGNUP: 'register', + FORGOT_PASSWORD: 'forgot-password', + RESET_PASSWORD: 'reset-password', + DASHBOARD: 'dashboard', + VENUES: 'venues', + VENUE_DETAILS: 'venues/:id', + CHECKOUT: 'checkout/:venueId', + MY_BOOKINGS: 'my-bookings', + MY_BOOKING_DETAIL: 'my-bookings/:id', + PROFILE: 'profile', + }, + VENDOR: { + BASE: 'vendor', + LOGIN: 'login', + SIGNUP: 'register', + FORGOT_PASSWORD: 'forgot-password', + DASHBOARD: 'dashboard', + VENUES: 'venues', + CREATE_VENUE: 'venues/create', + EDIT_VENUE: 'venues/:id/edit', + BOOKINGS: 'bookings', + ANALYTICS: 'analytics', + SETTINGS: 'settings', + VENUE_SLOTS: 'venues/:id/slots', + }, + ADMIN: { + BASE: 'admin', + LOGIN: 'login', + FORGOT_PASSWORD: 'forgot-password', + DASHBOARD: 'dashboard', + USERS: 'users', + VENDORS: 'vendors', + VENUES: 'venues', + BOOKINGS: 'bookings', + ANALYTICS: 'analytics', + SETTINGS: 'settings', + }, +} as const; diff --git a/client/src/app/shared/directives/click-outside.directive.ts b/client/src/app/shared/directives/click-outside.directive.ts new file mode 100644 index 000000000..36e3e0c99 --- /dev/null +++ b/client/src/app/shared/directives/click-outside.directive.ts @@ -0,0 +1,18 @@ +import { Directive, ElementRef, inject, output } from '@angular/core'; +import { fromEvent, filter } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Directive({ selector: '[appClickOutside]', standalone: true }) +export class ClickOutsideDirective { + private readonly elementRef = inject(ElementRef); + readonly appClickOutside = output(); + + constructor() { + fromEvent(document, 'click') + .pipe( + filter((event) => !this.elementRef.nativeElement.contains(event.target as Node)), + takeUntilDestroyed() + ) + .subscribe(() => this.appClickOutside.emit()); + } +} diff --git a/client/src/app/shared/enums/booking-status.enum.ts b/client/src/app/shared/enums/booking-status.enum.ts new file mode 100644 index 000000000..bc31ecbcb --- /dev/null +++ b/client/src/app/shared/enums/booking-status.enum.ts @@ -0,0 +1,6 @@ +export enum BookingStatus { + PENDING = 'PENDING', + CONFIRMED = 'CONFIRMED', + CANCELLED = 'CANCELLED', + EXPIRED = 'EXPIRED', +} diff --git a/client/src/app/shared/enums/index.ts b/client/src/app/shared/enums/index.ts new file mode 100644 index 000000000..88dece146 --- /dev/null +++ b/client/src/app/shared/enums/index.ts @@ -0,0 +1,3 @@ +export * from './user-role.enum'; +export * from './booking-status.enum'; +export * from './venue-status.enum'; diff --git a/client/src/app/shared/enums/user-role.enum.ts b/client/src/app/shared/enums/user-role.enum.ts new file mode 100644 index 000000000..bc5f9cb01 --- /dev/null +++ b/client/src/app/shared/enums/user-role.enum.ts @@ -0,0 +1,5 @@ +export enum UserRole { + User = 'USER', + Vendor = 'VENDOR', + Admin = 'ADMIN', +} diff --git a/client/src/app/shared/enums/venue-status.enum.ts b/client/src/app/shared/enums/venue-status.enum.ts new file mode 100644 index 000000000..7a25f16cd --- /dev/null +++ b/client/src/app/shared/enums/venue-status.enum.ts @@ -0,0 +1,6 @@ +export enum VenueStatus { + PENDING_APPROVAL = 'PENDING_APPROVAL', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + INACTIVE = 'INACTIVE', +} diff --git a/client/src/app/shared/features/verify-email/verify-email.component.css b/client/src/app/shared/features/verify-email/verify-email.component.css new file mode 100644 index 000000000..0511dbbfc --- /dev/null +++ b/client/src/app/shared/features/verify-email/verify-email.component.css @@ -0,0 +1,13 @@ +:host { + display: block; +} + +input[type="text"] { + -moz-appearance: textfield; +} + +input[type="text"]::-webkit-outer-spin-button, +input[type="text"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} \ No newline at end of file diff --git a/client/src/app/shared/features/verify-email/verify-email.component.html b/client/src/app/shared/features/verify-email/verify-email.component.html new file mode 100644 index 000000000..a28931af2 --- /dev/null +++ b/client/src/app/shared/features/verify-email/verify-email.component.html @@ -0,0 +1,114 @@ +
+ + + + + +
+ + +
+
+ + + +
+ BookMyVenue +
+ +
+ + + + + + + Back to signup + + + +
+

Verify your email

+

Enter the 6-digit code sent to {{ email() }}

+
+ + + @if (error()) { +
+

{{ error() }}

+
+ } + + +
+
+ @for (control of ['otp1', 'otp2', 'otp3', 'otp4', 'otp5', 'otp6']; track control; let i = $index) { + + } +
+ + + + + +
+ @if (countdown() > 0) { +

Resend code in {{ countdown() }}s

+ } @else { + + } +
+
+
+ +
\ No newline at end of file diff --git a/client/src/app/shared/features/verify-email/verify-email.component.ts b/client/src/app/shared/features/verify-email/verify-email.component.ts new file mode 100644 index 000000000..f7369237e --- /dev/null +++ b/client/src/app/shared/features/verify-email/verify-email.component.ts @@ -0,0 +1,144 @@ +import { Component, inject, signal, ChangeDetectionStrategy, ViewChildren, QueryList, ElementRef } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators, FormGroup, FormControl } from '@angular/forms'; +import { Router, ActivatedRoute, RouterLink } from '@angular/router'; +import { AuthService } from '../../services/auth.service'; +import { ButtonComponent } from '../../components/button/button.component'; +import { AUTH_ERRORS } from '../../constants/auth-errors.constant'; +import { Subscription, interval, takeWhile } from 'rxjs'; + +@Component({ + selector: 'app-verify-email', + standalone: true, + imports: [ReactiveFormsModule, RouterLink, ButtonComponent], + templateUrl: './verify-email.component.html', + styleUrl: './verify-email.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VerifyEmailComponent { + private readonly fb = inject(FormBuilder); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly authService = inject(AuthService); + + readonly email = signal(''); + readonly portal = signal<'user' | 'vendor' | 'admin'>('user'); + readonly loading = signal(false); + readonly error = signal(''); + readonly countdown = signal(0); + readonly resendLoading = signal(false); + + private countdownSubscription: Subscription | null = null; + + @ViewChildren('otpInput') otpInputs!: QueryList>; + + readonly form = this.fb.group({ + otp1: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(1)]], + otp2: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(1)]], + otp3: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(1)]], + otp4: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(1)]], + otp5: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(1)]], + otp6: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(1)]], + }); + + constructor() { + const emailParam = this.route.snapshot.queryParamMap.get('email'); + const portalParam = this.route.snapshot.queryParamMap.get('portal') as 'user' | 'vendor' | 'admin' | null; + + if (!emailParam) { + this.router.navigate(['/signup']); + return; + } + + this.email.set(emailParam); + this.portal.set(portalParam || 'user'); + this.startCountdown(); + } + + onInput(event: Event, index: number): void { + const input = event.target as HTMLInputElement; + const value = input.value; + + if (value.length === 1 && index < 5) { + const inputs = this.otpInputs.toArray(); + inputs[index + 1].nativeElement.focus(); + } + } + + onPaste(event: ClipboardEvent): void { + event.preventDefault(); + const pastedData = event.clipboardData?.getData('text'); + if (pastedData && pastedData.length >= 6) { + const otpArray = pastedData.slice(0, 6).split(''); + otpArray.forEach((char, index) => { + const controlName = `otp${index + 1}` as keyof typeof this.form.controls; + this.form.get(controlName)?.setValue(char); + }); + const inputs = this.otpInputs.toArray(); + inputs[5].nativeElement.focus(); + } + } + + onKeyDown(event: KeyboardEvent, index: number): void { + if (event.key === 'Backspace') { + const control = this.form.get(`otp${index + 1}`); + if (control?.value === '' && index > 0) { + const inputs = this.otpInputs.toArray(); + inputs[index - 1].nativeElement.focus(); + } + } + } + + onSubmit(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + const otp = Object.values(this.form.value).join(''); + this.loading.set(true); + this.error.set(''); + + this.authService.verifyEmail({ email: this.email(), otp }).subscribe({ + next: () => { + this.loading.set(false); + this.router.navigate(['/login'], { queryParams: { portal: this.portal() } }); + }, + error: (err) => { + this.loading.set(false); + if (err.error?.code === AUTH_ERRORS.INVALID_OTP || err.error?.code === AUTH_ERRORS.OTP_EXPIRED) { + this.error.set(err.error?.message || 'Invalid or expired OTP'); + } else { + this.error.set('Verification failed. Please try again.'); + } + }, + }); + } + + onResend(): void { + if (this.countdown() > 0 || this.resendLoading()) return; + + this.resendLoading.set(true); + this.error.set(''); + + this.authService.resendOtp({ email: this.email() }).subscribe({ + next: () => { + this.resendLoading.set(false); + this.startCountdown(); + }, + error: () => { + this.resendLoading.set(false); + this.error.set('Failed to resend OTP. Please try again.'); + }, + }); + } + + private startCountdown(): void { + this.countdown.set(30); + this.countdownSubscription?.unsubscribe(); + this.countdownSubscription = interval(1000) + .pipe(takeWhile(() => this.countdown() > 0)) + .subscribe(() => { + this.countdown.update((v) => v - 1); + }); + } +} \ No newline at end of file diff --git a/client/src/app/shared/guards/admin-auth.guard.ts b/client/src/app/shared/guards/admin-auth.guard.ts new file mode 100644 index 000000000..136c53e02 --- /dev/null +++ b/client/src/app/shared/guards/admin-auth.guard.ts @@ -0,0 +1,16 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../services/auth.service'; +import { UserRole } from '../enums/user-role.enum'; + +export const adminAuthGuard: CanActivateFn = () => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isAuthenticated() && authService.hasRole(UserRole.Admin)) { + return true; + } + + router.navigate(['/admin/login']); + return false; +}; diff --git a/client/src/app/shared/guards/guest.guard.ts b/client/src/app/shared/guards/guest.guard.ts new file mode 100644 index 000000000..fe4facdd5 --- /dev/null +++ b/client/src/app/shared/guards/guest.guard.ts @@ -0,0 +1,34 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../services/auth.service'; +import { UserRole } from '../enums/user-role.enum'; + +export const guestGuard: CanActivateFn = (route) => { + const authService = inject(AuthService); + const router = inject(Router); + + const url = route.pathFromRoot.map(r => r.url.map(s => s.path).join('/')).join('/'); + + if (url.includes('admin')) { + if (authService.isPortalAuthenticated(UserRole.Admin)) { + router.navigate(['/admin/dashboard']); + return false; + } + } else if (url.includes('vendor')) { + if (authService.isPortalAuthenticated(UserRole.Vendor)) { + router.navigate(['/vendor/dashboard']); + return false; + } + } else { + if (authService.isPortalAuthenticated(UserRole.Vendor)) { + router.navigate(['/vendor/dashboard']); + return false; + } + if (authService.isPortalAuthenticated(UserRole.User)) { + router.navigate(['/user/venues']); + return false; + } + } + + return true; +}; diff --git a/client/src/app/shared/guards/user-auth.guard.ts b/client/src/app/shared/guards/user-auth.guard.ts new file mode 100644 index 000000000..b7ebfc5e1 --- /dev/null +++ b/client/src/app/shared/guards/user-auth.guard.ts @@ -0,0 +1,16 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../services/auth.service'; +import { UserRole } from '../enums/user-role.enum'; + +export const userAuthGuard: CanActivateFn = () => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isAuthenticated() && authService.hasRole(UserRole.User)) { + return true; + } + + router.navigate(['/login']); + return false; +}; diff --git a/client/src/app/shared/guards/vendor-auth.guard.ts b/client/src/app/shared/guards/vendor-auth.guard.ts new file mode 100644 index 000000000..e2285fa2c --- /dev/null +++ b/client/src/app/shared/guards/vendor-auth.guard.ts @@ -0,0 +1,16 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../services/auth.service'; +import { UserRole } from '../enums/user-role.enum'; + +export const vendorAuthGuard: CanActivateFn = () => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isAuthenticated() && authService.hasRole(UserRole.Vendor)) { + return true; + } + + router.navigate(['/vendor/login']); + return false; +}; diff --git a/client/src/app/shared/interceptors/error.interceptor.ts b/client/src/app/shared/interceptors/error.interceptor.ts new file mode 100644 index 000000000..73c48bbd9 --- /dev/null +++ b/client/src/app/shared/interceptors/error.interceptor.ts @@ -0,0 +1,47 @@ +import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { catchError, switchMap, throwError } from 'rxjs'; +import { NotificationService } from '../services/notification.service'; +import { AuthService } from '../services/auth.service'; + +export const errorInterceptor: HttpInterceptorFn = (req, next) => { + const notification = inject(NotificationService); + const authService = inject(AuthService); + + return next(req).pipe( + catchError((error: HttpErrorResponse) => { + // Auth endpoints (login, register, verify, forgot/reset-password, etc.) return + // 401/403/400 for business reasons (invalid credentials, unverified email, bad OTP) — + // never treat those as an expired session, and let AuthService surface its own messages. + const isAuthEndpoint = req.url.includes('/auth/'); + + // 401 = access token expired — try refresh then retry + if (error.status === 401 && !isAuthEndpoint) { + return authService.refreshToken().pipe( + switchMap(() => next(req)), // retry original request with new cookie + catchError((refreshError) => { + // refresh token also expired — force logout + authService.handleLogout(); + notification.error('Session expired. Please log in again.'); + return throwError(() => refreshError); + }) + ); + } + + if (isAuthEndpoint) { + return throwError(() => error); + } + + let message = error.error?.message || 'An unexpected error occurred'; + switch (error.status) { + case 0: message = 'Unable to connect to server'; break; + case 403: message = error.error?.message || 'You do not have permission to perform this action'; break; + case 404: message = error.error?.message || 'Resource not found'; break; + case 500: message = 'Server error. Please try again later.'; break; + } + + notification.error(message); + return throwError(() => error); + }) + ); +}; diff --git a/client/src/app/shared/models/api-response.model.ts b/client/src/app/shared/models/api-response.model.ts new file mode 100644 index 000000000..3c31789ab --- /dev/null +++ b/client/src/app/shared/models/api-response.model.ts @@ -0,0 +1,29 @@ +export interface ApiResponse { + success: boolean; + data: T; + message: string; +} + +export interface PaginationResponse { + success: boolean; + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface ApiError { + success: false; + message: string; + errors?: Record; +} + + +export interface SpringPage { + content: T[]; + totalPages: number; + totalElements: number; + number: number; + size: number; +} diff --git a/client/src/app/shared/models/auth-response.model.ts b/client/src/app/shared/models/auth-response.model.ts new file mode 100644 index 000000000..3cfd3994c --- /dev/null +++ b/client/src/app/shared/models/auth-response.model.ts @@ -0,0 +1,61 @@ +export interface AuthResponse { + userId: string; + name: string; + email: string; + role: string; + accessToken?: string; + refreshToken?: string; +} + +export interface AuthUser { + id: string; + email: string; + role: string; + name: string; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface SignupRequest { + email: string; + password: string; + firstName: string; + lastName: string; + phone: string; + isVendor?: boolean; +} + +export interface ForgotPasswordRequest { + email: string; +} + +export interface ResetPasswordRequest { + email: string; + otp: string; + newPassword: string; +} + +export interface VerifyEmailRequest { + email: string; + otp: string; +} + +export interface ResendOtpRequest { + email: string; +} + +export interface MessageResponse { + message: string; +} + +export interface RefreshTokenApiResponse { + userId: string; + name: string; + email: string; + role: string; + accessToken: string; + refreshToken: string; +} diff --git a/client/src/app/shared/models/booking.model.ts b/client/src/app/shared/models/booking.model.ts new file mode 100644 index 000000000..9a524e6bb --- /dev/null +++ b/client/src/app/shared/models/booking.model.ts @@ -0,0 +1,45 @@ +import { BookingStatus } from '../enums/booking-status.enum'; + +export interface Booking { + id: number; + venueId: number; + slotTemplateId: number; + bookingDate: string; + status: BookingStatus; + totalAmount: number; + expiresAt: string; +} + +export interface CreateBookingRequest { + venueId: string; + slotTemplateId: number; + bookingDate: string; + startTime: string; + endTime: string; + guestCount: number; + notes?: string; +} + +export interface UpdateBookingRequest { + eventDate?: string; + startTime?: string; + endTime?: string; + guestCount?: number; + status?: BookingStatus; + notes?: string; +} + +export interface PaymentOrder { + paymentId: number; + bookingId: number; + razorpayOrderId: string; + amount: number; + status: string; +} + +export interface VerifyPaymentRequest { + razorpayOrderId: string; + razorpayPaymentId: string; + razorpaySignature: string; +} + diff --git a/client/src/app/shared/models/index.ts b/client/src/app/shared/models/index.ts new file mode 100644 index 000000000..1e81418f4 --- /dev/null +++ b/client/src/app/shared/models/index.ts @@ -0,0 +1,6 @@ +export * from './user.model'; +export * from './vendor.model'; +export * from './venue.model'; +export * from './booking.model'; +export * from './api-response.model'; +export * from './auth-response.model'; diff --git a/client/src/app/shared/models/slot.model.ts b/client/src/app/shared/models/slot.model.ts new file mode 100644 index 000000000..1f5b23bae --- /dev/null +++ b/client/src/app/shared/models/slot.model.ts @@ -0,0 +1,23 @@ +export interface TimeSlot { + id: number; + label: string; + startTime: string; + endTime: string; + duration: string; + available: boolean; + surcharge: number; +} + +export interface SlotTemplate { + id: number; + dayOfWeek: string; + startTime: string; + endTime: string; + active: boolean; +} + +export interface CreateSlotTemplateRequest { + dayOfWeek: string; + startTime: string; + endTime: string; +} diff --git a/client/src/app/shared/models/user.model.ts b/client/src/app/shared/models/user.model.ts new file mode 100644 index 000000000..8172a8283 --- /dev/null +++ b/client/src/app/shared/models/user.model.ts @@ -0,0 +1,21 @@ +export interface User { + id: string; + name: string; + email: string; + phone: string; + role: string; + createdAt: string; +} + +export interface CreateUserRequest { + email: string; + password: string; + firstName: string; + lastName: string; + phone: string; +} + +export interface UpdateUserRequest { + name?: string; + phone?: string; +} diff --git a/client/src/app/shared/models/vendor.model.ts b/client/src/app/shared/models/vendor.model.ts new file mode 100644 index 000000000..1afcef267 --- /dev/null +++ b/client/src/app/shared/models/vendor.model.ts @@ -0,0 +1,30 @@ +export interface Vendor { + id: string; + email: string; + businessName: string; + contactPerson: string; + phone: string; + address: string; + logo: string; + isVerified: boolean; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateVendorRequest { + email: string; + password: string; + businessName: string; + contactPerson: string; + phone: string; + address: string; +} + +export interface UpdateVendorRequest { + businessName?: string; + contactPerson?: string; + phone?: string; + address?: string; + logo?: string; +} diff --git a/client/src/app/shared/models/venue.model.ts b/client/src/app/shared/models/venue.model.ts new file mode 100644 index 000000000..7a8df5bfb --- /dev/null +++ b/client/src/app/shared/models/venue.model.ts @@ -0,0 +1,51 @@ +import { VenueStatus } from '../enums/venue-status.enum'; + +export interface Venue { + id: string; + name: string; + description: string; + address: string; + district: string; + capacity: number; + pricePerSlot: number; + advancePercentage: number; + category: string; + status: VenueStatus; + imageUrls?: string[]; +} + +export interface VenueCategory { + id: number; + name: string; + description?: string; +} + +export interface CreateVenueRequest { + name: string; + description: string; + address: string; + district: string; + capacity: number; + pricePerSlot: number; + advancePercentage: number; + categoryId: number; + imageUrls?: string[]; +} + +export interface UpdateVenueRequest { + name?: string; + description?: string; + address?: string; + district?: string; + capacity?: number; + pricePerSlot?: number; + advancePercentage?: number; + categoryId?: number; + imageUrls?: string[]; +} + +export interface VenueFilter { + search?: string; + district?: string; + categoryId?: number; +} diff --git a/client/src/app/shared/pipes/currency-format.pipe.ts b/client/src/app/shared/pipes/currency-format.pipe.ts new file mode 100644 index 000000000..038919dcd --- /dev/null +++ b/client/src/app/shared/pipes/currency-format.pipe.ts @@ -0,0 +1,14 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ name: 'currencyFormat', standalone: true }) +export class CurrencyFormatPipe implements PipeTransform { + transform(value: number, currency = 'INR', locale = 'en-IN'): string { + if (value === null || value === undefined) return ''; + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(value); + } +} diff --git a/client/src/app/shared/pipes/date-format.pipe.ts b/client/src/app/shared/pipes/date-format.pipe.ts new file mode 100644 index 000000000..c615fe757 --- /dev/null +++ b/client/src/app/shared/pipes/date-format.pipe.ts @@ -0,0 +1,24 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ name: 'dateFormat', standalone: true }) +export class DateFormatPipe implements PipeTransform { + transform(value: string | Date, format: 'short' | 'long' | 'date' | 'time' = 'short'): string { + if (!value) return ''; + const date = new Date(value); + + switch (format) { + case 'short': + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + case 'long': + return date.toLocaleDateString('en-US', { + weekday: 'long', month: 'long', day: 'numeric', year: 'numeric', + }); + case 'date': + return date.toLocaleDateString('en-US'); + case 'time': + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + default: + return date.toLocaleDateString('en-US'); + } + } +} diff --git a/client/src/app/shared/services/auth.repository.ts b/client/src/app/shared/services/auth.repository.ts new file mode 100644 index 000000000..3033862e1 --- /dev/null +++ b/client/src/app/shared/services/auth.repository.ts @@ -0,0 +1,100 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../constants/api-endpoints.constant'; +import { + AuthResponse, + LoginRequest, + SignupRequest, + ForgotPasswordRequest, + ResetPasswordRequest, + VerifyEmailRequest, + ResendOtpRequest, + MessageResponse, + RefreshTokenApiResponse, +} from '../models/auth-response.model'; + +@Injectable({ providedIn: 'root' }) +export class AuthRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + login(payload: LoginRequest): Observable { + return this.http.post( + `${this.apiUrl}${API_ENDPOINTS.AUTH.LOGIN}`, + payload, + { withCredentials: true } + ); + } + + adminLogin(payload: LoginRequest): Observable { + return this.http.post( + `${this.apiUrl}${API_ENDPOINTS.AUTH.ADMIN_LOGIN}`, + payload, + { withCredentials: true } + ); + } + + signup(payload: SignupRequest): Observable { + const body = { + name: `${payload.firstName} ${payload.lastName}`.trim(), + email: payload.email, + phone: payload.phone, + password: payload.password, + role: payload.isVendor ? 'VENDOR' : 'USER', + }; + return this.http.post( + `${this.apiUrl}${API_ENDPOINTS.AUTH.SIGNUP}`, + body + ); + } + + logout(): Observable { + return this.http.post( + `${this.apiUrl}${API_ENDPOINTS.AUTH.LOGOUT}`, + {}, + { withCredentials: true } + ); + } + + refreshToken(): Observable { + return this.http.post( + `${this.apiUrl}${API_ENDPOINTS.AUTH.REFRESH_TOKEN}`, + {}, + { withCredentials: true } + ); + } + + forgotPassword(payload: ForgotPasswordRequest): Observable { + return this.http.post( + `${this.apiUrl}${API_ENDPOINTS.AUTH.FORGOT_PASSWORD}`, + payload, + { withCredentials: true } + ); + } + + resetPassword(payload: ResetPasswordRequest): Observable { + return this.http.post( + `${this.apiUrl}${API_ENDPOINTS.AUTH.RESET_PASSWORD}`, + payload, + { withCredentials: true } + ); + } + + verifyEmail(payload: VerifyEmailRequest): Observable { + return this.http.post( + `${this.apiUrl}${API_ENDPOINTS.AUTH.VERIFY_EMAIL}`, + payload, + { withCredentials: true } + ); + } + + resendOtp(payload: ResendOtpRequest): Observable { + return this.http.post( + `${this.apiUrl}${API_ENDPOINTS.AUTH.RESEND_VERIFICATION}`, + payload, + { withCredentials: true } + ); + } +} diff --git a/client/src/app/shared/services/auth.service.ts b/client/src/app/shared/services/auth.service.ts new file mode 100644 index 000000000..3c63f86af --- /dev/null +++ b/client/src/app/shared/services/auth.service.ts @@ -0,0 +1,275 @@ +import { Injectable, inject, signal, computed } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Router } from '@angular/router'; +import { AuthRepository } from './auth.repository'; +import { StorageService } from './storage.service'; +import { NotificationService } from './notification.service'; +import { + AuthResponse, + AuthUser, + LoginRequest, + SignupRequest, + ForgotPasswordRequest, + ResetPasswordRequest, + VerifyEmailRequest, + ResendOtpRequest, + MessageResponse, +} from '../models/auth-response.model'; +import { UserRole } from '../enums/user-role.enum'; +import { AUTH_ERRORS } from '../constants/auth-errors.constant'; +import { finalize, map, tap, Observable } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class AuthService { + private readonly authRepository = inject(AuthRepository); + private readonly storage = inject(StorageService); + private readonly notification = inject(NotificationService); + private readonly router = inject(Router); + + readonly userSession = signal(null); + readonly vendorSession = signal(null); + readonly adminSession = signal(null); + readonly pendingVerificationEmail = signal(null); + + readonly currentUser = computed(() => { + const portal = this.detectPortal(); + if (portal === 'admin') return this.adminSession(); + if (portal === 'vendor') return this.vendorSession(); + return this.userSession(); + }); + + readonly isAuthenticated = computed(() => this.currentUser() !== null); + readonly loading = signal(false); + + constructor() { + this.loadAllSessions(); + } + + private portalKeys(portal: string): { token: string; refresh: string; user: string } { + return { + token: `bmv_${portal}_token`, + refresh: `bmv_${portal}_refresh`, + user: `bmv_${portal}_user`, + }; + } + + private detectPortal(): string { + const url = window.location.pathname || ''; + const routerUrl = this.router.url || ''; + if (url.startsWith('/admin') || routerUrl.startsWith('/admin')) return 'admin'; + if (url.startsWith('/vendor') || routerUrl.startsWith('/vendor')) return 'vendor'; + return 'user'; + } + + private loadAllSessions(): void { + this.userSession.set(this.loadSession('user')); + this.vendorSession.set(this.loadSession('vendor')); + this.adminSession.set(this.loadSession('admin')); + } + + private loadSession(portal: string): AuthUser | null { + const keys = this.portalKeys(portal); + const userJson = this.storage.get(keys.user); + if (userJson) { + try { + return JSON.parse(userJson); + } catch { + this.clearPortalSession(portal); + return null; + } + } + return null; + } + + private savePortalSession(portal: string, token: string, refreshToken: string, user: AuthUser): void { + const keys = this.portalKeys(portal); + this.storage.set(keys.token, token); + this.storage.set(keys.refresh, refreshToken); + this.storage.set(keys.user, JSON.stringify(user)); + + if (portal === 'admin') this.adminSession.set(user); + else if (portal === 'vendor') this.vendorSession.set(user); + else this.userSession.set(user); + } + + private clearPortalSession(portal: string): void { + const keys = this.portalKeys(portal); + this.storage.remove(keys.token); + this.storage.remove(keys.refresh); + this.storage.remove(keys.user); + this.storage.remove('accessToken'); + + if (portal === 'admin') this.adminSession.set(null); + else if (portal === 'vendor') this.vendorSession.set(null); + else this.userSession.set(null); + } + + private toAuthUser(response: AuthResponse): AuthUser { + return { id: response.userId, name: response.name, email: response.email, role: response.role }; + } + + isPortalAuthenticated(role: UserRole): boolean { + switch (role) { + case UserRole.Admin: return this.adminSession() !== null; + case UserRole.Vendor: return this.vendorSession() !== null; + case UserRole.User: return this.userSession() !== null; + default: return false; + } + } + + getPortalUser(role: UserRole): AuthUser | null { + switch (role) { + case UserRole.Admin: return this.adminSession(); + case UserRole.Vendor: return this.vendorSession(); + case UserRole.User: return this.userSession(); + default: return null; + } + } + + getToken(): string | null { + const portal = this.detectPortal(); + return this.storage.get(this.portalKeys(portal).token); + } + + getTokenForPortal(portal: string): string | null { + return this.storage.get(this.portalKeys(portal).token); + } + + getRefreshToken(): string | null { + const portal = this.detectPortal(); + return this.storage.get(this.portalKeys(portal).refresh); + } + + hasRole(role: UserRole): boolean { + return this.currentUser()?.role === role; + } + + login(payload: LoginRequest): void { + this.loading.set(true); + this.authRepository.login(payload).subscribe({ + next: (response) => { + const user = this.toAuthUser(response); + const portal = response.role === UserRole.Vendor ? 'vendor' : 'user'; + // Tokens are set via httpOnly cookie by the server; local copies (if any) live in storage. + const token = this.storage.get('accessToken') || ''; + const refreshToken = this.storage.get('refreshToken') || ''; + this.savePortalSession(portal, token, refreshToken, user); + this.notification.success('Login successful'); + this.router.navigate(portal === 'vendor' ? ['/vendor/dashboard'] : ['/user/venues']); + this.loading.set(false); + }, + error: (error: HttpErrorResponse) => { + this.loading.set(false); + if (error.status === 403 && error.error?.code === AUTH_ERRORS.EMAIL_NOT_VERIFIED) { + this.pendingVerificationEmail.set(payload.email); + this.notification.error('Please verify your email first'); + return; + } + this.notification.error(error.error?.message || 'Invalid credentials'); + }, + }); + } + + adminLogin(payload: LoginRequest): void { + this.loading.set(true); + this.authRepository.adminLogin(payload).subscribe({ + next: (response) => { + const user = this.toAuthUser(response); + const token = this.storage.get('accessToken') || ''; + const refreshToken = this.storage.get('refreshToken') || ''; + this.savePortalSession('admin', token, refreshToken, user); + this.notification.success('Login successful'); + this.router.navigate(['/admin/dashboard']); + this.loading.set(false); + }, + error: (error: HttpErrorResponse) => { + this.loading.set(false); + if (error.status === 403 && error.error?.code === AUTH_ERRORS.EMAIL_NOT_VERIFIED) { + this.pendingVerificationEmail.set(payload.email); + this.notification.error('Please verify your email first'); + return; + } + this.notification.error(error.error?.message || 'Invalid credentials'); + }, + }); + } + + signup(payload: SignupRequest): Observable { + this.loading.set(true); + return this.authRepository.signup(payload).pipe( + tap((response) => { + this.notification.success(response.message || 'Registration successful. Please verify your email.'); + }), + finalize(() => this.loading.set(false)) + ); + } + + forgotPassword(payload: ForgotPasswordRequest, onSuccess?: () => void): void { + this.loading.set(true); + this.authRepository.forgotPassword(payload).subscribe({ + next: () => { + this.notification.success('OTP sent to your email'); + this.loading.set(false); + onSuccess?.(); + }, + error: (error: HttpErrorResponse) => { + this.notification.error(error.error?.message || 'Failed to send OTP'); + this.loading.set(false); + }, + }); + } + + resetPassword(payload: ResetPasswordRequest, onSuccess?: () => void): void { + this.loading.set(true); + this.authRepository.resetPassword(payload).subscribe({ + next: () => { + this.notification.success('Password reset successful'); + this.loading.set(false); + onSuccess?.(); + }, + error: (error: HttpErrorResponse) => { + this.notification.error(error.error?.message || 'Failed to reset password'); + this.loading.set(false); + }, + }); + } + + verifyEmail(payload: VerifyEmailRequest): Observable { + return this.authRepository.verifyEmail(payload); + } + + resendOtp(payload: ResendOtpRequest): Observable { + return this.authRepository.resendOtp(payload); + } + + clearPendingVerification(): void { + this.pendingVerificationEmail.set(null); + } + + logout(): void { + const portal = this.detectPortal(); + this.authRepository.logout().subscribe({ + next: () => { + this.clearPortalSession(portal); + this.router.navigate(['/']); + this.notification.info('Logged out successfully'); + }, + error: () => { + this.clearPortalSession(portal); + this.router.navigate(['/']); + }, + }); + } + + refreshToken(): Observable { + return this.authRepository.refreshToken().pipe( + map(() => void 0) + ); + } + + handleLogout(): void { + const portal = this.detectPortal(); + this.clearPortalSession(portal); + this.router.navigate(['/login']); + } +} diff --git a/client/src/app/shared/services/category.service.ts b/client/src/app/shared/services/category.service.ts new file mode 100644 index 000000000..2032dfec4 --- /dev/null +++ b/client/src/app/shared/services/category.service.ts @@ -0,0 +1,16 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../constants/api-endpoints.constant'; +import { VenueCategory } from '../models/venue.model'; + +@Injectable({ providedIn: 'root' }) +export class CategoryService { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + getCategories(): Observable { + return this.http.get(`${this.apiUrl}${API_ENDPOINTS.CATEGORIES.BASE}`); + } +} diff --git a/client/src/app/shared/services/cloudinary.service.ts b/client/src/app/shared/services/cloudinary.service.ts new file mode 100644 index 000000000..998d18de7 --- /dev/null +++ b/client/src/app/shared/services/cloudinary.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { from, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { environment } from '../../core/config/environment'; + +@Injectable({ providedIn: 'root' }) +export class CloudinaryService { + private readonly uploadUrl = `https://api.cloudinary.com/v1_1/${environment.cloudinary.cloudName}/image/upload`; + + upload(file: File): Observable { + const formData = new FormData(); + formData.append('file', file); + formData.append('upload_preset', environment.cloudinary.uploadPreset); + + return from( + fetch(this.uploadUrl, { method: 'POST', body: formData }).then((res) => res.json()), + ).pipe(map((res: any) => res.secure_url as string)); + } +} diff --git a/client/src/app/shared/services/notification.service.ts b/client/src/app/shared/services/notification.service.ts new file mode 100644 index 000000000..acbe6c16e --- /dev/null +++ b/client/src/app/shared/services/notification.service.ts @@ -0,0 +1,41 @@ +import { Injectable, signal } from '@angular/core'; + +export interface Notification { + id: string; + type: 'success' | 'error' | 'warning' | 'info'; + message: string; + duration?: number; +} + +@Injectable({ providedIn: 'root' }) +export class NotificationService { + readonly notifications = signal([]); + + show(type: Notification['type'], message: string, duration = 5000): void { + const id = crypto.randomUUID(); + this.notifications.update(list => [...list, { id, type, message, duration }]); + if (duration > 0) { + setTimeout(() => this.dismiss(id), duration); + } + } + + success(message: string): void { + this.show('success', message); + } + + error(message: string): void { + this.show('error', message); + } + + warning(message: string): void { + this.show('warning', message); + } + + info(message: string): void { + this.show('info', message); + } + + dismiss(id: string): void { + this.notifications.update(list => list.filter(n => n.id !== id)); + } +} diff --git a/client/src/app/shared/services/storage.service.ts b/client/src/app/shared/services/storage.service.ts new file mode 100644 index 000000000..8fa518cfa --- /dev/null +++ b/client/src/app/shared/services/storage.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class StorageService { + private readonly cookieOptions = ';path=/;SameSite=Lax'; + + get(key: string): string | null { + // Try cookie first, then localStorage + const cookieValue = this.getCookie(key); + if (cookieValue) return cookieValue; + return localStorage.getItem(key); + } + + set(key: string, value: string, useCookie = false): void { + if (useCookie) { + this.setCookie(key, value); + } + localStorage.setItem(key, value); + } + + remove(key: string): void { + this.deleteCookie(key); + localStorage.removeItem(key); + } + + clear(): void { + localStorage.clear(); + } + + private getCookie(name: string): string | null { + const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); + return match ? decodeURIComponent(match[2]) : null; + } + + private setCookie(name: string, value: string): void { + document.cookie = `${name}=${encodeURIComponent(value)}${this.cookieOptions}`; + } + + private deleteCookie(name: string): void { + document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 UTC${this.cookieOptions}`; + } +} diff --git a/client/src/app/shared/utils/validators.ts b/client/src/app/shared/utils/validators.ts new file mode 100644 index 000000000..f0ec0e8c6 --- /dev/null +++ b/client/src/app/shared/utils/validators.ts @@ -0,0 +1,49 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +export class AppValidators { + static email(control: AbstractControl): ValidationErrors | null { + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + if (!control.value) return null; + return emailRegex.test(control.value) ? null : { email: true }; + } + + static phone(control: AbstractControl): ValidationErrors | null { + const phoneRegex = /^[0-9]{10}$/; + if (!control.value) return null; + return phoneRegex.test(control.value) ? null : { phone: true }; + } + + static matchField(fieldName: string): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const matchControl = control.parent?.get(fieldName); + if (!matchControl) return null; + return control.value === matchControl.value ? null : { mismatch: true }; + }; + } + + static minPrice(min: number): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (!control.value) return null; + return control.value >= min ? null : { minPrice: { min, actual: control.value } }; + }; + } + + static futureDate(control: AbstractControl): ValidationErrors | null { + if (!control.value) return null; + const selectedDate = new Date(control.value); + const today = new Date(); + today.setHours(0, 0, 0, 0); + return selectedDate >= today ? null : { futureDate: true }; + } + + static strongPassword(control: AbstractControl): ValidationErrors | null { + if (!control.value) return null; + const pattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,100}$/; + return pattern.test(control.value) ? null : { strongPassword: true }; + } + + static otp(control: AbstractControl): ValidationErrors | null { + if (!control.value) return null; + return /^\d{6}$/.test(control.value) ? null : { otp: true }; + } +} diff --git a/client/src/app/user/features/booking-detail/booking-detail.component.html b/client/src/app/user/features/booking-detail/booking-detail.component.html new file mode 100644 index 000000000..d028b6785 --- /dev/null +++ b/client/src/app/user/features/booking-detail/booking-detail.component.html @@ -0,0 +1,52 @@ +
+ + + @if (booking()) { +
+
+

Booking #{{ booking()!.id }}

+ + {{ booking()!.status }} + +
+ +
+
+

Booking Date

+

{{ booking()!.bookingDate | dateFormat }}

+
+
+

Venue ID

+

{{ booking()!.venueId }}

+
+
+

Slot ID

+

{{ booking()!.slotTemplateId }}

+
+
+

Total Amount

+

{{ booking()!.totalAmount | currencyFormat }}

+
+ @if (booking()!.expiresAt) { +
+

Expires At

+

{{ booking()!.expiresAt | dateFormat }}

+
+ } +
+ + @if (booking()!.status === BookingStatus.CONFIRMED || booking()!.status === BookingStatus.PENDING) { +
+ +
+ } +
+ } @else { +
Booking not found.
+ } +
diff --git a/client/src/app/user/features/booking-detail/booking-detail.component.ts b/client/src/app/user/features/booking-detail/booking-detail.component.ts new file mode 100644 index 000000000..3191712ad --- /dev/null +++ b/client/src/app/user/features/booking-detail/booking-detail.component.ts @@ -0,0 +1,49 @@ +import { Component, inject, computed, ChangeDetectionStrategy } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BookingService } from '../../services/booking.service'; +import { BookingStatus } from '../../../shared/enums/booking-status.enum'; +import { DateFormatPipe } from '../../../shared/pipes/date-format.pipe'; +import { CurrencyFormatPipe } from '../../../shared/pipes/currency-format.pipe'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; + +@Component({ + selector: 'app-booking-detail', + standalone: true, + imports: [DateFormatPipe, CurrencyFormatPipe, ButtonComponent], + templateUrl: './booking-detail.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BookingDetailComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly bookingService = inject(BookingService); + + readonly BookingStatus = BookingStatus; + + readonly booking = computed(() => { + const id = Number(this.route.snapshot.paramMap.get('id')); + return this.bookingService.bookings().find(b => b.id === id) ?? null; + }); + + getStatusClass(status: string): string { + switch (status) { + case BookingStatus.CONFIRMED: return 'bg-green-100 text-green-700'; + case BookingStatus.PENDING: return 'bg-yellow-100 text-yellow-700'; + case BookingStatus.CANCELLED: return 'bg-red-100 text-red-700'; + case BookingStatus.EXPIRED: return 'bg-red-100 text-red-700'; + default: return 'bg-gray-100 text-gray-700'; + } + } + + goBack(): void { + this.router.navigate(['/user/my-bookings']); + } + + onCancel(): void { + const booking = this.booking(); + if (booking) { + this.bookingService.cancelBooking(booking.id); + this.router.navigate(['/user/my-bookings']); + } + } +} diff --git a/client/src/app/user/features/checkout/checkout.component.css b/client/src/app/user/features/checkout/checkout.component.css new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/client/src/app/user/features/checkout/checkout.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/client/src/app/user/features/checkout/checkout.component.html b/client/src/app/user/features/checkout/checkout.component.html new file mode 100644 index 000000000..3d2f7f51b --- /dev/null +++ b/client/src/app/user/features/checkout/checkout.component.html @@ -0,0 +1,332 @@ +@if (loading()) { + +
+
+
+
+
+
+
+
+
+
+
+ +} @else if (venue(); as v) { +
+ + +
+

Complete Your Booking

+

{{ v.name }} · {{ v.district }}

+
+ +
+ + +
+ + +
+ @if (v.imageUrls && v.imageUrls.length > 0) { + + } +
+

{{ v.name }}

+

{{ v.address }}, {{ v.district }}

+ {{ v.category }} +
+
+

{{ v.pricePerSlot | currencyFormat }}

+

base / slot

+
+
+ + +
+
+
+ 1 +
+

Select Date

+
+
+
+ + +
+
+ + +
+
+
+ 2 +
+

Choose a Time Slot

+
+
+
+ @for (slot of slots(); track slot.id) { + + } +
+ @if (!selectedSlotId()) { +

+ + + + Select a slot to continue +

+ } +
+
+ + +
+
+
+ 3 +
+
+

Add-on Amenities

+
+ Optional +
+
+
+ @for (amenity of amenities; track amenity.id) { + + } +
+
+
+ + +
+
+
+ 4 +
+

Event Details

+
+
+
+ +
+ + +
+ +
+
+ +
+ + +
+
+ + +
+

Order Summary

+

{{ v.name }}

+
+ +
+ + +
+ Base venue price + {{ v.pricePerSlot | currencyFormat }} +
+ + + @if (selectedSlot(); as slot) { +
+
+

Slot: {{ slot.label }}

+

{{ slot.startTime }} - {{ slot.endTime }}

+
+ @if (slot.surcharge > 0) { + + {{ slot.surcharge | currencyFormat }} + } @else { + Included + } +
+ } @else { +
+ + + +

No slot selected yet

+
+ } + + + @if (selectedAmenitiesList().length > 0) { +
+

Add-ons

+ @for (amenity of selectedAmenitiesList(); track amenity.id) { +
+ {{ amenity.name }} + + {{ amenity.price | currencyFormat }} +
+ } +
+ } + + +
+
+ Total + + {{ grandTotal() | currencyFormat }} + +
+ @if (selectedSlotId()) { +

All taxes included

+ } +
+ + +
+ + @if (!selectedSlotId()) { +

Select a time slot to proceed

+ } +
+ + +
+
+ + + + Instant booking confirmation +
+
+ + + + No hidden charges +
+
+ + + + Free cancellation within 24 hrs +
+
+ +
+
+
+ +
+
+} diff --git a/client/src/app/user/features/checkout/checkout.component.spec.ts b/client/src/app/user/features/checkout/checkout.component.spec.ts new file mode 100644 index 000000000..00aca7d82 --- /dev/null +++ b/client/src/app/user/features/checkout/checkout.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { CheckoutComponent } from './checkout.component'; + +describe('CheckoutComponent', () => { + let component: CheckoutComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CheckoutComponent], + providers: [provideRouter([]), provideHttpClient()], + }).compileComponents(); + + fixture = TestBed.createComponent(CheckoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/user/features/checkout/checkout.component.ts b/client/src/app/user/features/checkout/checkout.component.ts new file mode 100644 index 000000000..e7450edbe --- /dev/null +++ b/client/src/app/user/features/checkout/checkout.component.ts @@ -0,0 +1,155 @@ +import { Component, inject, OnInit, signal, computed, ChangeDetectionStrategy } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { VenueService } from '../../services/venue.service'; +import { BookingService } from '../../services/booking.service'; +import { PaymentRepository } from '../../repositories/payment.repository'; +import { Venue } from '../../../shared/models/venue.model'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; +import { InputComponent } from '../../../shared/components/input/input.component'; +import { CurrencyFormatPipe } from '../../../shared/pipes/currency-format.pipe'; +import { AppValidators } from '../../../shared/utils/validators'; +import { SlotService } from '../../services/slot.services'; +import { TimeSlot } from '../../../shared/models/slot.model'; +import { CreateBookingRequest, VerifyPaymentRequest } from '../../../shared/models/booking.model'; +import { NotificationService } from '../../../shared/services/notification.service'; +import { environment } from '../../../core/config/environment'; + +declare var Razorpay: any; + +export interface Amenity { + id: string; + name: string; + desc: string; + price: number; + icon: string; +} + +@Component({ + selector: 'app-checkout', + standalone: true, + imports: [ReactiveFormsModule, ButtonComponent, InputComponent, CurrencyFormatPipe], + templateUrl: './checkout.component.html', + styleUrl: './checkout.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CheckoutComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly fb = inject(FormBuilder); + private readonly venueService = inject(VenueService); + private readonly bookingService = inject(BookingService); + private readonly paymentRepository = inject(PaymentRepository); + private readonly slotService = inject(SlotService); + private readonly notification = inject(NotificationService); + + readonly venue = signal(null); + readonly loading = signal(true); + readonly submitting = signal(false); + readonly selectedSlotId = signal(null); + readonly selectedAmenityIds = signal>(new Set()); + readonly slots = signal([]); + + readonly amenities: Amenity[] = [ + { id: 'a1', name: 'Catering Service', desc: 'Full catering for all guests (per head)', price: 500, icon: 'M3 3h2l.4 2M7 13h10l4-4H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z' }, + { id: 'a2', name: 'AV & Sound System', desc: 'Professional PA, lighting & projector', price: 3000, icon: 'M15.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 12M8.464 8.464a5 5 0 000 7.072M21 12a9 9 0 11-18 0 9 9 0 0118 0z' }, + { id: 'a3', name: 'Valet Parking', desc: 'Dedicated parking for up to 100 cars', price: 1500, icon: 'M8 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM15 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM3 4h13l4 4v4H3V4z' }, + { id: 'a4', name: 'Event Decoration', desc: 'Floral & thematic decoration setup', price: 8000, icon: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z' }, + { id: 'a5', name: 'Security Team', desc: '4 trained security personnel', price: 2000, icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' }, + { id: 'a6', name: 'Photography Setup', desc: 'Photo booth + candid photography', price: 5000, icon: 'M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z M15 13a3 3 0 11-6 0 3 3 0 016 0z' }, + ]; + + readonly form = this.fb.nonNullable.group({ + eventDate: ['', [Validators.required, AppValidators.futureDate]], + guestCount: ['', [Validators.required, Validators.min(1)]], + notes: [''], + }); + + readonly selectedSlot = computed(() => this.slots().find(s => s.id === this.selectedSlotId()) ?? null); + readonly amenitiesTotal = computed(() => this.amenities.filter(a => this.selectedAmenityIds().has(a.id)).reduce((sum, a) => sum + a.price, 0)); + readonly slotTotal = computed(() => { const v = this.venue(); const slot = this.selectedSlot(); if (!v || !slot) return 0; return v.pricePerSlot + slot.surcharge; }); + readonly grandTotal = computed(() => this.slotTotal() + this.amenitiesTotal()); + readonly selectedAmenitiesList = computed(() => this.amenities.filter(a => this.selectedAmenityIds().has(a.id))); + + ngOnInit(): void { + const venueId = this.route.snapshot.paramMap.get('venueId') ?? ''; + + this.form.controls.eventDate.valueChanges.subscribe(date => { + this.selectedSlotId.set(null); + if (!date || this.form.controls.eventDate.invalid) { this.slots.set([]); return; } + this.slotService.loadSlotsForVenue(venueId, date).subscribe({ + next: (slots) => this.slots.set(slots), + error: () => this.slots.set([]), + }); + }); + + if (venueId) { + this.venueService.loadVenueById(venueId).subscribe({ + next: (venue) => { this.venue.set(venue); this.loading.set(false); }, + error: () => this.loading.set(false), + }); + } + } + + selectSlot(slot: TimeSlot): void { if (!slot.available) return; this.selectedSlotId.set(slot.id); } + + toggleAmenity(id: string): void { + this.selectedAmenityIds.update(set => { const next = new Set(set); next.has(id) ? next.delete(id) : next.add(id); return next; }); + } + + isAmenitySelected(id: string): boolean { return this.selectedAmenityIds().has(id); } + + onSubmit(): void { + const selectedSlot = this.selectedSlot(); + if (!this.form.valid || !this.venue() || !selectedSlot) return; + + this.submitting.set(true); + + const payload: CreateBookingRequest = { + venueId: this.venue()!.id, + slotTemplateId: selectedSlot.id, + bookingDate: this.form.value.eventDate!, + startTime: selectedSlot.startTime, + endTime: selectedSlot.endTime, + guestCount: Number(this.form.value.guestCount), + notes: this.form.value.notes, + }; + + this.bookingService.createBooking(payload).subscribe({ + next: (booking) => this.initiatePayment(booking.id, booking.totalAmount), + error: () => { this.notification.error('Failed to create booking'); this.submitting.set(false); }, + }); + } + + private initiatePayment(bookingId: number, amount: number): void { + this.paymentRepository.createPaymentOrder(bookingId).subscribe({ + next: (order) => { + const options = { + key: environment.razorpayKeyId, + amount: order.amount * 100, + currency: 'INR', + order_id: order.razorpayOrderId, + name: 'BookMyVenue', + description: 'Venue Booking Payment', + handler: (response: any) => this.verifyPayment(response), + modal: { ondismiss: () => this.submitting.set(false) }, + }; + const rzp = new Razorpay(options); + rzp.open(); + }, + error: () => { this.notification.error('Failed to initiate payment'); this.submitting.set(false); }, + }); + } + + private verifyPayment(response: any): void { + const request: VerifyPaymentRequest = { + razorpayOrderId: response.razorpay_order_id, + razorpayPaymentId: response.razorpay_payment_id, + razorpaySignature: response.razorpay_signature, + }; + this.paymentRepository.verifyPayment(request).subscribe({ + next: () => { this.notification.success('Payment successful! Booking confirmed.'); this.router.navigate(['/user/my-bookings']); }, + error: () => { this.notification.error('Payment verification failed'); this.submitting.set(false); }, + }); + } +} diff --git a/client/src/app/user/features/forgot-password/forgot-password.component.css b/client/src/app/user/features/forgot-password/forgot-password.component.css new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/client/src/app/user/features/forgot-password/forgot-password.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/client/src/app/user/features/forgot-password/forgot-password.component.html b/client/src/app/user/features/forgot-password/forgot-password.component.html new file mode 100644 index 000000000..000948d9f --- /dev/null +++ b/client/src/app/user/features/forgot-password/forgot-password.component.html @@ -0,0 +1,37 @@ +
+
+
+

Forgot Password

+

Enter your email to receive a reset code

+
+ +
+
+
+ +
+ +
+ +
+
+ +

+ Remember your password? + Sign in +

+
+
+
diff --git a/client/src/app/user/features/forgot-password/forgot-password.component.spec.ts b/client/src/app/user/features/forgot-password/forgot-password.component.spec.ts new file mode 100644 index 000000000..f6ac6bd1e --- /dev/null +++ b/client/src/app/user/features/forgot-password/forgot-password.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { ForgotPasswordComponent } from './forgot-password.component'; + +describe('User ForgotPasswordComponent', () => { + let component: ForgotPasswordComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ForgotPasswordComponent], + providers: [provideRouter([]), provideHttpClient()], + }).compileComponents(); + + fixture = TestBed.createComponent(ForgotPasswordComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/user/features/forgot-password/forgot-password.component.ts b/client/src/app/user/features/forgot-password/forgot-password.component.ts new file mode 100644 index 000000000..39c7b00b5 --- /dev/null +++ b/client/src/app/user/features/forgot-password/forgot-password.component.ts @@ -0,0 +1,36 @@ +import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { AuthService } from '../../../shared/services/auth.service'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; +import { InputComponent } from '../../../shared/components/input/input.component'; + +@Component({ + selector: 'app-user-forgot-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink, ButtonComponent, InputComponent], + templateUrl: './forgot-password.component.html', + styleUrl: './forgot-password.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ForgotPasswordComponent { + private readonly fb = inject(FormBuilder); + private readonly authService = inject(AuthService); + private readonly router = inject(Router); + + readonly loading = this.authService.loading; + + readonly form = this.fb.nonNullable.group({ + email: ['', [Validators.required, Validators.email]], + }); + + onSubmit(): void { + if (this.form.valid) { + this.authService.forgotPassword(this.form.getRawValue(), () => { + this.router.navigate(['/reset-password']); + }); + } else { + this.form.markAllAsTouched(); + } + } +} diff --git a/client/src/app/user/features/login/login.component.css b/client/src/app/user/features/login/login.component.css new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/client/src/app/user/features/login/login.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/client/src/app/user/features/login/login.component.html b/client/src/app/user/features/login/login.component.html new file mode 100644 index 000000000..0ab6b0c41 --- /dev/null +++ b/client/src/app/user/features/login/login.component.html @@ -0,0 +1,146 @@ +
+ + + + + +
+ + +
+
+ + + +
+ BookMyVenue +
+ +
+ + + + + + + Back to home + + + +
+

Welcome back

+

Sign in to continue to BookMyVenue

+
+ + +
+
+ + +
+ + + + + + @if (showVerifyButton()) { + + } + + +

+ Don't have an account? + Sign up free +

+
+
+ +
diff --git a/client/src/app/user/features/login/login.component.spec.ts b/client/src/app/user/features/login/login.component.spec.ts new file mode 100644 index 000000000..872f265e9 --- /dev/null +++ b/client/src/app/user/features/login/login.component.spec.ts @@ -0,0 +1,28 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { LoginComponent } from './login.component'; + +describe('User LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LoginComponent], + providers: [provideRouter([]), provideHttpClient()], + }).compileComponents(); + + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have invalid form initially', () => { + expect(component.form.valid).toBeFalse(); + }); +}); diff --git a/client/src/app/user/features/login/login.component.ts b/client/src/app/user/features/login/login.component.ts new file mode 100644 index 000000000..2ace0e81b --- /dev/null +++ b/client/src/app/user/features/login/login.component.ts @@ -0,0 +1,58 @@ +import { Component, inject, ChangeDetectionStrategy, computed } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { AuthService } from '../../../shared/services/auth.service'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; +import { InputComponent } from '../../../shared/components/input/input.component'; + +@Component({ + selector: 'app-user-login', + standalone: true, + imports: [ReactiveFormsModule, RouterLink, ButtonComponent, InputComponent], + templateUrl: './login.component.html', + styleUrl: './login.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LoginComponent { + private readonly fb = inject(FormBuilder); + private readonly router = inject(Router); + private readonly authService = inject(AuthService); + + readonly loading = this.authService.loading; + readonly pendingVerificationEmail = this.authService.pendingVerificationEmail; + + readonly showVerifyButton = computed(() => this.pendingVerificationEmail() !== null); + + readonly panelFeatures = [ + 'Browse 500+ verified venues across India', + 'Real-time availability & instant booking', + 'Transparent pricing with no hidden charges', + ]; + + readonly form = this.fb.nonNullable.group({ + email: ['', [Validators.required, Validators.email]], + password: ['', [Validators.required, Validators.minLength(6)]], + }); + + onSubmit(): void { + if (this.form.valid) { + this.authService.login(this.form.getRawValue()); + } else { + this.form.markAllAsTouched(); + } + } + + onVerify(): void { + const email = this.pendingVerificationEmail(); + if (email) { + this.authService.clearPendingVerification(); + this.authService.resendOtp({ email }).subscribe({ + next: () => { + this.router.navigate(['/verify-email'], { + queryParams: { email, portal: 'user' }, + }); + }, + }); + } + } +} diff --git a/client/src/app/user/features/my-bookings/my-bookings.component.css b/client/src/app/user/features/my-bookings/my-bookings.component.css new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/client/src/app/user/features/my-bookings/my-bookings.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/client/src/app/user/features/my-bookings/my-bookings.component.html b/client/src/app/user/features/my-bookings/my-bookings.component.html new file mode 100644 index 000000000..1371e7e08 --- /dev/null +++ b/client/src/app/user/features/my-bookings/my-bookings.component.html @@ -0,0 +1,60 @@ +
+

My Bookings

+ + @if (loading()) { + + } @else if (!bookings() || bookings().length === 0) { + + } @else { +
+
+ + + + + + + + + + + + + @for (booking of bookings(); track booking.id) { + + + + + + + + + } + +
Venue IDDateSlot IDAmountStatusActions
+ {{ booking.venueId }} + + {{ booking.bookingDate | dateFormat }} + + {{ booking.slotTemplateId }} + + {{ booking.totalAmount | currencyFormat }} + + + {{ booking.status }} + + + + @if (booking.status === BookingStatus.CONFIRMED || booking.status === BookingStatus.PENDING) { + + } +
+
+
+ } +
\ No newline at end of file diff --git a/client/src/app/user/features/my-bookings/my-bookings.component.spec.ts b/client/src/app/user/features/my-bookings/my-bookings.component.spec.ts new file mode 100644 index 000000000..2297fdd4a --- /dev/null +++ b/client/src/app/user/features/my-bookings/my-bookings.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { MyBookingsComponent } from './my-bookings.component'; + +describe('MyBookingsComponent', () => { + let component: MyBookingsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MyBookingsComponent], + providers: [provideHttpClient()], + }).compileComponents(); + + fixture = TestBed.createComponent(MyBookingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/user/features/my-bookings/my-bookings.component.ts b/client/src/app/user/features/my-bookings/my-bookings.component.ts new file mode 100644 index 000000000..72f2aea65 --- /dev/null +++ b/client/src/app/user/features/my-bookings/my-bookings.component.ts @@ -0,0 +1,50 @@ +import { Component, inject, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { Router } from '@angular/router'; +import { BookingService } from '../../services/booking.service'; +import { Booking } from '../../../shared/models/booking.model'; +import { BookingStatus } from '../../../shared/enums/booking-status.enum'; +import { LoaderComponent } from '../../../shared/components/loader/loader.component'; +import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component'; +import { DateFormatPipe } from '../../../shared/pipes/date-format.pipe'; +import { CurrencyFormatPipe } from '../../../shared/pipes/currency-format.pipe'; + +@Component({ + selector: 'app-my-bookings', + standalone: true, + imports: [LoaderComponent, EmptyStateComponent, DateFormatPipe, CurrencyFormatPipe], + templateUrl: './my-bookings.component.html', + styleUrl: './my-bookings.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MyBookingsComponent implements OnInit { + private readonly bookingService = inject(BookingService); + private readonly router = inject(Router); + + readonly bookings = this.bookingService.bookings; + readonly loading = this.bookingService.loading; + + // Expose enum to the template so we can compare booking.status against it + readonly BookingStatus = BookingStatus; + + ngOnInit(): void { + this.bookingService.loadUserBookings(); + } + + onView(booking: Booking): void { + this.router.navigate(['/user/my-bookings', booking.id]); + } + + onCancel(booking: Booking): void { + this.bookingService.cancelBooking(booking.id); + } + + getStatusClass(status: string): string { + switch (status) { + case BookingStatus.CONFIRMED: return 'bg-green-100 text-green-700'; + case BookingStatus.PENDING: return 'bg-yellow-100 text-yellow-700'; + case BookingStatus.CANCELLED: return 'bg-red-100 text-red-700'; + case BookingStatus.EXPIRED: return 'bg-red-100 text-red-700'; + default: return 'bg-gray-100 text-gray-700'; + } + } +} \ No newline at end of file diff --git a/client/src/app/user/features/profile/profile.component.css b/client/src/app/user/features/profile/profile.component.css new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/client/src/app/user/features/profile/profile.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/client/src/app/user/features/profile/profile.component.html b/client/src/app/user/features/profile/profile.component.html new file mode 100644 index 000000000..f813c0244 --- /dev/null +++ b/client/src/app/user/features/profile/profile.component.html @@ -0,0 +1,44 @@ +
+

My Profile

+ +
+
+
+ + {{ authService.currentUser()?.name?.charAt(0) || 'U' }} + +
+
+

{{ authService.currentUser()?.name }}

+

{{ authService.currentUser()?.email }}

+
+
+ +
+
+ + + +
+ +
+ +
+
+
+
diff --git a/client/src/app/user/features/profile/profile.component.spec.ts b/client/src/app/user/features/profile/profile.component.spec.ts new file mode 100644 index 000000000..102bf8b02 --- /dev/null +++ b/client/src/app/user/features/profile/profile.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { ProfileComponent } from './profile.component'; + +describe('User ProfileComponent', () => { + let component: ProfileComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProfileComponent], + providers: [provideRouter([]), provideHttpClient()], + }).compileComponents(); + + fixture = TestBed.createComponent(ProfileComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/user/features/profile/profile.component.ts b/client/src/app/user/features/profile/profile.component.ts new file mode 100644 index 000000000..77a11179d --- /dev/null +++ b/client/src/app/user/features/profile/profile.component.ts @@ -0,0 +1,55 @@ +import { Component, inject, signal, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { UserService } from '../../services/user.service'; +import { AuthService } from '../../../shared/services/auth.service'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; +import { InputComponent } from '../../../shared/components/input/input.component'; +import { AppValidators } from '../../../shared/utils/validators'; + +@Component({ + selector: 'app-profile', + standalone: true, + imports: [ReactiveFormsModule, ButtonComponent, InputComponent], + templateUrl: './profile.component.html', + styleUrl: './profile.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProfileComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly userService = inject(UserService); + readonly authService = inject(AuthService); + + readonly loading = signal(false); + readonly saving = signal(false); + + readonly form = this.fb.nonNullable.group({ + name: ['', [Validators.required]], + phone: ['', [Validators.required, AppValidators.phone]], + }); + + ngOnInit(): void { + this.loading.set(true); + this.userService.loadProfile().subscribe({ + next: (user) => { + this.form.patchValue({ + name: user.name, + phone: user.phone, + }); + this.loading.set(false); + }, + error: () => this.loading.set(false), + }); + } + + onSubmit(): void { + if (this.form.valid) { + this.saving.set(true); + this.userService.updateProfile(this.form.getRawValue()).subscribe({ + next: () => this.saving.set(false), + error: () => this.saving.set(false), + }); + } else { + this.form.markAllAsTouched(); + } + } +} diff --git a/client/src/app/user/features/reset-password/reset-password.component.css b/client/src/app/user/features/reset-password/reset-password.component.css new file mode 100644 index 000000000..50ce81d13 --- /dev/null +++ b/client/src/app/user/features/reset-password/reset-password.component.css @@ -0,0 +1 @@ +/* Reset Password styles - uses utility classes in template */ \ No newline at end of file diff --git a/client/src/app/user/features/reset-password/reset-password.component.html b/client/src/app/user/features/reset-password/reset-password.component.html new file mode 100644 index 000000000..1ba9f336b --- /dev/null +++ b/client/src/app/user/features/reset-password/reset-password.component.html @@ -0,0 +1,63 @@ +
+
+
+

Reset Password

+

Enter the OTP sent to your email and create a new password

+
+ +
+
+
+ + + + + + + +
+ +
+ +
+
+ +

+ Remember your password? + Sign in +

+
+
+
diff --git a/client/src/app/user/features/reset-password/reset-password.component.ts b/client/src/app/user/features/reset-password/reset-password.component.ts new file mode 100644 index 000000000..f5d0a2351 --- /dev/null +++ b/client/src/app/user/features/reset-password/reset-password.component.ts @@ -0,0 +1,41 @@ +import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { AuthService } from '../../../shared/services/auth.service'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; +import { InputComponent } from '../../../shared/components/input/input.component'; +import { AppValidators } from '../../../shared/utils/validators'; + +@Component({ + selector: 'app-user-reset-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink, ButtonComponent, InputComponent], + templateUrl: './reset-password.component.html', + styleUrl: './reset-password.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResetPasswordComponent { + private readonly fb = inject(FormBuilder); + private readonly router = inject(Router); + private readonly authService = inject(AuthService); + + readonly loading = this.authService.loading; + + readonly form = this.fb.nonNullable.group({ + email: ['', [Validators.required, Validators.email]], + otp: ['', [Validators.required, AppValidators.otp]], + newPassword: ['', [Validators.required, AppValidators.strongPassword]], + confirmPassword: ['', [Validators.required, AppValidators.matchField('newPassword')]], + }); + + onSubmit(): void { + if (this.form.valid) { + const { confirmPassword, ...payload } = this.form.getRawValue(); + this.authService.resetPassword(payload, () => { + this.router.navigate(['/login']); + }); + } else { + this.form.markAllAsTouched(); + } + } +} diff --git a/client/src/app/user/features/signup/signup.component.css b/client/src/app/user/features/signup/signup.component.css new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/client/src/app/user/features/signup/signup.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/client/src/app/user/features/signup/signup.component.html b/client/src/app/user/features/signup/signup.component.html new file mode 100644 index 000000000..99b089915 --- /dev/null +++ b/client/src/app/user/features/signup/signup.component.html @@ -0,0 +1,184 @@ +
+ + + + + +
+ + +
+
+ + + +
+ BookMyVenue +
+ +
+ + + + + + + Back to home + + + +
+

Create account

+

Join BookMyVenue — it's free

+
+ + +
+
+ +
+ + +
+ + + + + + + + + + + + +
+ +
+ +
+
+ +

+ Already have an account? + Sign in +

+
+
+ +
diff --git a/client/src/app/user/features/signup/signup.component.spec.ts b/client/src/app/user/features/signup/signup.component.spec.ts new file mode 100644 index 000000000..dfa81e137 --- /dev/null +++ b/client/src/app/user/features/signup/signup.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { SignupComponent } from './signup.component'; + +describe('User SignupComponent', () => { + let component: SignupComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SignupComponent], + providers: [provideRouter([]), provideHttpClient()], + }).compileComponents(); + + fixture = TestBed.createComponent(SignupComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/user/features/signup/signup.component.ts b/client/src/app/user/features/signup/signup.component.ts new file mode 100644 index 000000000..2478d1c59 --- /dev/null +++ b/client/src/app/user/features/signup/signup.component.ts @@ -0,0 +1,51 @@ +import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { AuthService } from '../../../shared/services/auth.service'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; +import { InputComponent } from '../../../shared/components/input/input.component'; +import { AppValidators } from '../../../shared/utils/validators'; + +@Component({ + selector: 'app-user-signup', + standalone: true, + imports: [ReactiveFormsModule, RouterLink, ButtonComponent, InputComponent], + templateUrl: './signup.component.html', + styleUrl: './signup.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SignupComponent { + private readonly fb = inject(FormBuilder); + private readonly router = inject(Router); + private readonly authService = inject(AuthService); + + readonly loading = this.authService.loading; + + readonly venueTypes = ['Weddings', 'Corporate', 'Birthday', 'Conferences', 'Social', 'Outdoor']; + + readonly form = this.fb.nonNullable.group({ + firstName: ['', [Validators.required]], + lastName: ['', [Validators.required]], + email: ['', [Validators.required, Validators.email]], + phone: ['', [Validators.required, AppValidators.phone]], + password: ['', [Validators.required, AppValidators.strongPassword]], + confirmPassword: ['', [Validators.required, AppValidators.matchField('password')]], + isVendor: [false], + }); + + onSubmit(): void { + if (this.form.valid) { + const { confirmPassword, ...payload } = this.form.getRawValue(); + this.authService.signup(payload).subscribe({ + next: () => { + const portal = payload.isVendor ? 'vendor' : 'user'; + this.router.navigate(['/verify-email'], { + queryParams: { email: payload.email, portal }, + }); + }, + }); + } else { + this.form.markAllAsTouched(); + } + } +} diff --git a/client/src/app/user/features/venue-details/venue-details.component.css b/client/src/app/user/features/venue-details/venue-details.component.css new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/client/src/app/user/features/venue-details/venue-details.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/client/src/app/user/features/venue-details/venue-details.component.html b/client/src/app/user/features/venue-details/venue-details.component.html new file mode 100644 index 000000000..b15c4e654 --- /dev/null +++ b/client/src/app/user/features/venue-details/venue-details.component.html @@ -0,0 +1,276 @@ +@if (loading()) { + +
+
+
+
+ @for (i of [1,2,3,4]; track i) { +
+ } +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +} @else if (venue(); as v) { +
+ + + + + + + Back to venues + + + +
+ +
+ @if (v.imageUrls && v.imageUrls.length > 0) { + + } @else { +
+ + + +
+ } + + +
+ + +
+ @if (v.status === 'APPROVED') { + + + Verified & Available + + } @else { + + {{ v.status }} + + } +
+ + + @if (v.imageUrls && v.imageUrls.length > 1) { + + } +
+ + + @if (v.imageUrls && v.imageUrls.length > 1) { +
+ @for (img of v.imageUrls; track $index) { + + } +
+ } +
+ + +
+ + +
+ + +
+
+ + {{ v.category }} + +
+

{{ v.name }}

+
+ + + + + {{ v.address }}, {{ v.district }} +
+
+ + +
+
+
+ + + +
+
+

Capacity

+

{{ v.capacity }} guests max

+
+
+ +
+
+ + + +
+
+

Venue Type

+

{{ v.category }}

+
+
+ +
+
+ + + + +
+
+

District

+

{{ v.district }}

+
+
+
+ + +
+

About this venue

+

{{ v.description }}

+
+ + +
+

Venue Details

+
+
+

Full Address

+

{{ v.address }}

+
+
+

District

+

{{ v.district }}

+
+
+

Max Capacity

+

{{ v.capacity }} guests

+
+
+

Pricing

+

{{ v.pricePerSlot | currencyFormat }} / slot

+
+
+
+ +
+ + +
+
+ + +
+
+ {{ v.pricePerSlot | currencyFormat }} +
+

per slot · all taxes included

+
+ + +
+
+ + + + + Max guests + + {{ v.capacity }} +
+
+ + + + + + Location + + {{ v.district }} +
+
+ + + + + Type + + {{ v.category }} +
+
+ + + + + +
+
+ + + + Instant booking confirmation +
+
+ + + + No hidden charges +
+
+ + + + Verified venue +
+
+ +
+
+ +
+
+} diff --git a/client/src/app/user/features/venue-details/venue-details.component.spec.ts b/client/src/app/user/features/venue-details/venue-details.component.spec.ts new file mode 100644 index 000000000..e072a7d82 --- /dev/null +++ b/client/src/app/user/features/venue-details/venue-details.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { VenueDetailsComponent } from './venue-details.component'; + +describe('VenueDetailsComponent', () => { + let component: VenueDetailsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VenueDetailsComponent], + providers: [provideRouter([]), provideHttpClient()], + }).compileComponents(); + + fixture = TestBed.createComponent(VenueDetailsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/user/features/venue-details/venue-details.component.ts b/client/src/app/user/features/venue-details/venue-details.component.ts new file mode 100644 index 000000000..2f54e2ffd --- /dev/null +++ b/client/src/app/user/features/venue-details/venue-details.component.ts @@ -0,0 +1,41 @@ +import { Component, inject, OnInit, signal, ChangeDetectionStrategy } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { VenueService } from '../../services/venue.service'; +import { Venue } from '../../../shared/models/venue.model'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; +import { CurrencyFormatPipe } from '../../../shared/pipes/currency-format.pipe'; + +@Component({ + selector: 'app-venue-details', + standalone: true, + imports: [ButtonComponent, CurrencyFormatPipe, RouterLink], + templateUrl: './venue-details.component.html', + styleUrl: './venue-details.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VenueDetailsComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly venueService = inject(VenueService); + + readonly venue = signal(null); + readonly loading = signal(true); + readonly selectedImage = signal(0); + + ngOnInit(): void { + const id = this.route.snapshot.paramMap.get('id') ?? ''; + if (id) { + this.venueService.loadVenueById(id).subscribe({ + next: (venue) => { this.venue.set(venue); this.loading.set(false); }, + error: () => this.loading.set(false), + }); + } + } + + selectImage(index: number): void { this.selectedImage.set(index); } + + onBookNow(): void { + const venue = this.venue(); + if (venue) this.router.navigate(['/user/checkout', venue.id]); + } +} diff --git a/client/src/app/user/features/venue-list/venue-list.component.css b/client/src/app/user/features/venue-list/venue-list.component.css new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/client/src/app/user/features/venue-list/venue-list.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/client/src/app/user/features/venue-list/venue-list.component.html b/client/src/app/user/features/venue-list/venue-list.component.html new file mode 100644 index 000000000..148d8cb00 --- /dev/null +++ b/client/src/app/user/features/venue-list/venue-list.component.html @@ -0,0 +1,111 @@ + +
+
+

Find Your Perfect Venue

+

Discover and book amazing spaces for every occasion

+
+
+ + + + +
+
+ +@if (loading()) { +
+ @for (i of [1,2,3,4,5,6]; track i) { +
+
+
+
+
+
+
+ } +
+ +} @else if (venues().length === 0) { +
+
+ + + +
+

No venues found

+

Try adjusting your search or check back later

+
+ +} @else { + +} diff --git a/client/src/app/user/features/venue-list/venue-list.component.spec.ts b/client/src/app/user/features/venue-list/venue-list.component.spec.ts new file mode 100644 index 000000000..00ea71791 --- /dev/null +++ b/client/src/app/user/features/venue-list/venue-list.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { VenueListComponent } from './venue-list.component'; + +describe('VenueListComponent', () => { + let component: VenueListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VenueListComponent], + providers: [provideRouter([]), provideHttpClient()], + }).compileComponents(); + + fixture = TestBed.createComponent(VenueListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/user/features/venue-list/venue-list.component.ts b/client/src/app/user/features/venue-list/venue-list.component.ts new file mode 100644 index 000000000..4f015519a --- /dev/null +++ b/client/src/app/user/features/venue-list/venue-list.component.ts @@ -0,0 +1,30 @@ +import { Component, inject, OnInit, signal, ChangeDetectionStrategy } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { VenueService } from '../../services/venue.service'; +import { CurrencyFormatPipe } from '../../../shared/pipes/currency-format.pipe'; + +@Component({ + selector: 'app-venue-list', + standalone: true, + imports: [RouterLink, FormsModule, CurrencyFormatPipe], + templateUrl: './venue-list.component.html', + styleUrl: './venue-list.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VenueListComponent implements OnInit { + private readonly venueService = inject(VenueService); + + readonly venues = this.venueService.venues; + readonly loading = this.venueService.loading; + readonly searchQuery = signal(''); + + ngOnInit(): void { + this.venueService.loadVenues(); + } + + onSearch(query: string): void { + this.searchQuery.set(query); + this.venueService.loadVenues({ search: query }); + } +} diff --git a/client/src/app/user/guards/user-auth.guard.ts b/client/src/app/user/guards/user-auth.guard.ts new file mode 100644 index 000000000..2772e4cbd --- /dev/null +++ b/client/src/app/user/guards/user-auth.guard.ts @@ -0,0 +1,16 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../../shared/services/auth.service'; +import { UserRole } from '../../shared/enums/user-role.enum'; + +export const userAuthGuard: CanActivateFn = () => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isPortalAuthenticated(UserRole.User)) { + return true; + } + + router.navigate(['/login']); + return false; +}; diff --git a/client/src/app/user/interceptors/user-auth.interceptor.ts b/client/src/app/user/interceptors/user-auth.interceptor.ts new file mode 100644 index 000000000..aeb39e670 --- /dev/null +++ b/client/src/app/user/interceptors/user-auth.interceptor.ts @@ -0,0 +1,23 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { AuthService } from '../../shared/services/auth.service'; + +export const userAuthInterceptor: HttpInterceptorFn = (req, next) => { + const authService = inject(AuthService); + const token = authService.getToken(); + + if (token) { + req = req.clone({ + setHeaders: { + Authorization: `Bearer ${token}` + }, + withCredentials: true + }); + } else { + req = req.clone({ + withCredentials: true + }); + } + + return next(req); +}; diff --git a/client/src/app/user/layouts/user-layout/user-layout.component.css b/client/src/app/user/layouts/user-layout/user-layout.component.css new file mode 100644 index 000000000..ca6b14ddc --- /dev/null +++ b/client/src/app/user/layouts/user-layout/user-layout.component.css @@ -0,0 +1,4 @@ +:host { + display: block; + height: 100vh; +} diff --git a/client/src/app/user/layouts/user-layout/user-layout.component.html b/client/src/app/user/layouts/user-layout/user-layout.component.html new file mode 100644 index 000000000..6e8974ab5 --- /dev/null +++ b/client/src/app/user/layouts/user-layout/user-layout.component.html @@ -0,0 +1,159 @@ +
+ + + + + +
+ +
+ @if (currentUser()) { +
+

Welcome back

+

{{ currentUser()?.name }}

+
+
+ +
+ {{ currentUser()?.name?.charAt(0)?.toUpperCase() }} +
+
+ } @else { +

Browse our venues

+ + + + + Sign In + + } +
+ + +
+ +
+ + +
+

© 2026 BookMyVenue. All rights reserved.

+
+
+ +
diff --git a/client/src/app/user/layouts/user-layout/user-layout.component.spec.ts b/client/src/app/user/layouts/user-layout/user-layout.component.spec.ts new file mode 100644 index 000000000..159e0a0b4 --- /dev/null +++ b/client/src/app/user/layouts/user-layout/user-layout.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { UserLayoutComponent } from './user-layout.component'; + +describe('UserLayoutComponent', () => { + let component: UserLayoutComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UserLayoutComponent], + providers: [provideRouter([]), provideHttpClient()], + }).compileComponents(); + + fixture = TestBed.createComponent(UserLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/user/layouts/user-layout/user-layout.component.ts b/client/src/app/user/layouts/user-layout/user-layout.component.ts new file mode 100644 index 000000000..fb7557d8c --- /dev/null +++ b/client/src/app/user/layouts/user-layout/user-layout.component.ts @@ -0,0 +1,32 @@ +import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core'; +import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; +import { AuthService } from '../../../shared/services/auth.service'; + +@Component({ + selector: 'app-user-layout', + standalone: true, + imports: [RouterOutlet, RouterLink, RouterLinkActive], + templateUrl: './user-layout.component.html', + styleUrl: './user-layout.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UserLayoutComponent { + private readonly authService = inject(AuthService); + + readonly currentUser = this.authService.currentUser; + readonly sidebarOpen = signal(true); + + readonly navItems = [ + { label: 'Browse Venues', path: '/user/venues', icon: 'search', requiresAuth: false }, + { label: 'My Bookings', path: '/user/my-bookings', icon: 'calendar', requiresAuth: true }, + { label: 'Profile', path: '/user/profile', icon: 'user', requiresAuth: true }, + ]; + + toggleSidebar(): void { + this.sidebarOpen.update(v => !v); + } + + logout(): void { + this.authService.logout(); + } +} diff --git a/client/src/app/user/models/index.ts b/client/src/app/user/models/index.ts new file mode 100644 index 000000000..43919a261 --- /dev/null +++ b/client/src/app/user/models/index.ts @@ -0,0 +1,3 @@ +export * from '../../shared/models/user.model'; +export * from '../../shared/models/venue.model'; +export * from '../../shared/models/booking.model'; diff --git a/client/src/app/user/repositories/booking.repository.ts b/client/src/app/user/repositories/booking.repository.ts new file mode 100644 index 000000000..9cd98e440 --- /dev/null +++ b/client/src/app/user/repositories/booking.repository.ts @@ -0,0 +1,49 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, map } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; +import { Booking, CreateBookingRequest } from '../../shared/models/booking.model'; +import { ApiResponse, PaginationResponse } from '../../shared/models/api-response.model'; + +@Injectable({ providedIn: 'root' }) +export class BookingRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + getUserBookings(): Observable> { + // Backend returns plain array, wrap it in pagination format + return this.http.get( + `${this.apiUrl}${API_ENDPOINTS.BOOKINGS.MY_BOOKINGS}` + ).pipe( + map(data => ({ + success: true, + data: data, + total: data.length, + page: 1, + limit: data.length, + totalPages: 1 + })) + ); + } + + getBookingById(id: string): Observable> { + return this.http.get>( + `${this.apiUrl}${API_ENDPOINTS.BOOKINGS.BY_ID(id)}` + ); + } + + createBooking(payload: CreateBookingRequest): Observable { + return this.http.post( + `${this.apiUrl}${API_ENDPOINTS.BOOKINGS.CREATE_WITH_SLOT(payload.venueId, payload.slotTemplateId)}`, + payload + ); + } + + cancelBooking(id: string): Observable { + return this.http.post( + `${this.apiUrl}${API_ENDPOINTS.BOOKINGS.CANCEL(id)}`, + {} + ); + } +} diff --git a/client/src/app/user/repositories/payment.repository.ts b/client/src/app/user/repositories/payment.repository.ts new file mode 100644 index 000000000..55ae1dcfc --- /dev/null +++ b/client/src/app/user/repositories/payment.repository.ts @@ -0,0 +1,26 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; +import { PaymentOrder, VerifyPaymentRequest } from '../../shared/models/booking.model'; + +@Injectable({ providedIn: 'root' }) +export class PaymentRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + createPaymentOrder(bookingId: number): Observable { + return this.http.post( + `${this.apiUrl}${API_ENDPOINTS.PAYMENTS.CREATE(bookingId)}`, + {} + ); + } + + verifyPayment(request: VerifyPaymentRequest): Observable { + return this.http.post( + `${this.apiUrl}${API_ENDPOINTS.PAYMENTS.VERIFY}`, + request + ); + } +} diff --git a/client/src/app/user/repositories/user.repository.ts b/client/src/app/user/repositories/user.repository.ts new file mode 100644 index 000000000..654376e4e --- /dev/null +++ b/client/src/app/user/repositories/user.repository.ts @@ -0,0 +1,26 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; +import { User, UpdateUserRequest } from '../../shared/models/user.model'; +import { ApiResponse } from '../../shared/models/api-response.model'; + +@Injectable({ providedIn: 'root' }) +export class UserRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + getProfile(): Observable> { + return this.http.get>( + `${this.apiUrl}${API_ENDPOINTS.USERS.PROFILE}` + ); + } + + updateProfile(payload: UpdateUserRequest): Observable> { + return this.http.put>( + `${this.apiUrl}${API_ENDPOINTS.USERS.PROFILE}`, + payload + ); + } +} diff --git a/client/src/app/user/repositories/venue.repository.ts b/client/src/app/user/repositories/venue.repository.ts new file mode 100644 index 000000000..2d9fe8c6b --- /dev/null +++ b/client/src/app/user/repositories/venue.repository.ts @@ -0,0 +1,46 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, map } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; +import { Venue, VenueFilter } from '../../shared/models/venue.model'; + +@Injectable({ providedIn: 'root' }) +export class VenueRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + getVenues(filter?: VenueFilter): Observable { + let params = new HttpParams(); + if (filter) { + Object.entries(filter).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + params = params.set(key, String(value)); + } + }); + } + return this.http.get( + `${this.apiUrl}${API_ENDPOINTS.VENUES.PUBLIC_BASE}`, + { params } + ).pipe( + map(venues => (venues || []).map(venue => ({ + ...venue, + id: String(venue.id), + imageUrls: venue.imageUrls || [] + }))) + ); + } + + getVenueById(id: string): Observable { + return this.http.get( + `${this.apiUrl}${API_ENDPOINTS.VENUES.PUBLIC_BY_ID(id)}` + ).pipe( + map(venue => ({ + ...venue, + id: String(venue.id), + imageUrls: venue.imageUrls || [] + })) + ); + } + +} diff --git a/client/src/app/user/routes/user.routes.ts b/client/src/app/user/routes/user.routes.ts new file mode 100644 index 000000000..818a79ead --- /dev/null +++ b/client/src/app/user/routes/user.routes.ts @@ -0,0 +1,52 @@ +import { Routes } from '@angular/router'; +import { ROUTE_PATHS } from '../../shared/constants/route-paths.constant'; +import { userAuthGuard } from '../guards/user-auth.guard'; + +export const USER_ROUTES: Routes = [ + { + path: '', + loadComponent: () => + import('../layouts/user-layout/user-layout.component').then(m => m.UserLayoutComponent), + children: [ + { + path: ROUTE_PATHS.USER.VENUES, + loadComponent: () => + import('../features/venue-list/venue-list.component').then(m => m.VenueListComponent), + }, + { + path: ROUTE_PATHS.USER.VENUE_DETAILS, + loadComponent: () => + import('../features/venue-details/venue-details.component').then(m => m.VenueDetailsComponent), + }, + { + path: ROUTE_PATHS.USER.CHECKOUT, + canActivate: [userAuthGuard], + loadComponent: () => + import('../features/checkout/checkout.component').then(m => m.CheckoutComponent), + }, + { + path: ROUTE_PATHS.USER.MY_BOOKINGS, + canActivate: [userAuthGuard], + loadComponent: () => + import('../features/my-bookings/my-bookings.component').then(m => m.MyBookingsComponent), + }, + { + path: ROUTE_PATHS.USER.MY_BOOKING_DETAIL, + canActivate: [userAuthGuard], + loadComponent: () => + import('../features/booking-detail/booking-detail.component').then(m => m.BookingDetailComponent), + }, + { + path: ROUTE_PATHS.USER.PROFILE, + canActivate: [userAuthGuard], + loadComponent: () => + import('../features/profile/profile.component').then(m => m.ProfileComponent), + }, + { + path: '', + redirectTo: ROUTE_PATHS.USER.VENUES, + pathMatch: 'full', + }, + ], + }, +]; diff --git a/client/src/app/user/services/booking.service.ts b/client/src/app/user/services/booking.service.ts new file mode 100644 index 000000000..a0e791112 --- /dev/null +++ b/client/src/app/user/services/booking.service.ts @@ -0,0 +1,41 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { Router } from '@angular/router'; +import { BookingRepository } from '../repositories/booking.repository'; +import { Booking, CreateBookingRequest } from '../../shared/models/booking.model'; +import { NotificationService } from '../../shared/services/notification.service'; +import { Observable } from 'rxjs'; +import { BookingStatus } from '../../shared/enums'; + +@Injectable({ providedIn: 'root' }) +export class BookingService { + private readonly bookingRepository = inject(BookingRepository); + private readonly notification = inject(NotificationService); + private readonly router = inject(Router); + + readonly bookings = signal([]); + readonly loading = signal(false); + + loadUserBookings(): void { + this.loading.set(true); + this.bookingRepository.getUserBookings().subscribe({ + next: (response) => { this.bookings.set(response.data); this.loading.set(false); }, + error: () => { this.notification.error('Failed to load bookings'); this.loading.set(false); }, + }); + } + + createBooking(payload: CreateBookingRequest): Observable { + return this.bookingRepository.createBooking(payload); + } + + cancelBooking(id: number): void { + this.bookingRepository.cancelBooking(String(id)).subscribe({ + next: () => { + this.bookings.update(list => + list.map(b => b.id === id ? { ...b, status: BookingStatus.CANCELLED } : b) + ); + this.notification.success('Booking cancelled'); + }, + error: () => this.notification.error('Failed to cancel booking'), + }); + } +} diff --git a/client/src/app/user/services/slot.services.ts b/client/src/app/user/services/slot.services.ts new file mode 100644 index 000000000..62ca211f4 --- /dev/null +++ b/client/src/app/user/services/slot.services.ts @@ -0,0 +1,80 @@ +// src/app/user/services/slot.service.ts +import { Injectable, inject, signal } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, map } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; +import { TimeSlot } from '../../shared/models/slot.model'; + +export interface AvailableSlotResponse { + slotTemplateId: number; + startTime: string; + endTime: string; +} + +@Injectable({ providedIn: 'root' }) +export class SlotService { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + readonly slots = signal([]); + readonly loading = signal(false); + + loadSlotsForVenue(venueId: string, date: string): Observable { + const params = new HttpParams().set('date', date); + return this.http.get( + `${this.apiUrl}${API_ENDPOINTS.VENUES.AVAILABILITY(venueId)}`, + { params } + ).pipe( + map(slots => (slots || []).map(slot => ({ + id: slot.slotTemplateId, + label: this.getLabelForSlot(slot.startTime), + startTime: slot.startTime.substring(0, 5), + endTime: slot.endTime.substring(0, 5), + duration: this.formatDuration(slot.startTime, slot.endTime), + available: true, + surcharge: 0 + }))) + ); + } + + private getLabelForSlot(startTime: string): string { + const parts = startTime.split(':').map(Number); + const hour = parts[0] || 0; + if (hour < 12) return 'Morning'; + if (hour < 16) return 'Afternoon'; + if (hour < 19) return 'Evening'; + return 'Night'; + } + + private parseTimeToMinutes(timeStr: string): number { + const parts = timeStr.split(':').map(Number); + const h = parts[0] || 0; + const m = parts[1] || 0; + return h * 60 + m; + } + + private formatDuration(start: string, end: string): string { + const startMins = this.parseTimeToMinutes(start); + const endMins = this.parseTimeToMinutes(end); + let diff = endMins - startMins; + if (diff < 0) { + diff += 24 * 60; // Handle overnight slots + } + const hours = Math.floor(diff / 60); + const mins = diff % 60; + return mins > 0 ? `${hours} hrs ${mins} mins` : `${hours} hrs`; + } + + + getDummySlots(): TimeSlot[] { + return [ + { id: 1, label: 'Morning', startTime: '09:00', endTime: '12:00', duration: '3 hrs', available: true, surcharge: 0 }, + { id: 2, label: 'Afternoon', startTime: '12:00', endTime: '16:00', duration: '4 hrs', available: true, surcharge: 2000 }, + { id: 3, label: 'Evening', startTime: '16:00', endTime: '20:00', duration: '4 hrs', available: false, surcharge: 3000 }, + { id: 4, label: 'Night', startTime: '20:00', endTime: '23:00', duration: '3 hrs', available: true, surcharge: 1500 }, + { id: 5, label: 'Full Day', startTime: '09:00', endTime: '23:00', duration: '14 hrs', available: true, surcharge: 8000 }, + { id: 6, label: 'Half Day AM', startTime: '09:00', endTime: '14:00', duration: '5 hrs', available: true, surcharge: 1000 }, + ]; + } +} \ No newline at end of file diff --git a/client/src/app/user/services/user.service.ts b/client/src/app/user/services/user.service.ts new file mode 100644 index 000000000..6972c1a04 --- /dev/null +++ b/client/src/app/user/services/user.service.ts @@ -0,0 +1,30 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { Observable, map, tap } from 'rxjs'; +import { UserRepository } from '../repositories/user.repository'; +import { User, UpdateUserRequest } from '../../shared/models/user.model'; +import { NotificationService } from '../../shared/services/notification.service'; + +@Injectable({ providedIn: 'root' }) +export class UserService { + private readonly userRepository = inject(UserRepository); + private readonly notification = inject(NotificationService); + + readonly profile = signal(null); + + loadProfile(): Observable { + return this.userRepository.getProfile().pipe( + map((response) => response.data), + tap((user) => this.profile.set(user)) + ); + } + + updateProfile(payload: UpdateUserRequest): Observable { + return this.userRepository.updateProfile(payload).pipe( + map((response) => response.data), + tap((user) => { + this.profile.set(user); + this.notification.success('Profile updated successfully'); + }) + ); + } +} diff --git a/client/src/app/user/services/venue.service.ts b/client/src/app/user/services/venue.service.ts new file mode 100644 index 000000000..d34b18098 --- /dev/null +++ b/client/src/app/user/services/venue.service.ts @@ -0,0 +1,32 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { Observable, map, tap } from 'rxjs'; +import { VenueRepository } from '../repositories/venue.repository'; +import { Venue, VenueFilter } from '../../shared/models/venue.model'; +import { NotificationService } from '../../shared/services/notification.service'; + +@Injectable({ providedIn: 'root' }) +export class VenueService { + private readonly venueRepository = inject(VenueRepository); + private readonly notification = inject(NotificationService); + + readonly venues = signal([]); + readonly loading = signal(false); + + loadVenues(filter?: VenueFilter): void { + this.loading.set(true); + this.venueRepository.getVenues(filter).subscribe({ + next: (venues) => { + this.venues.set(venues); + this.loading.set(false); + }, + error: () => { + this.notification.error('Failed to load venues'); + this.loading.set(false); + }, + }); + } + + loadVenueById(id: string): Observable { + return this.venueRepository.getVenueById(id); + } +} diff --git a/client/src/app/vendor/features/analytics/analytics.component.css b/client/src/app/vendor/features/analytics/analytics.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/vendor/features/analytics/analytics.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/vendor/features/analytics/analytics.component.html b/client/src/app/vendor/features/analytics/analytics.component.html new file mode 100644 index 000000000..5d19c8be0 --- /dev/null +++ b/client/src/app/vendor/features/analytics/analytics.component.html @@ -0,0 +1,41 @@ +
+
+

Analytics

+
+ @for (p of ['week', 'month', 'year']; track p) { + + } +
+
+ +
+
+

Revenue Overview

+
+

Chart placeholder — integrate with a charting library

+
+
+
+

Booking Trends

+
+

Chart placeholder — integrate with a charting library

+
+
+
+

Top Venues

+
+

Chart placeholder — integrate with a charting library

+
+
+
+

Occupancy Rate

+
+

Chart placeholder — integrate with a charting library

+
+
+
+
diff --git a/client/src/app/vendor/features/analytics/analytics.component.spec.ts b/client/src/app/vendor/features/analytics/analytics.component.spec.ts new file mode 100644 index 000000000..cc4b08170 --- /dev/null +++ b/client/src/app/vendor/features/analytics/analytics.component.spec.ts @@ -0,0 +1,10 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AnalyticsComponent } from './analytics.component'; +describe('Vendor AnalyticsComponent', () => { + let component: AnalyticsComponent; let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [AnalyticsComponent] }).compileComponents(); + fixture = TestBed.createComponent(AnalyticsComponent); component = fixture.componentInstance; fixture.detectChanges(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); +}); diff --git a/client/src/app/vendor/features/analytics/analytics.component.ts b/client/src/app/vendor/features/analytics/analytics.component.ts new file mode 100644 index 000000000..7c577eaad --- /dev/null +++ b/client/src/app/vendor/features/analytics/analytics.component.ts @@ -0,0 +1,18 @@ +import { Component, signal, ChangeDetectionStrategy } from '@angular/core'; +import { TitleCasePipe } from '@angular/common'; + +@Component({ + selector: 'app-vendor-analytics', + standalone: true, + imports: [TitleCasePipe], + templateUrl: './analytics.component.html', + styleUrl: './analytics.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AnalyticsComponent { + readonly period = signal<'week' | 'month' | 'year'>('month'); + + setPeriod(period: 'week' | 'month' | 'year'): void { + this.period.set(period); + } +} diff --git a/client/src/app/vendor/features/bookings/bookings.component.css b/client/src/app/vendor/features/bookings/bookings.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/vendor/features/bookings/bookings.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/vendor/features/bookings/bookings.component.html b/client/src/app/vendor/features/bookings/bookings.component.html new file mode 100644 index 000000000..d38553cfd --- /dev/null +++ b/client/src/app/vendor/features/bookings/bookings.component.html @@ -0,0 +1,48 @@ +
+

Bookings

+ @if (loading()) { + + } @else if (bookings().length === 0) { + + } @else { +
+ + + + + + + + + + + + + @for (booking of bookings(); track booking.id) { + + + + + + + + + } + +
Venue IDDateSlot IDAmountStatusActions
{{ booking.venueId }}{{ booking.bookingDate | dateFormat }}{{ booking.slotTemplateId }}{{ booking.totalAmount | currencyFormat }} + {{ booking.status + }} + + @if (booking.status === BookingStatus.PENDING) { +
+ + +
+ } +
+
+ } +
\ No newline at end of file diff --git a/client/src/app/vendor/features/bookings/bookings.component.spec.ts b/client/src/app/vendor/features/bookings/bookings.component.spec.ts new file mode 100644 index 000000000..c864062b3 --- /dev/null +++ b/client/src/app/vendor/features/bookings/bookings.component.spec.ts @@ -0,0 +1,11 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { BookingsComponent } from './bookings.component'; +describe('Vendor BookingsComponent', () => { + let component: BookingsComponent; let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [BookingsComponent], providers: [provideHttpClient()] }).compileComponents(); + fixture = TestBed.createComponent(BookingsComponent); component = fixture.componentInstance; fixture.detectChanges(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); +}); diff --git a/client/src/app/vendor/features/bookings/bookings.component.ts b/client/src/app/vendor/features/bookings/bookings.component.ts new file mode 100644 index 000000000..9dc81f384 --- /dev/null +++ b/client/src/app/vendor/features/bookings/bookings.component.ts @@ -0,0 +1,27 @@ +import { Component, inject, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { BookingService } from '../../services/booking.service'; +import { BookingStatus } from '../../../shared/enums/booking-status.enum'; +import { LoaderComponent } from '../../../shared/components/loader/loader.component'; +import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component'; +import { DateFormatPipe } from '../../../shared/pipes/date-format.pipe'; +import { CurrencyFormatPipe } from '../../../shared/pipes/currency-format.pipe'; + +@Component({ + selector: 'app-vendor-bookings', + standalone: true, + imports: [LoaderComponent, EmptyStateComponent, DateFormatPipe, CurrencyFormatPipe], + templateUrl: './bookings.component.html', + styleUrl: './bookings.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BookingsComponent implements OnInit { + private readonly bookingService = inject(BookingService); + readonly bookings = this.bookingService.bookings; + readonly loading = this.bookingService.loading; + readonly BookingStatus = BookingStatus; + + ngOnInit(): void { this.bookingService.loadVendorBookings(); } + + onApprove(id: number): void { this.bookingService.updateBookingStatus(id, 'confirmed'); } + onReject(id: number): void { this.bookingService.updateBookingStatus(id, 'rejected'); } +} \ No newline at end of file diff --git a/client/src/app/vendor/features/create-venue/create-venue.component.css b/client/src/app/vendor/features/create-venue/create-venue.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/vendor/features/create-venue/create-venue.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/vendor/features/create-venue/create-venue.component.html b/client/src/app/vendor/features/create-venue/create-venue.component.html new file mode 100644 index 000000000..4c7d953fe --- /dev/null +++ b/client/src/app/vendor/features/create-venue/create-venue.component.html @@ -0,0 +1,265 @@ +
+ + +
+
+

Create New Venue

+

Fill in the details below to list your venue

+
+
+ + +
+
+ +
+ + +
+
+
+ 1 +
+

Basic Information

+
+
+ + +
+ + + @if (form.controls.description.touched && form.controls.description.errors) { +

Description must be at least 20 characters

+ } +
+ + +
+ + + @if (form.controls.categoryId.touched && form.controls.categoryId.errors) { +

Please select a category

+ } +
+
+
+ + +
+
+
+ 2 +
+

Location

+
+
+ + +
+
+ + +
+
+
+ 3 +
+

Pricing & Capacity

+
+
+
+ + +
+
+ +
+
+
+ + +
+
+
+ 4 +
+

Venue Photos

+ {{ imageUrls().length }} / 10 uploaded +
+
+ + + + + + @if (imageUrls().length > 0) { +
+ @for (url of imageUrls(); track url; let i = $index) { +
+ Venue photo {{ i + 1 }} +
+ + @if (i === 0) { + Cover + } +
+ } +
+ } +
+
+ + +
+
+
+ 5 +
+

Available Amenities

+ {{ selectedAmenities().size }} selected +
+
+

Select all amenities your venue provides to guests

+
+ @for (amenity of amenitiesList; track amenity.id) { + + } +
+
+
+ + +
+ + +
+ +
+
diff --git a/client/src/app/vendor/features/create-venue/create-venue.component.spec.ts b/client/src/app/vendor/features/create-venue/create-venue.component.spec.ts new file mode 100644 index 000000000..2bfade49d --- /dev/null +++ b/client/src/app/vendor/features/create-venue/create-venue.component.spec.ts @@ -0,0 +1,13 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { CreateVenueComponent } from './create-venue.component'; +describe('CreateVenueComponent', () => { + let component: CreateVenueComponent; + let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [CreateVenueComponent], providers: [provideRouter([]), provideHttpClient()] }).compileComponents(); + fixture = TestBed.createComponent(CreateVenueComponent); component = fixture.componentInstance; fixture.detectChanges(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); +}); diff --git a/client/src/app/vendor/features/create-venue/create-venue.component.ts b/client/src/app/vendor/features/create-venue/create-venue.component.ts new file mode 100644 index 000000000..4bce874c2 --- /dev/null +++ b/client/src/app/vendor/features/create-venue/create-venue.component.ts @@ -0,0 +1,134 @@ +import { Component, inject, signal, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { VenueService } from '../../services/venue.service'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; +import { InputComponent } from '../../../shared/components/input/input.component'; +import { CloudinaryService } from '../../../shared/services/cloudinary.service'; +import { NotificationService } from '../../../shared/services/notification.service'; +import { CategoryService } from '../../../shared/services/category.service'; +import { VenueCategory } from '../../../shared/models/venue.model'; +import { forkJoin } from 'rxjs'; + +export interface VenueAmenity { id: string; name: string; desc: string; icon: string; } + +@Component({ + selector: 'app-create-venue', + standalone: true, + imports: [ReactiveFormsModule, ButtonComponent, InputComponent], + templateUrl: './create-venue.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CreateVenueComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly venueService = inject(VenueService); + private readonly cloudinary = inject(CloudinaryService); + private readonly notification = inject(NotificationService); + private readonly categoryService = inject(CategoryService); + readonly router = inject(Router); + + readonly saving = signal(false); + readonly uploading = signal(false); + readonly imageUrls = signal([]); + readonly selectedAmenities = signal>(new Set()); + readonly dragOver = signal(false); + + readonly categories = signal([]); + + ngOnInit(): void { + this.categoryService.getCategories().subscribe({ + next: (categories) => this.categories.set(categories), + error: () => this.notification.error('Failed to load venue categories'), + }); + } + + readonly amenitiesList: VenueAmenity[] = [ + { id: 'catering', name: 'Catering Service', desc: 'In-house food & beverage', icon: 'M3 3h2l.4 2M7 13h10l4-4H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z' }, + { id: 'av', name: 'AV & Sound System', desc: 'PA system, projector & lighting', icon: 'M15.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 12M8.464 8.464a5 5 0 000 7.072M21 12a9 9 0 11-18 0 9 9 0 0118 0z' }, + { id: 'parking', name: 'Parking', desc: 'On-site vehicle parking', icon: 'M8 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM15 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM3 4h13l4 4v4H3V4z' }, + { id: 'decoration', name: 'Decoration', desc: 'Floral & thematic decor setup', icon: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z' }, + { id: 'security', name: 'Security', desc: 'Trained security personnel', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' }, + { id: 'photography', name: 'Photography', desc: 'Photo booths & candid setup', icon: 'M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z M15 13a3 3 0 11-6 0 3 3 0 016 0z' }, + { id: 'wifi', name: 'High-Speed WiFi', desc: 'Fibre broadband for all guests', icon: 'M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0' }, + { id: 'ac', name: 'Air Conditioning', desc: 'Central AC throughout venue', icon: 'M9.59 4.59A2 2 0 1111 8H2m10.59 11.41A2 2 0 1010 16H2m15.73-8.27A2 2 0 1119 12H2' }, + { id: 'generator', name: 'Generator Backup', desc: '24/7 power backup available', icon: 'M13 10V3L4 14h7v7l9-11h-7z' }, + { id: 'bridal_room', name: 'Bridal / VIP Room', desc: 'Private suite for the main guests', icon: 'M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z' }, + { id: 'kitchen', name: 'Catering Kitchen', desc: 'Commercial kitchen for external caterers', icon: 'M17 14v6m-3-3h6M6 10h2a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2zm10 0h2a2 2 0 002-2V6a2 2 0 00-2-2h-2a2 2 0 00-2 2v2a2 2 0 002 2zM6 20h2a2 2 0 002-2v-2a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2z' }, + { id: 'stage', name: 'Stage & Podium', desc: 'Elevated stage with podium mic', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' }, + ]; + + readonly form = this.fb.nonNullable.group({ + name: ['', [Validators.required]], + description: ['', [Validators.required, Validators.minLength(20)]], + address: ['', [Validators.required]], + district: ['', [Validators.required]], + capacity: ['', [Validators.required, Validators.min(1)]], + pricePerSlot:['', [Validators.required, Validators.min(1)]], + advancePercentage: ['', [Validators.required, Validators.min(0), Validators.max(100)]], + categoryId: ['', [Validators.required, Validators.min(1)]], + }); + + toggleAmenity(id: string): void { + this.selectedAmenities.update(set => { + const next = new Set(set); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + isAmenitySelected(id: string): boolean { + return this.selectedAmenities().has(id); + } + + onDragOver(e: DragEvent): void { e.preventDefault(); this.dragOver.set(true); } + onDragLeave(): void { this.dragOver.set(false); } + onDrop(e: DragEvent): void { + e.preventDefault(); + this.dragOver.set(false); + const files = Array.from(e.dataTransfer?.files ?? []).filter(f => f.type.startsWith('image/')); + if (files.length) this.uploadFiles(files); + } + + onFilesSelected(event: Event): void { + const input = event.target as HTMLInputElement; + if (!input.files?.length) return; + this.uploadFiles(Array.from(input.files)); + input.value = ''; + } + + private uploadFiles(files: File[]): void { + this.uploading.set(true); + forkJoin(files.map(f => this.cloudinary.upload(f))).subscribe({ + next: urls => { this.imageUrls.update(existing => [...existing, ...urls]); this.uploading.set(false); }, + error: () => { this.uploading.set(false); this.notification.error('Image upload failed. Please try again.'); }, + }); + } + + removeImage(url: string): void { + this.imageUrls.update(existing => existing.filter(u => u !== url)); + } + + onSubmit(): void { + if (this.form.valid) { + this.saving.set(true); + const raw = this.form.getRawValue(); + this.venueService.createVenue({ + name: raw.name, + description: raw.description, + address: raw.address, + district: raw.district, + capacity: Number(raw.capacity), + pricePerSlot: Number(raw.pricePerSlot), + advancePercentage: Number(raw.advancePercentage), + categoryId: Number(raw.categoryId), + imageUrls: this.imageUrls(), + // amenities: Array.from(this.selectedAmenities()), // add when backend supports it + }).subscribe({ + next: () => { this.saving.set(false); this.router.navigate(['/vendor/venues']); }, + error: () => { this.saving.set(false); this.notification.error('Failed to create venue. Please check your inputs.'); }, + }); + } else { + this.form.markAllAsTouched(); + } + } +} diff --git a/client/src/app/vendor/features/dashboard/dashboard.component.css b/client/src/app/vendor/features/dashboard/dashboard.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/vendor/features/dashboard/dashboard.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/vendor/features/dashboard/dashboard.component.html b/client/src/app/vendor/features/dashboard/dashboard.component.html new file mode 100644 index 000000000..d4ac336ee --- /dev/null +++ b/client/src/app/vendor/features/dashboard/dashboard.component.html @@ -0,0 +1,155 @@ +
+ + +
+ +
+ +
+
+

Good morning,

+

+ {{ authService.currentUser()?.name }} +

+

Here's what's happening with your venues today.

+
+ + + + + Add Venue + +
+ + + @if (loading()) { +
+ @for (i of [1,2,3,4]; track i) { +
+
+
+
+
+
+
+ } +
+ } @else { +
+ + +
+
+ Total Venues +
+ + + +
+
+

{{ stats().totalVenues }}

+

Listed properties

+
+ + +
+
+ Total Bookings +
+ + + +
+
+

{{ stats().totalBookings }}

+

All time reservations

+
+ + +
+
+ Total Revenue +
+ + + +
+
+

₹{{ stats().totalRevenue | number }}

+

Lifetime earnings

+
+ + +
+
+ Pending +
+ + + +
+
+

{{ stats().pendingBookings }}

+

Awaiting confirmation

+ @if (stats().pendingBookings > 0) { + + + Action needed + + } +
+ +
+ } + + +
+
+

Quick Actions

+

Jump to common tasks

+
+
+ @for (action of quickActions; track action.path) { + +
+ + + +
+
+

{{ action.label }}

+

{{ action.desc }}

+
+ + + +
+ } +
+
+ + +
+
+ + + +
+
+

Tip: Add photos to increase bookings

+

Venues with 5+ high-quality photos receive 3× more enquiries.

+
+ + Manage Venues → + +
+ +
diff --git a/client/src/app/vendor/features/dashboard/dashboard.component.spec.ts b/client/src/app/vendor/features/dashboard/dashboard.component.spec.ts new file mode 100644 index 000000000..2ace629f6 --- /dev/null +++ b/client/src/app/vendor/features/dashboard/dashboard.component.spec.ts @@ -0,0 +1,19 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { DashboardComponent } from './dashboard.component'; + +describe('Vendor DashboardComponent', () => { + let component: DashboardComponent; + let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DashboardComponent], + providers: [provideRouter([]), provideHttpClient()], + }).compileComponents(); + fixture = TestBed.createComponent(DashboardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); +}); diff --git a/client/src/app/vendor/features/dashboard/dashboard.component.ts b/client/src/app/vendor/features/dashboard/dashboard.component.ts new file mode 100644 index 000000000..5b55bfafc --- /dev/null +++ b/client/src/app/vendor/features/dashboard/dashboard.component.ts @@ -0,0 +1,62 @@ +import { Component, inject, OnInit, signal, ChangeDetectionStrategy } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { DecimalPipe } from '@angular/common'; +import { AuthService } from '../../../shared/services/auth.service'; +import { VendorService } from '../../services/vendor.service'; + +@Component({ + selector: 'app-vendor-dashboard', + standalone: true, + imports: [RouterLink, DecimalPipe], + templateUrl: './dashboard.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DashboardComponent implements OnInit { + private readonly vendorService = inject(VendorService); + readonly authService = inject(AuthService); + + readonly stats = signal({ + totalVenues: 0, + totalBookings: 0, + totalRevenue: 0, + pendingBookings: 0, + }); + readonly loading = signal(true); + + readonly quickActions = [ + { + label: 'Add New Venue', + desc: 'List a new venue for bookings', + path: '/vendor/venues/create', + icon: 'M12 4v16m8-8H4', + color: 'text-accent-600', + bg: 'bg-amber-50', + border: 'hover:border-accent-400', + }, + { + label: 'Manage Bookings', + desc: 'View, confirm & cancel bookings', + path: '/vendor/bookings', + icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2', + color: 'text-emerald-600', + bg: 'bg-emerald-50', + border: 'hover:border-emerald-300', + }, + { + label: 'My Venues', + desc: 'Edit listings & manage details', + path: '/vendor/venues', + icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4', + color: 'text-brand-500', + bg: 'bg-blue-50', + border: 'hover:border-brand-400', + }, + ]; + + ngOnInit(): void { + this.vendorService.loadDashboardStats().subscribe({ + next: (data) => { this.stats.set(data); this.loading.set(false); }, + error: () => this.loading.set(false), + }); + } +} diff --git a/client/src/app/vendor/features/edit-venue/edit-venue.component.css b/client/src/app/vendor/features/edit-venue/edit-venue.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/vendor/features/edit-venue/edit-venue.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/vendor/features/edit-venue/edit-venue.component.html b/client/src/app/vendor/features/edit-venue/edit-venue.component.html new file mode 100644 index 000000000..4db9b7b36 --- /dev/null +++ b/client/src/app/vendor/features/edit-venue/edit-venue.component.html @@ -0,0 +1,289 @@ +
+ + @if (loading()) { + +
+
+
+
+
+
+
+
+
+
+
+ @for (i of [1, 2, 3, 4, 5]; track i) { +
+
+
+
+
+ } +
+ } @else { + + +
+
+

Edit Venue

+

Update the details of your listed venue

+
+
+ + +
+
+ +
+ + +
+
+
+ 1 +
+

Basic Information

+
+
+ + +
+ + + @if (form.controls.description.touched && form.controls.description.errors) { +

Description must be at least 20 characters

+ } +
+ + +
+ + + @if (form.controls.categoryId.touched && form.controls.categoryId.errors) { +

Please select a category

+ } +
+
+
+ + +
+
+
+ 2 +
+

Location

+
+
+ + +
+
+ + +
+
+
+ 3 +
+

Pricing & Capacity

+
+
+
+ + +
+
+ +
+
+
+ + +
+
+
+ 4 +
+

Venue Photos

+ {{ imageUrls().length }} / 10 uploaded +
+
+ + + + + + @if (imageUrls().length > 0) { +
+ @for (url of imageUrls(); track url; let i = $index) { +
+ Venue photo {{ i + 1 }} +
+ + @if (i === 0) { + Cover + } +
+ } +
+ } +
+
+ + +
+
+
+ 5 +
+

Available Amenities

+ {{ selectedAmenities().size }} selected +
+
+

Select all amenities your venue provides to guests

+
+ @for (amenity of amenitiesList; track amenity.id) { + + } +
+
+
+ + +
+ + +
+ +
+ } +
diff --git a/client/src/app/vendor/features/edit-venue/edit-venue.component.spec.ts b/client/src/app/vendor/features/edit-venue/edit-venue.component.spec.ts new file mode 100644 index 000000000..3e932620e --- /dev/null +++ b/client/src/app/vendor/features/edit-venue/edit-venue.component.spec.ts @@ -0,0 +1,12 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { EditVenueComponent } from './edit-venue.component'; +describe('EditVenueComponent', () => { + let component: EditVenueComponent; let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [EditVenueComponent], providers: [provideRouter([]), provideHttpClient()] }).compileComponents(); + fixture = TestBed.createComponent(EditVenueComponent); component = fixture.componentInstance; fixture.detectChanges(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); +}); diff --git a/client/src/app/vendor/features/edit-venue/edit-venue.component.ts b/client/src/app/vendor/features/edit-venue/edit-venue.component.ts new file mode 100644 index 000000000..fa9f7333c --- /dev/null +++ b/client/src/app/vendor/features/edit-venue/edit-venue.component.ts @@ -0,0 +1,168 @@ +import { Component, inject, signal, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { VenueService } from '../../services/venue.service'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; +import { InputComponent } from '../../../shared/components/input/input.component'; +import { CloudinaryService } from '../../../shared/services/cloudinary.service'; +import { NotificationService } from '../../../shared/services/notification.service'; +import { CategoryService } from '../../../shared/services/category.service'; +import { VenueCategory } from '../../../shared/models/venue.model'; +import { forkJoin } from 'rxjs'; +import { VenueAmenity } from '../create-venue/create-venue.component'; + +@Component({ + selector: 'app-edit-venue', + standalone: true, + imports: [ReactiveFormsModule, ButtonComponent, InputComponent], + templateUrl: './edit-venue.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EditVenueComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + readonly router = inject(Router); + private readonly fb = inject(FormBuilder); + private readonly venueService = inject(VenueService); + private readonly cloudinary = inject(CloudinaryService); + private readonly notification = inject(NotificationService); + private readonly categoryService = inject(CategoryService); + + readonly loading = signal(true); + readonly saving = signal(false); + readonly uploading = signal(false); + readonly imageUrls = signal([]); + readonly selectedAmenities = signal>(new Set()); + readonly dragOver = signal(false); + + private venueId = ''; + + readonly categories = signal([]); + + readonly amenitiesList: VenueAmenity[] = [ + { id: 'catering', name: 'Catering Service', desc: 'In-house food & beverage', icon: 'M3 3h2l.4 2M7 13h10l4-4H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z' }, + { id: 'av', name: 'AV & Sound System', desc: 'PA system, projector & lighting', icon: 'M15.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 12M8.464 8.464a5 5 0 000 7.072M21 12a9 9 0 11-18 0 9 9 0 0118 0z' }, + { id: 'parking', name: 'Parking', desc: 'On-site vehicle parking', icon: 'M8 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM15 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM3 4h13l4 4v4H3V4z' }, + { id: 'decoration', name: 'Decoration', desc: 'Floral & thematic decor setup', icon: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z' }, + { id: 'security', name: 'Security', desc: 'Trained security personnel', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' }, + { id: 'photography', name: 'Photography', desc: 'Photo booths & candid setup', icon: 'M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z M15 13a3 3 0 11-6 0 3 3 0 016 0z' }, + { id: 'wifi', name: 'High-Speed WiFi', desc: 'Fibre broadband for all guests', icon: 'M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0' }, + { id: 'ac', name: 'Air Conditioning', desc: 'Central AC throughout venue', icon: 'M9.59 4.59A2 2 0 1111 8H2m10.59 11.41A2 2 0 1010 16H2m15.73-8.27A2 2 0 1119 12H2' }, + { id: 'generator', name: 'Generator Backup', desc: '24/7 power backup available', icon: 'M13 10V3L4 14h7v7l9-11h-7z' }, + { id: 'bridal_room', name: 'Bridal / VIP Room', desc: 'Private suite for the main guests', icon: 'M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z' }, + { id: 'kitchen', name: 'Catering Kitchen', desc: 'Commercial kitchen for external caterers', icon: 'M17 14v6m-3-3h6M6 10h2a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2zm10 0h2a2 2 0 002-2V6a2 2 0 00-2-2h-2a2 2 0 00-2 2v2a2 2 0 002 2zM6 20h2a2 2 0 002-2v-2a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2z' }, + { id: 'stage', name: 'Stage & Podium', desc: 'Elevated stage with podium mic', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' }, + ]; + + readonly form = this.fb.nonNullable.group({ + name: ['', [Validators.required]], + description: ['', [Validators.required, Validators.minLength(20)]], + address: ['', [Validators.required]], + district: ['', [Validators.required]], + capacity: ['', [Validators.required, Validators.min(1)]], + pricePerSlot: ['', [Validators.required, Validators.min(1)]], + advancePercentage: ['', [Validators.required, Validators.min(0), Validators.max(100)]], + categoryId: ['', [Validators.required, Validators.min(1)]], + }); + + ngOnInit(): void { + this.venueId = this.route.snapshot.paramMap.get('id') || ''; + + this.categoryService.getCategories().subscribe({ + next: (categories) => { + this.categories.set(categories); + const match = categories.find(c => c.name === this.pendingCategoryName); + if (match) this.form.patchValue({ categoryId: String(match.id) }); + }, + error: () => this.notification.error('Failed to load venue categories'), + }); + + if (this.venueId) { + this.venueService.loadVenueById(this.venueId).subscribe({ + next: (venue) => { + this.pendingCategoryName = venue.category; + const match = this.categories().find(c => c.name === venue.category); + this.form.patchValue({ + name: venue.name, + description: venue.description, + address: venue.address, + district: venue.district, + capacity: String(venue.capacity), + pricePerSlot: String(venue.pricePerSlot), + advancePercentage: String(venue.advancePercentage ?? ''), + categoryId: match ? String(match.id) : '', + }); + this.imageUrls.set(venue.imageUrls ?? []); + // this.selectedAmenities.set(new Set(venue.amenities ?? [])); // enable when backend ready + this.loading.set(false); + }, + error: () => this.loading.set(false), + }); + } + } + + private pendingCategoryName = ''; + + toggleAmenity(id: string): void { + this.selectedAmenities.update(set => { + const next = new Set(set); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + isAmenitySelected(id: string): boolean { + return this.selectedAmenities().has(id); + } + + onDragOver(e: DragEvent): void { e.preventDefault(); this.dragOver.set(true); } + onDragLeave(): void { this.dragOver.set(false); } + onDrop(e: DragEvent): void { + e.preventDefault(); + this.dragOver.set(false); + const files = Array.from(e.dataTransfer?.files ?? []).filter(f => f.type.startsWith('image/')); + if (files.length) this.uploadFiles(files); + } + + onFilesSelected(event: Event): void { + const input = event.target as HTMLInputElement; + if (!input.files?.length) return; + this.uploadFiles(Array.from(input.files)); + input.value = ''; + } + + private uploadFiles(files: File[]): void { + this.uploading.set(true); + forkJoin(files.map(f => this.cloudinary.upload(f))).subscribe({ + next: urls => { this.imageUrls.update(existing => [...existing, ...urls]); this.uploading.set(false); }, + error: () => { this.uploading.set(false); this.notification.error('Image upload failed. Please try again.'); }, + }); + } + + removeImage(url: string): void { + this.imageUrls.update(existing => existing.filter(u => u !== url)); + } + + onSubmit(): void { + if (this.form.valid) { + this.saving.set(true); + const raw = this.form.getRawValue(); + this.venueService.updateVenue(this.venueId, { + name: raw.name, + description: raw.description, + address: raw.address, + district: raw.district, + capacity: Number(raw.capacity), + pricePerSlot: Number(raw.pricePerSlot), + advancePercentage: raw.advancePercentage ? Number(raw.advancePercentage) : undefined, + categoryId: raw.categoryId ? Number(raw.categoryId) : undefined, + imageUrls: this.imageUrls(), + // amenities: Array.from(this.selectedAmenities()), // enable when backend ready + }).subscribe({ + next: () => { this.saving.set(false); this.router.navigate(['/vendor/venues']); }, + error: () => { this.saving.set(false); this.notification.error('Failed to update venue. Please check your inputs.'); }, + }); + } else { + this.form.markAllAsTouched(); + } + } +} diff --git a/client/src/app/vendor/features/forgot-password/forgot-password.component.css b/client/src/app/vendor/features/forgot-password/forgot-password.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/vendor/features/forgot-password/forgot-password.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/vendor/features/forgot-password/forgot-password.component.html b/client/src/app/vendor/features/forgot-password/forgot-password.component.html new file mode 100644 index 000000000..01d970ca9 --- /dev/null +++ b/client/src/app/vendor/features/forgot-password/forgot-password.component.html @@ -0,0 +1,20 @@ +
+
+
+

Forgot Password

+

We'll send a reset link to your email

+
+
+
+ +
+ +
+ +

+ Back to login +

+
+
+
diff --git a/client/src/app/vendor/features/forgot-password/forgot-password.component.spec.ts b/client/src/app/vendor/features/forgot-password/forgot-password.component.spec.ts new file mode 100644 index 000000000..6614c9376 --- /dev/null +++ b/client/src/app/vendor/features/forgot-password/forgot-password.component.spec.ts @@ -0,0 +1,19 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { ForgotPasswordComponent } from './forgot-password.component'; + +describe('Vendor ForgotPasswordComponent', () => { + let component: ForgotPasswordComponent; + let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ForgotPasswordComponent], + providers: [provideRouter([]), provideHttpClient()], + }).compileComponents(); + fixture = TestBed.createComponent(ForgotPasswordComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); +}); diff --git a/client/src/app/vendor/features/forgot-password/forgot-password.component.ts b/client/src/app/vendor/features/forgot-password/forgot-password.component.ts new file mode 100644 index 000000000..ae716708f --- /dev/null +++ b/client/src/app/vendor/features/forgot-password/forgot-password.component.ts @@ -0,0 +1,36 @@ +import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { AuthService } from '../../../shared/services/auth.service'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; +import { InputComponent } from '../../../shared/components/input/input.component'; + +@Component({ + selector: 'app-vendor-forgot-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink, ButtonComponent, InputComponent], + templateUrl: './forgot-password.component.html', + styleUrl: './forgot-password.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ForgotPasswordComponent { + private readonly fb = inject(FormBuilder); + private readonly authService = inject(AuthService); + private readonly router = inject(Router); + + readonly loading = this.authService.loading; + + readonly form = this.fb.nonNullable.group({ + email: ['', [Validators.required, Validators.email]], + }); + + onSubmit(): void { + if (this.form.valid) { + this.authService.forgotPassword(this.form.getRawValue(), () => { + this.router.navigate(['/reset-password']); + }); + } else { + this.form.markAllAsTouched(); + } + } +} diff --git a/client/src/app/vendor/features/settings/settings.component.css b/client/src/app/vendor/features/settings/settings.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/vendor/features/settings/settings.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/vendor/features/settings/settings.component.html b/client/src/app/vendor/features/settings/settings.component.html new file mode 100644 index 000000000..b7d01e3d0 --- /dev/null +++ b/client/src/app/vendor/features/settings/settings.component.html @@ -0,0 +1,17 @@ +
+

Settings

+
+

Business Information

+
+
+ + + + +
+
+ +
+
+
+
diff --git a/client/src/app/vendor/features/settings/settings.component.spec.ts b/client/src/app/vendor/features/settings/settings.component.spec.ts new file mode 100644 index 000000000..123e538e0 --- /dev/null +++ b/client/src/app/vendor/features/settings/settings.component.spec.ts @@ -0,0 +1,10 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SettingsComponent } from './settings.component'; +describe('Vendor SettingsComponent', () => { + let component: SettingsComponent; let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [SettingsComponent] }).compileComponents(); + fixture = TestBed.createComponent(SettingsComponent); component = fixture.componentInstance; fixture.detectChanges(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); +}); diff --git a/client/src/app/vendor/features/settings/settings.component.ts b/client/src/app/vendor/features/settings/settings.component.ts new file mode 100644 index 000000000..cd14b5cff --- /dev/null +++ b/client/src/app/vendor/features/settings/settings.component.ts @@ -0,0 +1,31 @@ +import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ButtonComponent } from '../../../shared/components/button/button.component'; +import { InputComponent } from '../../../shared/components/input/input.component'; + +@Component({ + selector: 'app-vendor-settings', + standalone: true, + imports: [ReactiveFormsModule, ButtonComponent, InputComponent], + templateUrl: './settings.component.html', + styleUrl: './settings.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SettingsComponent { + private readonly fb = inject(FormBuilder); + readonly saving = signal(false); + + readonly form = this.fb.nonNullable.group({ + businessName: ['', [Validators.required]], + contactPerson: ['', [Validators.required]], + phone: ['', [Validators.required]], + address: ['', [Validators.required]], + }); + + onSubmit(): void { + if (this.form.valid) { + this.saving.set(true); + setTimeout(() => this.saving.set(false), 1000); + } + } +} diff --git a/client/src/app/vendor/features/slot-management/slot-management.component.css b/client/src/app/vendor/features/slot-management/slot-management.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/vendor/features/slot-management/slot-management.component.html b/client/src/app/vendor/features/slot-management/slot-management.component.html new file mode 100644 index 000000000..71d9b13f5 --- /dev/null +++ b/client/src/app/vendor/features/slot-management/slot-management.component.html @@ -0,0 +1,78 @@ +
+

Manage Slots

+ + +
+

Add New Slot Template

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + + @if (slotService.loading()) { + + } @else if (slotService.slots().length === 0) { + + } @else { +
+ + + + + + + + + + + + @for (slot of slotService.slots(); track slot.id) { + + + + + + + + } + +
DayStartEndStatusAction
{{ slot.dayOfWeek }}{{ slot.startTime }}{{ slot.endTime }} + + {{ slot.active ? 'Active' : 'Inactive' }} + + + +
+
+ } +
diff --git a/client/src/app/vendor/features/slot-management/slot-management.component.spec.ts b/client/src/app/vendor/features/slot-management/slot-management.component.spec.ts new file mode 100644 index 000000000..d1c738af5 --- /dev/null +++ b/client/src/app/vendor/features/slot-management/slot-management.component.spec.ts @@ -0,0 +1,28 @@ +/* tslint:disable:no-unused-variable */ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; + +import { SlotManagementComponent } from './slot-management.component'; + +describe('SlotManagementComponent', () => { + let component: SlotManagementComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SlotManagementComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SlotManagementComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/vendor/features/slot-management/slot-management.component.ts b/client/src/app/vendor/features/slot-management/slot-management.component.ts new file mode 100644 index 000000000..7a3e4359e --- /dev/null +++ b/client/src/app/vendor/features/slot-management/slot-management.component.ts @@ -0,0 +1,45 @@ +import { Component, inject, OnInit, signal, ChangeDetectionStrategy } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { VendorSlotService } from '../../services/slot.service'; +import { LoaderComponent } from '../../../shared/components/loader/loader.component'; +import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component'; + +const DAYS = ['MONDAY','TUESDAY','WEDNESDAY','THURSDAY','FRIDAY','SATURDAY','SUNDAY']; + +@Component({ + selector: 'app-slot-management', + standalone: true, + imports: [ReactiveFormsModule, LoaderComponent, EmptyStateComponent], + templateUrl: './slot-management.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SlotManagementComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly fb = inject(FormBuilder); + readonly slotService = inject(VendorSlotService); + + readonly venueId = signal(''); + readonly days = DAYS; + + readonly form = this.fb.nonNullable.group({ + dayOfWeek: ['', Validators.required], + startTime: ['', Validators.required], + endTime: ['', Validators.required], + }); + + ngOnInit(): void { + const id = this.route.snapshot.paramMap.get('id') ?? ''; + this.venueId.set(id); + this.slotService.loadSlots(id); + } + + onSubmit(): void { + if (this.form.invalid) return; + this.slotService.createSlot(this.venueId(), this.form.getRawValue(), () => this.form.reset()); + } + + deleteSlot(id: number): void { + this.slotService.deleteSlot(id); + } +} diff --git a/client/src/app/vendor/features/venue-list/venue-list.component.css b/client/src/app/vendor/features/venue-list/venue-list.component.css new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/client/src/app/vendor/features/venue-list/venue-list.component.css @@ -0,0 +1 @@ +:host { display: block; } diff --git a/client/src/app/vendor/features/venue-list/venue-list.component.html b/client/src/app/vendor/features/venue-list/venue-list.component.html new file mode 100644 index 000000000..860249ec3 --- /dev/null +++ b/client/src/app/vendor/features/venue-list/venue-list.component.html @@ -0,0 +1,43 @@ +
+
+

My Venues

+ + + Add Venue + +
+ + @if (loading()) { + + } @else if (venues().length === 0) { + + } @else { +
+ @for (venue of venues(); track venue.id) { +
+
+ @if (venue.imageUrls && venue.imageUrls.length > 0) { + + } +
+
+
+

{{ venue.name }}

+ + {{ venue.status }} + +
+

{{ venue.address }}

+
+ {{ venue.pricePerSlot }}/slot +
+ Edit + + Slots +
+
+
+
+ } +
+ } +
diff --git a/client/src/app/vendor/features/venue-list/venue-list.component.spec.ts b/client/src/app/vendor/features/venue-list/venue-list.component.spec.ts new file mode 100644 index 000000000..a7a12fa2c --- /dev/null +++ b/client/src/app/vendor/features/venue-list/venue-list.component.spec.ts @@ -0,0 +1,13 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { VenueListComponent } from './venue-list.component'; +describe('Vendor VenueListComponent', () => { + let component: VenueListComponent; + let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [VenueListComponent], providers: [provideRouter([]), provideHttpClient()] }).compileComponents(); + fixture = TestBed.createComponent(VenueListComponent); component = fixture.componentInstance; fixture.detectChanges(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); +}); diff --git a/client/src/app/vendor/features/venue-list/venue-list.component.ts b/client/src/app/vendor/features/venue-list/venue-list.component.ts new file mode 100644 index 000000000..891e54835 --- /dev/null +++ b/client/src/app/vendor/features/venue-list/venue-list.component.ts @@ -0,0 +1,39 @@ +import { Component, inject, OnInit, ChangeDetectionStrategy, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { VenueService } from '../../services/venue.service'; +import { LoaderComponent } from '../../../shared/components/loader/loader.component'; +import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component'; + +@Component({ + selector: 'app-vendor-venue-list', + standalone: true, + imports: [RouterLink, LoaderComponent, EmptyStateComponent], + templateUrl: './venue-list.component.html', + styleUrl: './venue-list.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VenueListComponent implements OnInit { + private readonly venueService = inject(VenueService); + + readonly venues = this.venueService.venues; + readonly loading = this.venueService.loading; + readonly deleting = signal(null); + + ngOnInit(): void { + this.venueService.loadVendorVenues(); + } + + deleteVenue(id:string):void{ + if(!confirm('Are you sure you want to delete this venue?')) return; + this.deleting.set(id); + this.venueService.deleteVenue(id).subscribe(({ + next:()=>{ + this.venueService.loadVendorVenues(); + this.deleting.set(null); + }, + error:() => this.deleting.set(null), + })) + } + + +} diff --git a/client/src/app/vendor/guards/vendor-auth.guard.ts b/client/src/app/vendor/guards/vendor-auth.guard.ts new file mode 100644 index 000000000..9ae8e0b07 --- /dev/null +++ b/client/src/app/vendor/guards/vendor-auth.guard.ts @@ -0,0 +1,15 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../../shared/services/auth.service'; +import { UserRole } from '../../shared/enums/user-role.enum'; + +export const vendorAuthGuard: CanActivateFn = () => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isPortalAuthenticated(UserRole.Vendor)) { + return true; + } + router.navigate(['/login']); + return false; +}; diff --git a/client/src/app/vendor/layouts/vendor-layout/vendor-layout.component.css b/client/src/app/vendor/layouts/vendor-layout/vendor-layout.component.css new file mode 100644 index 000000000..ca6b14ddc --- /dev/null +++ b/client/src/app/vendor/layouts/vendor-layout/vendor-layout.component.css @@ -0,0 +1,4 @@ +:host { + display: block; + height: 100vh; +} diff --git a/client/src/app/vendor/layouts/vendor-layout/vendor-layout.component.html b/client/src/app/vendor/layouts/vendor-layout/vendor-layout.component.html new file mode 100644 index 000000000..7a1ad6d56 --- /dev/null +++ b/client/src/app/vendor/layouts/vendor-layout/vendor-layout.component.html @@ -0,0 +1,129 @@ +
+ + + + + +
+ + +
+
+

Vendor Portal

+
+
+ {{ currentUser()?.name }} +
+ {{ currentUser()?.name?.charAt(0) | uppercase }} +
+
+
+ +
+ +
+
+
diff --git a/client/src/app/vendor/layouts/vendor-layout/vendor-layout.component.spec.ts b/client/src/app/vendor/layouts/vendor-layout/vendor-layout.component.spec.ts new file mode 100644 index 000000000..1a0a41eda --- /dev/null +++ b/client/src/app/vendor/layouts/vendor-layout/vendor-layout.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { VendorLayoutComponent } from './vendor-layout.component'; + +describe('VendorLayoutComponent', () => { + let component: VendorLayoutComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VendorLayoutComponent], + providers: [provideRouter([]), provideHttpClient()], + }).compileComponents(); + + fixture = TestBed.createComponent(VendorLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/vendor/layouts/vendor-layout/vendor-layout.component.ts b/client/src/app/vendor/layouts/vendor-layout/vendor-layout.component.ts new file mode 100644 index 000000000..45cf4516b --- /dev/null +++ b/client/src/app/vendor/layouts/vendor-layout/vendor-layout.component.ts @@ -0,0 +1,35 @@ +import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core'; +import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; +import { UpperCasePipe } from '@angular/common'; +import { AuthService } from '../../../shared/services/auth.service'; + +@Component({ + selector: 'app-vendor-layout', + standalone: true, + imports: [RouterOutlet, RouterLink, RouterLinkActive, UpperCasePipe], + templateUrl: './vendor-layout.component.html', + styleUrl: './vendor-layout.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VendorLayoutComponent { + private readonly authService = inject(AuthService); + + readonly currentUser = this.authService.currentUser; + readonly sidebarOpen = signal(true); + + readonly navItems = [ + { label: 'Dashboard', path: '/vendor/dashboard', icon: 'dashboard' }, + { label: 'My Venues', path: '/vendor/venues', icon: 'venues' }, + { label: 'Bookings', path: '/vendor/bookings', icon: 'bookings' }, + { label: 'Analytics', path: '/vendor/analytics', icon: 'analytics' }, + { label: 'Settings', path: '/vendor/settings', icon: 'settings' }, + ]; + + toggleSidebar(): void { + this.sidebarOpen.update(v => !v); + } + + logout(): void { + this.authService.logout(); + } +} diff --git a/client/src/app/vendor/models/index.ts b/client/src/app/vendor/models/index.ts new file mode 100644 index 000000000..d9811af81 --- /dev/null +++ b/client/src/app/vendor/models/index.ts @@ -0,0 +1,3 @@ +export * from '../../shared/models/vendor.model'; +export * from '../../shared/models/venue.model'; +export * from '../../shared/models/booking.model'; diff --git a/client/src/app/vendor/repositories/analytics.repository.ts b/client/src/app/vendor/repositories/analytics.repository.ts new file mode 100644 index 000000000..4f2992e46 --- /dev/null +++ b/client/src/app/vendor/repositories/analytics.repository.ts @@ -0,0 +1,22 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; +import { ApiResponse } from '../../shared/models/api-response.model'; + +export interface AnalyticsData { + revenue: { date: string; amount: number }[]; + bookings: { date: string; count: number }[]; + topVenues: { name: string; bookings: number; revenue: number }[]; +} + +@Injectable({ providedIn: 'root' }) +export class VendorAnalyticsRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + getAnalytics(period: string): Observable> { + return this.http.get>(`${this.apiUrl}${API_ENDPOINTS.VENDORS.ANALYTICS}`, { params: { period } }); + } +} diff --git a/client/src/app/vendor/repositories/booking.repository.ts b/client/src/app/vendor/repositories/booking.repository.ts new file mode 100644 index 000000000..6070448a6 --- /dev/null +++ b/client/src/app/vendor/repositories/booking.repository.ts @@ -0,0 +1,21 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; +import { Booking } from '../../shared/models/booking.model'; +import { ApiResponse, PaginationResponse } from '../../shared/models/api-response.model'; + +@Injectable({ providedIn: 'root' }) +export class VendorBookingRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + getVendorBookings(): Observable> { + return this.http.get>(`${this.apiUrl}${API_ENDPOINTS.BOOKINGS.BY_VENDOR}`); + } + + updateBookingStatus(id: string, status: string): Observable> { + return this.http.patch>(`${this.apiUrl}${API_ENDPOINTS.BOOKINGS.BY_ID(id)}`, { status }); + } +} diff --git a/client/src/app/vendor/repositories/slot.repository.ts b/client/src/app/vendor/repositories/slot.repository.ts new file mode 100644 index 000000000..08a96795c --- /dev/null +++ b/client/src/app/vendor/repositories/slot.repository.ts @@ -0,0 +1,31 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; +import { SlotTemplate, CreateSlotTemplateRequest } from '../../shared/models/slot.model'; + +@Injectable({ providedIn: 'root' }) +export class VendorSlotRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + getSlots(venueId: string): Observable { + return this.http.get( + `${this.apiUrl}${API_ENDPOINTS.VENUES.SLOTS_BY_VENUE(venueId)}` + ); + } + + createSlot(venueId: string, payload: CreateSlotTemplateRequest): Observable { + return this.http.post( + `${this.apiUrl}${API_ENDPOINTS.VENUES.SLOTS_BY_VENUE(venueId)}`, + payload + ); + } + + deleteSlot(templateId: number): Observable { + return this.http.delete( + `${this.apiUrl}${API_ENDPOINTS.VENUES.DELETE_SLOT(templateId)}` + ); + } +} diff --git a/client/src/app/vendor/repositories/vendor.repository.ts b/client/src/app/vendor/repositories/vendor.repository.ts new file mode 100644 index 000000000..169d5b133 --- /dev/null +++ b/client/src/app/vendor/repositories/vendor.repository.ts @@ -0,0 +1,27 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; +import { Vendor, UpdateVendorRequest } from '../../shared/models/vendor.model'; +import { ApiResponse } from '../../shared/models/api-response.model'; + +@Injectable({ providedIn: 'root' }) +export class VendorRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + getProfile(): Observable> { + return this.http.get>(`${this.apiUrl}${API_ENDPOINTS.VENDORS.PROFILE}`); + } + + updateProfile(payload: UpdateVendorRequest): Observable> { + return this.http.put>(`${this.apiUrl}${API_ENDPOINTS.VENDORS.PROFILE}`, payload); + } + + getDashboardStats(): Observable> { + return this.http.get>( + `${this.apiUrl}${API_ENDPOINTS.VENDORS.ANALYTICS}` + ); + } +} diff --git a/client/src/app/vendor/repositories/venue.repository.ts b/client/src/app/vendor/repositories/venue.repository.ts new file mode 100644 index 000000000..412c86ca7 --- /dev/null +++ b/client/src/app/vendor/repositories/venue.repository.ts @@ -0,0 +1,32 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../core/config/environment'; +import { API_ENDPOINTS } from '../../shared/constants/api-endpoints.constant'; +import { Venue, CreateVenueRequest, UpdateVenueRequest } from '../../shared/models/venue.model'; + +@Injectable({ providedIn: 'root' }) +export class VendorVenueRepository { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + getVendorVenues(): Observable { + return this.http.get(`${this.apiUrl}${API_ENDPOINTS.VENUES.VENDOR_BASE}`); + } + + getVenueById(id: string): Observable { + return this.http.get(`${this.apiUrl}${API_ENDPOINTS.VENUES.VENDOR_BY_ID(id)}`); + } + + createVenue(payload: CreateVenueRequest): Observable { + return this.http.post(`${this.apiUrl}${API_ENDPOINTS.VENUES.VENDOR_BASE}`, payload); + } + + updateVenue(id: string, payload: UpdateVenueRequest): Observable { + return this.http.patch(`${this.apiUrl}${API_ENDPOINTS.VENUES.PUBLIC_BY_ID(id)}`, payload); + } + + deleteVenue(id: string): Observable { + return this.http.delete(`${this.apiUrl}${API_ENDPOINTS.VENUES.PUBLIC_BY_ID(id)}`); + } +} diff --git a/client/src/app/vendor/routes/vendor.routes.ts b/client/src/app/vendor/routes/vendor.routes.ts new file mode 100644 index 000000000..afad29c48 --- /dev/null +++ b/client/src/app/vendor/routes/vendor.routes.ts @@ -0,0 +1,25 @@ +import { Routes } from '@angular/router'; +import { ROUTE_PATHS } from '../../shared/constants/route-paths.constant'; +import { vendorAuthGuard } from '../guards/vendor-auth.guard'; + +export const VENDOR_ROUTES: Routes = [ + { path: ROUTE_PATHS.VENDOR.LOGIN, redirectTo: '/login', pathMatch: 'full' }, + { path: ROUTE_PATHS.VENDOR.SIGNUP, redirectTo: '/signup', pathMatch: 'full' }, + { path: ROUTE_PATHS.VENDOR.FORGOT_PASSWORD, redirectTo: '/forgot-password', pathMatch: 'full' }, + { + path: '', + loadComponent: () => import('../layouts/vendor-layout/vendor-layout.component').then(m => m.VendorLayoutComponent), + canActivate: [vendorAuthGuard], + children: [ + { path: ROUTE_PATHS.VENDOR.DASHBOARD, loadComponent: () => import('../features/dashboard/dashboard.component').then(m => m.DashboardComponent) }, + { path: ROUTE_PATHS.VENDOR.VENUES, loadComponent: () => import('../features/venue-list/venue-list.component').then(m => m.VenueListComponent) }, + { path: ROUTE_PATHS.VENDOR.CREATE_VENUE, loadComponent: () => import('../features/create-venue/create-venue.component').then(m => m.CreateVenueComponent) }, + { path: ROUTE_PATHS.VENDOR.EDIT_VENUE, loadComponent: () => import('../features/edit-venue/edit-venue.component').then(m => m.EditVenueComponent) }, + { path: ROUTE_PATHS.VENDOR.BOOKINGS, loadComponent: () => import('../features/bookings/bookings.component').then(m => m.BookingsComponent) }, + { path: ROUTE_PATHS.VENDOR.ANALYTICS, loadComponent: () => import('../features/analytics/analytics.component').then(m => m.AnalyticsComponent) }, + { path: ROUTE_PATHS.VENDOR.SETTINGS, loadComponent: () => import('../features/settings/settings.component').then(m => m.SettingsComponent) }, + { path: ROUTE_PATHS.VENDOR.VENUE_SLOTS, loadComponent: () => import('../features/slot-management/slot-management.component').then(m => m.SlotManagementComponent) }, + { path: '', redirectTo: ROUTE_PATHS.VENDOR.DASHBOARD, pathMatch: 'full' }, + ], + }, +]; diff --git a/client/src/app/vendor/services/analytics.service.ts b/client/src/app/vendor/services/analytics.service.ts new file mode 100644 index 000000000..c8824bc75 --- /dev/null +++ b/client/src/app/vendor/services/analytics.service.ts @@ -0,0 +1,20 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { VendorAnalyticsRepository, AnalyticsData } from '../repositories/analytics.repository'; +import { NotificationService } from '../../shared/services/notification.service'; + +@Injectable({ providedIn: 'root' }) +export class AnalyticsService { + private readonly analyticsRepository = inject(VendorAnalyticsRepository); + private readonly notification = inject(NotificationService); + + readonly data = signal(null); + readonly loading = signal(false); + + loadAnalytics(period: string): void { + this.loading.set(true); + this.analyticsRepository.getAnalytics(period).subscribe({ + next: (response) => { this.data.set(response.data); this.loading.set(false); }, + error: () => { this.notification.error('Failed to load analytics'); this.loading.set(false); }, + }); + } +} diff --git a/client/src/app/vendor/services/booking.service.ts b/client/src/app/vendor/services/booking.service.ts new file mode 100644 index 000000000..b57bb9f29 --- /dev/null +++ b/client/src/app/vendor/services/booking.service.ts @@ -0,0 +1,31 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { VendorBookingRepository } from '../repositories/booking.repository'; +import { Booking } from '../../shared/models/booking.model'; +import { NotificationService } from '../../shared/services/notification.service'; + +@Injectable({ providedIn: 'root' }) +export class BookingService { + private readonly bookingRepository = inject(VendorBookingRepository); + private readonly notification = inject(NotificationService); + + readonly bookings = signal([]); + readonly loading = signal(false); + + loadVendorBookings(): void { + this.loading.set(true); + this.bookingRepository.getVendorBookings().subscribe({ + next: (response) => { this.bookings.set(response.data); this.loading.set(false); }, + error: () => { this.notification.error('Failed to load bookings'); this.loading.set(false); }, + }); + } + + updateBookingStatus(id: number, status: string): void { + this.bookingRepository.updateBookingStatus(String(id), status).subscribe({ + next: (response) => { + this.bookings.update(list => list.map(b => b.id === id ? response.data : b)); + this.notification.success(`Booking ${status}`); + }, + error: () => this.notification.error('Failed to update booking'), + }); + } +} \ No newline at end of file diff --git a/client/src/app/vendor/services/slot.service.ts b/client/src/app/vendor/services/slot.service.ts new file mode 100644 index 000000000..444c9e072 --- /dev/null +++ b/client/src/app/vendor/services/slot.service.ts @@ -0,0 +1,48 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { VendorSlotRepository } from '../repositories/slot.repository'; +import { SlotTemplate, CreateSlotTemplateRequest } from '../../shared/models/slot.model'; +import { NotificationService } from '../../shared/services/notification.service'; + +@Injectable({ providedIn: 'root' }) +export class VendorSlotService { + private readonly repo = inject(VendorSlotRepository); + private readonly notification = inject(NotificationService); + + readonly slots = signal([]); + readonly loading = signal(false); + readonly saving = signal(false); + readonly deletingId = signal(null); + + loadSlots(venueId: string): void { + this.loading.set(true); + this.repo.getSlots(venueId).subscribe({ + next: (slots) => { this.slots.set(slots); this.loading.set(false); }, + error: () => { this.notification.error('Failed to load slots'); this.loading.set(false); }, + }); + } + + createSlot(venueId: string, payload: CreateSlotTemplateRequest, onDone: () => void): void { + this.saving.set(true); + this.repo.createSlot(venueId, payload).subscribe({ + next: (slot) => { + this.slots.update(list => [...list, slot]); + this.notification.success('Slot created'); + this.saving.set(false); + onDone(); + }, + error: () => { this.notification.error('Failed to create slot'); this.saving.set(false); }, + }); + } + + deleteSlot(templateId: number): void { + this.deletingId.set(templateId); + this.repo.deleteSlot(templateId).subscribe({ + next: () => { + this.slots.update(list => list.filter(s => s.id !== templateId)); + this.notification.success('Slot deleted'); + this.deletingId.set(null); + }, + error: () => { this.notification.error('Failed to delete slot'); this.deletingId.set(null); }, + }); + } +} diff --git a/client/src/app/vendor/services/vendor.service.ts b/client/src/app/vendor/services/vendor.service.ts new file mode 100644 index 000000000..b3ff7ae4f --- /dev/null +++ b/client/src/app/vendor/services/vendor.service.ts @@ -0,0 +1,12 @@ +import { Injectable, inject } from '@angular/core'; +import { Observable, map } from 'rxjs'; +import { VendorRepository } from '../repositories/vendor.repository'; + +@Injectable({ providedIn: 'root' }) +export class VendorService { + private readonly vendorRepository = inject(VendorRepository); + + loadDashboardStats(): Observable<{ totalVenues: number; totalBookings: number; totalRevenue: number; pendingBookings: number }> { + return this.vendorRepository.getDashboardStats().pipe(map(r => r.data)); + } +} diff --git a/client/src/app/vendor/services/venue.service.ts b/client/src/app/vendor/services/venue.service.ts new file mode 100644 index 000000000..da9f300d3 --- /dev/null +++ b/client/src/app/vendor/services/venue.service.ts @@ -0,0 +1,39 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { Observable } from 'rxjs'; +import { VendorVenueRepository } from '../repositories/venue.repository'; +import { Venue, CreateVenueRequest, UpdateVenueRequest } from '../../shared/models/venue.model'; +import { NotificationService } from '../../shared/services/notification.service'; + +@Injectable({ providedIn: 'root' }) +export class VenueService { + private readonly venueRepository = inject(VendorVenueRepository); + private readonly notification = inject(NotificationService); + + readonly venues = signal([]); + readonly loading = signal(false); + + loadVendorVenues(): void { + this.loading.set(true); + this.venueRepository.getVendorVenues().subscribe({ + next: (response) => { this.venues.set(response);}, + error: () => { this.notification.error('Failed to load venues');}, + complete:() => { this.loading.set(false);} + }); + } + + loadVenueById(id: string): Observable { + return this.venueRepository.getVenueById(id); + } + + createVenue(payload: CreateVenueRequest): Observable { + return this.venueRepository.createVenue(payload); + } + + updateVenue(id: string, payload: UpdateVenueRequest): Observable { + return this.venueRepository.updateVenue(id, payload); + } + + deleteVenue(id:string):Observable{ + return this.venueRepository.deleteVenue(id); + } +} diff --git a/client/src/index.html b/client/src/index.html new file mode 100644 index 000000000..e575830e7 --- /dev/null +++ b/client/src/index.html @@ -0,0 +1,17 @@ + + + + + BookMyVenue + + + + + + + + + + + + diff --git a/client/src/main.ts b/client/src/main.ts new file mode 100644 index 000000000..5df75f9c8 --- /dev/null +++ b/client/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig) + .catch((err) => console.error(err)); diff --git a/client/src/styles.css b/client/src/styles.css new file mode 100644 index 000000000..3ab4af068 --- /dev/null +++ b/client/src/styles.css @@ -0,0 +1,178 @@ +@import "tailwindcss"; + +@theme { + /* Fonts */ + --font-heading: 'Playfair Display', Georgia, serif; + --font-body: 'DM Sans', system-ui, sans-serif; + + /* Brand — Midnight Navy */ + --color-brand-50: #eef2f9; + --color-brand-100: #d5e0f0; + --color-brand-200: #a8c0e3; + --color-brand-500: #1E3A5F; + --color-brand-600: #142d4f; + --color-brand-700: #0A1F3D; + --color-brand-900: #060f1e; + + /* Accent — Antique Gold */ + --color-accent-300: #f0d98a; + --color-accent-400: #dfc26a; + --color-accent-500: #C9A84C; + --color-accent-600: #A88730; + --color-accent-700: #8A6F25; + + /* Surfaces */ + --color-surface: #ffffff; + --color-surface-soft: #fafaf9; + --color-surface-dark: #0A1628; + --color-surface-dark2: #0F1F3D; + --color-surface-dark3: #1A3050; + + /* Text */ + --color-text-primary: #111827; + --color-text-secondary: #6b7280; + --color-text-muted: #9ca3af; + --color-text-on-dark: #e2e8f0; + --color-text-on-dark2: #94a3b8; + + /* Border */ + --color-border: #e5e7eb; + --color-border-dark: #1A3050; + + /* Semantic */ + --color-success: #10b981; + --color-warning: #f59e0b; + --color-danger: #ef4444; + + /* Animations */ + --animate-fade-up: fade-up 0.4s cubic-bezier(0.16,1,0.3,1) both; + --animate-fade-in: fade-in 0.3s ease-out both; + --animate-scale-in: scale-in 0.25s cubic-bezier(0.16,1,0.3,1) both; + --animate-slide-in: slide-in 0.35s cubic-bezier(0.16,1,0.3,1) both; +} + +@keyframes fade-up { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes scale-in { + from { opacity: 0; transform: scale(0.94); } + to { opacity: 1; transform: scale(1); } +} +@keyframes slide-in { + from { opacity: 0; transform: translateX(-12px); } + to { opacity: 1; transform: translateX(0); } +} + +@layer base { + body { + font-family: var(--font-body); + color: var(--color-text-primary); + background-color: var(--color-surface-soft); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + h1, h2, h3, h4, h5, h6 { + font-family: var(--font-heading); + line-height: 1.2; + letter-spacing: -0.01em; + } + + a { text-decoration: none; } + + * { box-sizing: border-box; } +} + +@layer utilities { + .animate-fade-up { animation: var(--animate-fade-up); } + .animate-fade-in { animation: var(--animate-fade-in); } + .animate-scale-in { animation: var(--animate-scale-in); } + .animate-slide-in { animation: var(--animate-slide-in); } + + /* Stagger delays */ + .stagger > *:nth-child(1) { animation-delay: 0ms; } + .stagger > *:nth-child(2) { animation-delay: 50ms; } + .stagger > *:nth-child(3) { animation-delay: 100ms; } + .stagger > *:nth-child(4) { animation-delay: 150ms; } + .stagger > *:nth-child(5) { animation-delay: 200ms; } + .stagger > *:nth-child(6) { animation-delay: 250ms; } + .stagger > *:nth-child(7) { animation-delay: 300ms; } + .stagger > *:nth-child(8) { animation-delay: 350ms; } + + /* Premium card lift */ + .card-hover { + transition: transform 0.25s cubic-bezier(0.16,1,0.3,1), + box-shadow 0.25s cubic-bezier(0.16,1,0.3,1); + } + .card-hover:hover { + transform: translateY(-6px); + box-shadow: 0 24px 48px -12px rgb(10 22 40 / 0.20); + } + + /* Glass effect */ + .glass { + background: rgb(255 255 255 / 0.08); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgb(255 255 255 / 0.12); + } + + /* Gradient text — navy to gold */ + .text-gradient { + background: linear-gradient(135deg, #C9A84C 0%, #f0d98a 50%, #C9A84C 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + /* Premium navy button */ + .btn-primary { + background: linear-gradient(135deg, #1E3A5F 0%, #0A1628 100%); + color: white; + padding: 0.625rem 1.5rem; + border-radius: 0.75rem; + font-weight: 600; + font-size: 0.875rem; + transition: all 0.2s ease; + box-shadow: 0 4px 14px rgb(10 22 40 / 0.30); + cursor: pointer; + border: none; + font-family: var(--font-body); + letter-spacing: 0.01em; + } + .btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgb(10 22 40 / 0.40); + } + + /* Gold accent button */ + .btn-accent { + background: linear-gradient(135deg, #C9A84C 0%, #A88730 100%); + color: white; + padding: 0.625rem 1.5rem; + border-radius: 0.75rem; + font-weight: 700; + font-size: 0.875rem; + transition: all 0.2s ease; + box-shadow: 0 4px 14px rgb(201 168 76 / 0.35); + cursor: pointer; + border: none; + font-family: var(--font-body); + letter-spacing: 0.01em; + } + .btn-accent:hover { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgb(201 168 76 / 0.45); + } + + @media (prefers-reduced-motion: reduce) { + .animate-fade-up, .animate-fade-in, .animate-scale-in, .animate-slide-in { animation: none; } + .card-hover:hover { transform: none; } + .btn-primary:hover, .btn-accent:hover { transform: none; } + } +} diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json new file mode 100644 index 000000000..e4dd97c65 --- /dev/null +++ b/client/tsconfig.app.json @@ -0,0 +1,16 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "rootDir": "./src", + "types": [] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 000000000..2ab744275 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,33 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/client/tsconfig.spec.json b/client/tsconfig.spec.json new file mode 100644 index 000000000..d38370633 --- /dev/null +++ b/client/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 000000000..698905e5c --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,3 @@ +target/ +.git/ +.idea/ \ No newline at end of file diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 000000000..049c92de3 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,18 @@ +# PostgreSQL +DB_USERNAME=postgres +DB_PASSWORD=type_your_password_here +DB_NAME=bookmyvenue_db + +# JWT +JWT_SECRET=replace_with_your_jwt_secret +JWT_ACCESS_TOKEN_EXPIRATION=900000 +JWT_REFRESH_TOKEN_EXPIRATION=604800000 + + +RAZORPAY_KEY_ID=rzp_test_T3DBrC7nq10MPn +RAZORPAY_KEY_SECRET=pGHlSR1p2mm1g2m02AAjwl2W + + +BREVO_API_KEY=your-api-key +BREVO_SENDER_EMAIL=noreply@yourdomain.com +BREVO_SENDER_NAME=BookMyVenue \ No newline at end of file diff --git a/server/.gitattributes b/server/.gitattributes new file mode 100644 index 000000000..3b41682ac --- /dev/null +++ b/server/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 000000000..ba43720e9 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,109 @@ +HELP.md + +# ========================================== + +# Build Output + +# ========================================== + +target/ +build/ + +# ========================================== + +# Environment Files + +# ========================================== + +.env +.env.* +!.env.example + +# ========================================== + +# Logs + +# ========================================== + +*.log + +# ========================================== + +# IntelliJ IDEA + +# ========================================== + +.idea/ +*.iml +*.ipr +*.iws +out/ + +# ========================================== + +# VS Code + +# ========================================== + +.vscode/ + +# ========================================== + +# Spring Tool Suite (STS) + +# ========================================== + +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +# ========================================== + +# NetBeans + +# ========================================== + +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +# ========================================== + +# OS Files + +# ========================================== + +.DS_Store +Thumbs.db + +# ========================================== + +# Coverage Reports + +# ========================================== + +coverage/ + +# ========================================== + +# Temporary Files + +# ========================================== + +tmp/ +temp/ + +# ========================================== + +# Docker + +# ========================================== + +*.pid + diff --git a/server/.mvn/wrapper/maven-wrapper.properties b/server/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 000000000..216df0589 --- /dev/null +++ b/server/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 000000000..eaa2dee8d --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,17 @@ +FROM maven:3.9.11-eclipse-temurin-21 AS builder + +WORKDIR /app + +COPY . . + +RUN mvn clean package -DskipTests + +FROM eclipse-temurin:21-jre + +WORKDIR /app + +COPY --from=builder /app/target/*.jar app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java","-jar","app.jar"] \ No newline at end of file diff --git a/server/README.md b/server/README.md new file mode 100644 index 000000000..ce505c34b --- /dev/null +++ b/server/README.md @@ -0,0 +1,193 @@ +# BookMyVenue - Backend Server + +Backend service for **BookMyVenue**, built with **Spring Boot**, **Java 21**, and **Maven**. + +The project uses **PostgreSQL** running inside Docker. The backend application can be run either from an IDE (recommended for backend developers) or inside Docker (recommended for frontend developers and deployment). + +--- + +# Tech Stack + +* **Language:** Java 21 +* **Framework:** Spring Boot 3.5.14 +* **Build Tool:** Apache Maven +* **Database:** PostgreSQL +* **Containerization:** Docker & Docker Compose + +--- + +# Prerequisites + +Before running the project, make sure the following are installed. + +## 1. Java 21 + +Check installed version: + +```bash +java -version +``` + +--- + +## 2. Docker & Docker Compose + +Check installation: + +```bash +docker --version +docker compose version +``` + +--- + +# Running the Project + +## Step 1: Configure Environment Variables + +Inside the `server/` directory, create a local `.env` file from the example template: + +```bash +cp ..env.example ..env +``` + +Verify the values: + +```env +DB_USERNAME=postgres +DB_PASSWORD=postgres123 +DB_NAME=bookmyvenue_db + +JWT_SECRET= +JWT_ACCESS_TOKEN_EXPIRATION=900000 +JWT_REFRESH_TOKEN_EXPIRATION=604800000 +``` + +--- + +# Backend Developer Setup (Recommended) + +Backend developers should run PostgreSQL in Docker and run Spring Boot from IntelliJ IDEA. + +## Step 1: Start PostgreSQL + +```bash +docker compose up -d postgres +``` + +Verify: + +```bash +docker ps +``` + +You should see: + +```text +bmv-postgres-db +``` + +## Step 2: Run Spring Boot + +Run the application from IntelliJ IDEA or: + +```bash +./mvnw spring-boot:run +``` + +When you see: + +```text +Started ServerApplication +``` + +the backend server is running. + +The application will be available at: + +```text +http://localhost:8080 +``` + +--- + +# Frontend Developer Setup + +Frontend developers do not need Java, Maven, or IntelliJ. + +Start the entire backend stack using Docker: + +```bash +docker compose up --build +``` + +This starts: + +* PostgreSQL +* Spring Boot Backend + +The API will be available at: + +```text +http://localhost:8080 +``` + +--- + +# Stopping Containers + +Stop all services: + +```bash +docker compose down +``` + +Database data will remain safe because Docker volumes are persisted. + +To remove all database data and start fresh: + +```bash +docker compose down -v +``` + +--- + +# Project Structure + +```text +server/ +├── src/ +├── docker-compose.yml +├── Dockerfile +├── .env.example +├── pom.xml +├── mvnw +└── README.md +``` + +--- + +# Deployment + +Build and start all services: + +```bash +docker compose up -d --build +``` + +This will: + +1. Start PostgreSQL +2. Build the Spring Boot Docker image +3. Start the backend container +4. Apply Flyway migrations + +--- + +# Notes + +* Ensure Docker is running before starting the project. +* Use Java 21 for compatibility. +* PostgreSQL runs inside Docker for consistency across environments. +* Backend developers are encouraged to run Spring Boot from IntelliJ for easier debugging and faster development. +* Frontend developers can run the full backend stack using Docker only. diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 000000000..58743d0ed --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,52 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + container_name: bmv-postgres-db + environment: + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_NAME} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + container_name: bmv-redis + restart: unless-stopped + ports: + - "6379:6379" + + + backend: + build: + context: . + dockerfile: Dockerfile + container_name: bmv-backend + depends_on: + - postgres + - redis + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${DB_NAME} + SPRING_DATASOURCE_USERNAME: ${DB_USERNAME} + SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD} + + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 + + JWT_SECRET: ${JWT_SECRET} + JWT_ACCESS_TOKEN_EXPIRATION: ${JWT_ACCESS_TOKEN_EXPIRATION} + JWT_REFRESH_TOKEN_EXPIRATION: ${JWT_REFRESH_TOKEN_EXPIRATION} + RAZORPAY_KEY_ID: ${RAZORPAY_KEY_ID} + RAZORPAY_KEY_SECRET: ${RAZORPAY_KEY_SECRET} + BREVO_API_KEY: ${BREVO_API_KEY} + BREVO_SENDER_EMAIL: ${BREVO_SENDER_EMAIL} + BREVO_SENDER_NAME: ${BREVO_SENDER_NAME} + ports: + - "8080:8080" + +volumes: + postgres_data: \ No newline at end of file diff --git a/server/mvnw b/server/mvnw new file mode 100755 index 000000000..bd8896bf2 --- /dev/null +++ b/server/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/server/mvnw.cmd b/server/mvnw.cmd new file mode 100644 index 000000000..92450f932 --- /dev/null +++ b/server/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/server/openapi.json b/server/openapi.json new file mode 100644 index 000000000..9e6613cbc --- /dev/null +++ b/server/openapi.json @@ -0,0 +1,310 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost:8080", + "description": "Generated server url" + } + ], + "tags": [ + { + "name": "Authentication", + "description": "Authentication and account management APIs" + } + ], + "paths": { + "/api/auth/register": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Register a new account", + "description": "Creates a new USER or VENDOR account and sets authentication cookies.", + "operationId": "register", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Registration successful", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/AuthResponse" + } + } + } + }, + "400": { + "description": "Invalid request or ADMIN registration attempted", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "409": { + "description": "Email or phone already exists", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/api/auth/refresh-token": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Refresh JWT tokens", + "description": "Generates new authentication cookies using a valid refresh token.", + "operationId": "refreshToken", + "parameters": [ + { + "name": "refreshToken", + "in": "cookie", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tokens refreshed successfully", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/AuthResponse" + } + } + } + }, + "401": { + "description": "Missing, invalid or expired refresh token", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/api/auth/logout": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Logout user", + "description": "Clears authentication cookies.", + "operationId": "logout", + "responses": { + "204": { + "description": "Logout successful" + } + } + } + }, + "/api/auth/login": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Authenticate user", + "description": "Authenticates a user and sets authentication cookies.", + "operationId": "login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Login successful", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/AuthResponse" + } + } + } + }, + "401": { + "description": "Invalid credentials", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "RegisterRequest": { + "type": "object", + "description": "User registration request", + "properties": { + "name": { + "type": "string", + "description": "Full name of the user", + "example": "Ajay", + "minLength": 1 + }, + "email": { + "type": "string", + "description": "Unique email address", + "example": "ajay@gmail.com", + "minLength": 1 + }, + "phone": { + "type": "string", + "description": "10 digit phone number", + "example": 9876543210, + "minLength": 1, + "pattern": "^[0-9]{10}$" + }, + "password": { + "type": "string", + "description": "Account password", + "example": "password123", + "minLength": 1 + }, + "role": { + "type": "string", + "description": "Account role", + "enum": [ + "USER", + "VENDOR" + ], + "example": "USER" + } + }, + "required": [ + "email", + "name", + "password", + "phone", + "role" + ] + }, + "ApiErrorResponse": { + "type": "object", + "description": "Standard API error response", + "properties": { + "status": { + "type": "integer", + "format": "int32", + "description": "HTTP status code", + "example": 401 + }, + "error": { + "type": "string", + "description": "Error type", + "example": "Unauthorized" + }, + "message": { + "type": "string", + "description": "Detailed error message", + "example": "Invalid email or password" + }, + "path": { + "type": "string", + "description": "Request path", + "example": "/api/auth/specific path" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Timestamp of the error", + "example": "2026-06-04T01:22:49" + } + } + }, + "AuthResponse": { + "type": "object", + "description": "Authentication response returned after successful login or registration", + "properties": { + "userId": { + "type": "string", + "format": "uuid", + "description": "Unique identifier of the authenticated user", + "example": "82b45eec-c37b-4ef5-b5fb-921ab399cb13" + }, + "name": { + "type": "string", + "description": "Full name of the user", + "example": "Ajay" + }, + "email": { + "type": "string", + "description": "Registered email address", + "example": "ajay@gmail.com" + }, + "role": { + "type": "string", + "description": "Role assigned to the user", + "enum": [ + "USER", + "VENDOR", + "ADMIN" + ], + "example": "USER" + } + } + }, + "LoginRequest": { + "type": "object", + "description": "User login request", + "properties": { + "email": { + "type": "string", + "description": "Registered email address", + "example": "ajay@gmail.com", + "minLength": 1 + }, + "password": { + "type": "string", + "description": "Account password", + "example": "password123", + "minLength": 1 + } + }, + "required": [ + "email", + "password" + ] + } + } + } +} \ No newline at end of file diff --git a/server/pom.xml b/server/pom.xml new file mode 100644 index 000000000..24935a193 --- /dev/null +++ b/server/pom.xml @@ -0,0 +1,243 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.14 + + + com.bookmyvenue + server + 0.0.1-SNAPSHOT + + + + + + + + + + + + + + + + + 21 + 2.3.10 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-validation + + + io.jsonwebtoken + jjwt-api + 0.12.7 + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.9 + + + + me.paulschwarz + spring-dotenv + 4.0.0 + + + + + io.jsonwebtoken + jjwt-impl + 0.12.7 + runtime + + + + io.jsonwebtoken + jjwt-jackson + 0.12.7 + runtime + + + org.postgresql + postgresql + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-test + ${kotlin.version} + test + + + org.flywaydb + flyway-core + compile + + + org.flywaydb + flyway-database-postgresql + + + com.razorpay + razorpay-java + 1.4.8 + + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-mail + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + true + + + compile + compile + + compile + + + + src/main/java + target/generated-sources/annotations + + + + + test-compile + test-compile + + test-compile + + + + src/test/java + target/generated-test-sources/test-annotations + + + + + + 1.8 + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + compile + + compile + + + + + org.projectlombok + lombok + + + + + + default-testCompile + test-compile + + testCompile + + + + + org.projectlombok + lombok + + + + + + compile + compile + + compile + + + + testCompile + test-compile + + testCompile + + + + + + + + diff --git a/server/src/main/java/com/bookmyvenue/server/ServerApplication.java b/server/src/main/java/com/bookmyvenue/server/ServerApplication.java new file mode 100644 index 000000000..212da88f1 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/ServerApplication.java @@ -0,0 +1,16 @@ +package com.bookmyvenue.server; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class ServerApplication { + + + public static void main(String[] args) { + SpringApplication.run(ServerApplication.class, args); + } + +} diff --git a/server/src/main/java/com/bookmyvenue/server/admin/controller/AdminController.java b/server/src/main/java/com/bookmyvenue/server/admin/controller/AdminController.java new file mode 100644 index 000000000..cc5217410 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/admin/controller/AdminController.java @@ -0,0 +1,139 @@ +package com.bookmyvenue.server.admin.controller; + +import com.bookmyvenue.server.admin.dto.response.AdminUserResponse; +import com.bookmyvenue.server.admin.dto.response.AdminVenueResponse; +import com.bookmyvenue.server.admin.dto.response.DashboardResponse; +import com.bookmyvenue.server.admin.service.AdminService; +import com.bookmyvenue.server.venue.dto.response.VenueResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin") +@RequiredArgsConstructor +@Validated +@PreAuthorize("hasRole('ADMIN')") +public class AdminController { + + private final AdminService adminService; + + @Operation(summary = "Get admin dashboard", description = "Returns platform statistics including users, vendors, venues and bookings.") + @ApiResponse(responseCode = "200", description = "Dashboard retrieved successfully") + @GetMapping("/dashboard") + public DashboardResponse getDashboard() { + return adminService.getDashboard(); + } + + + @Operation(summary = "Get pending venues", description = "Returns a paginated list of venues awaiting admin approval.") + @ApiResponse(responseCode = "200", description = "Pending venues retrieved successfully") + @GetMapping("/venues/pending") + public Page getPendingVenues( + @Parameter(description = "Page number", example = "0") + @RequestParam(defaultValue = "0") + @Min(0) int page, + @Parameter(description = "Page size", example = "10") + @RequestParam(defaultValue = "10") + @Min(1) + @Max(100) int size) + { + return adminService.getPendingVenues(page, size); + } + + + + @Operation(summary = "Get all venues", description = "Returns a paginated list of all venues regardless of status.") + @ApiResponse(responseCode = "200", description = "Venues retrieved successfully") + @GetMapping("/venues") + public Page getAllVenues( + @Parameter(description = "Page number", example = "0") + @RequestParam(defaultValue = "0") + @Min(0) int page, + + @Parameter(description = "Page size", example = "10") + @RequestParam(defaultValue = "10") + @Min(1) + @Max(100) int size + ) { + return adminService.getAllVenues(page, size); + } + + + @Operation(summary = "Get venue details", description = "Returns complete venue information for administrative review.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Venue retrieved successfully"), + @ApiResponse(responseCode = "404", description = "Venue not found") + }) + @GetMapping("/venues/{venueId}") + public VenueResponse getVenue( + @PathVariable Long venueId + ) { + return adminService.getVenue(venueId); + } + + + + @Operation(summary = "Approve venue", description = "Approves a pending venue and makes it visible to customers.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Venue approved successfully"), + @ApiResponse(responseCode = "400", description = "Invalid venue status"), + @ApiResponse(responseCode = "404", description = "Venue not found"), + @ApiResponse(responseCode = "403", description = "Access denied") + }) + @PatchMapping("/venues/{venueId}/approve") + public void approveVenue(@PathVariable Long venueId) { + adminService.approveVenue(venueId); + } + + + + @Operation(summary = "Reject venue", description = "Rejects a pending venue.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Venue rejected successfully"), + @ApiResponse(responseCode = "400", description = "Invalid venue status"), + @ApiResponse(responseCode = "404", description = "Venue not found"), + @ApiResponse(responseCode = "403", description = "Access denied") + }) + @PatchMapping("/venues/{venueId}/reject") + public void rejectVenue(@PathVariable Long venueId) { + adminService.rejectVenue(venueId); + } + + + @Operation(summary = "Get all users", description = "Returns a paginated list of all registered users.") + @ApiResponses({@ApiResponse(responseCode = "200",description = "Users retrieved successfully"), + @ApiResponse(responseCode = "403", description = "Access denied") + }) + @GetMapping("/users") + public Page getUsers( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + return adminService.getUsers(page, size); + } + + + @Operation(summary = "Get all vendors", description = "Returns a paginated list of all registered venue vendors.") + @ApiResponses({@ApiResponse(responseCode = "200", description = "Vendors retrieved successfully"), + @ApiResponse(responseCode = "403", description = "Access denied") + }) + @GetMapping("/vendors") + public Page getVendors( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + return adminService.getVendors(page, size); + } + + + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/admin/dto/response/AdminUserResponse.java b/server/src/main/java/com/bookmyvenue/server/admin/dto/response/AdminUserResponse.java new file mode 100644 index 000000000..923b30a42 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/admin/dto/response/AdminUserResponse.java @@ -0,0 +1,18 @@ +package com.bookmyvenue.server.admin.dto.response; + +import com.bookmyvenue.server.user.enums.Role; +import lombok.Builder; +import lombok.Data; + +import java.util.UUID; + +@Data +@Builder +public class AdminUserResponse { + + private UUID id; + private String name; + private String email; + private String phone; + private Role role; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/admin/dto/response/AdminVenueResponse.java b/server/src/main/java/com/bookmyvenue/server/admin/dto/response/AdminVenueResponse.java new file mode 100644 index 000000000..7efa1deb8 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/admin/dto/response/AdminVenueResponse.java @@ -0,0 +1,19 @@ +package com.bookmyvenue.server.admin.dto.response; + +import com.bookmyvenue.server.venue.entity.VenueStatus; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class AdminVenueResponse { + + private Long id; + private String name; + private String district; + private String address; + private Integer capacity; + private String ownerName; + private String ownerEmail; + private VenueStatus status; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/admin/dto/response/DashboardResponse.java b/server/src/main/java/com/bookmyvenue/server/admin/dto/response/DashboardResponse.java new file mode 100644 index 000000000..da35414fe --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/admin/dto/response/DashboardResponse.java @@ -0,0 +1,17 @@ +package com.bookmyvenue.server.admin.dto.response; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class DashboardResponse { + + private long totalUsers; + private long totalVendors; + private long totalVenues; + private long pendingVenues; + private long approvedVenues; + private long rejectedVenues; + private long totalBookings; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/admin/service/AdminService.java b/server/src/main/java/com/bookmyvenue/server/admin/service/AdminService.java new file mode 100644 index 000000000..5f8621dd3 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/admin/service/AdminService.java @@ -0,0 +1,34 @@ +package com.bookmyvenue.server.admin.service; + +import com.bookmyvenue.server.admin.dto.response.AdminUserResponse; +import com.bookmyvenue.server.admin.dto.response.AdminVenueResponse; +import com.bookmyvenue.server.admin.dto.response.DashboardResponse; +import com.bookmyvenue.server.venue.dto.response.VenueResponse; +import org.springframework.data.domain.Page; + +import java.util.List; + +public interface AdminService { + + + DashboardResponse getDashboard(); + + + Page getPendingVenues(int page, int size); + + + void approveVenue(Long venueId); + + + void rejectVenue(Long venueId); + + VenueResponse getVenue(Long venueId); + + + Page getAllVenues(int page, int size); + + + Page getUsers(int page, int size); + + Page getVendors(int page, int size); +} diff --git a/server/src/main/java/com/bookmyvenue/server/admin/service/AdminServiceImpl.java b/server/src/main/java/com/bookmyvenue/server/admin/service/AdminServiceImpl.java new file mode 100644 index 000000000..93f2cfab2 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/admin/service/AdminServiceImpl.java @@ -0,0 +1,169 @@ +package com.bookmyvenue.server.admin.service; + +import com.bookmyvenue.server.admin.dto.response.AdminUserResponse; +import com.bookmyvenue.server.admin.dto.response.AdminVenueResponse; +import com.bookmyvenue.server.admin.dto.response.DashboardResponse; +import com.bookmyvenue.server.booking.repository.BookingRepository; +import com.bookmyvenue.server.common.exception.BusinessException; +import com.bookmyvenue.server.common.exception.ErrorCode; +import com.bookmyvenue.server.user.enums.Role; +import com.bookmyvenue.server.user.repository.UserRepository; +import com.bookmyvenue.server.venue.dto.response.VenueResponse; +import com.bookmyvenue.server.venue.entity.Venue; +import com.bookmyvenue.server.venue.entity.VenueStatus; +import com.bookmyvenue.server.venue.mapper.VenueMapper; +import com.bookmyvenue.server.venue.repository.VenueRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminServiceImpl implements AdminService { + + private final UserRepository userRepository; + private final VenueRepository venueRepository; + private final BookingRepository bookingRepository; + private final VenueMapper venueMapper; + + + @Override + public DashboardResponse getDashboard() { + return DashboardResponse.builder() + .totalUsers(userRepository.countByRole(Role.USER)) + .totalVendors(userRepository.countByRole(Role.VENDOR)) + .totalVenues(venueRepository.count()) + .pendingVenues( + venueRepository.countByStatus( + VenueStatus.PENDING_APPROVAL)) + .approvedVenues( + venueRepository.countByStatus( + VenueStatus.APPROVED)) + .rejectedVenues( + venueRepository.countByStatus( + VenueStatus.REJECTED)) + .totalBookings(bookingRepository.count()) + .build(); + } + + + + + @Override + public Page getPendingVenues(int page, int size) { + + Pageable pageable = PageRequest.of(page, size); + + return venueRepository + .findByStatus( + VenueStatus.PENDING_APPROVAL, + pageable + ) + .map(venue -> AdminVenueResponse.builder() + .id(venue.getId()) + .name(venue.getName()) + .district(venue.getDistrict()) + .address(venue.getAddress()) + .capacity(venue.getCapacity()) + .ownerName(venue.getOwner().getName()) + .ownerEmail(venue.getOwner().getEmail()) + .status(venue.getStatus()) + .build()); + } + + + @Override + @Transactional + public void approveVenue(Long venueId) { + + Venue venue = venueRepository.findById(venueId) + .orElseThrow(() -> + new BusinessException(ErrorCode.VENUE_NOT_FOUND)); + if (venue.getStatus() != VenueStatus.PENDING_APPROVAL) { + throw new BusinessException(ErrorCode.INVALID_VENUE_STATUS); + } + venue.setStatus(VenueStatus.APPROVED); + venueRepository.save(venue); + } + + + @Override + @Transactional + public void rejectVenue(Long venueId) { + + Venue venue = venueRepository.findById(venueId) + .orElseThrow(() -> + new BusinessException(ErrorCode.VENUE_NOT_FOUND)); + if (venue.getStatus() != VenueStatus.PENDING_APPROVAL) { + throw new BusinessException(ErrorCode.INVALID_VENUE_STATUS); + } + venue.setStatus(VenueStatus.REJECTED); + venueRepository.save(venue); + } + + + @Override + public VenueResponse getVenue(Long venueId) { + + Venue venue = venueRepository.findById(venueId) + .orElseThrow(() -> + new BusinessException(ErrorCode.VENUE_NOT_FOUND)); + return venueMapper.toResponse(venue); + } + + + @Override + public Page getAllVenues(int page, int size) { + + Pageable pageable = PageRequest.of(page, size); + return venueRepository + .findAll(pageable) + .map(venue -> AdminVenueResponse.builder() + .id(venue.getId()) + .name(venue.getName()) + .district(venue.getDistrict()) + .address(venue.getAddress()) + .capacity(venue.getCapacity()) + .ownerName(venue.getOwner().getName()) + .ownerEmail(venue.getOwner().getEmail()) + .status(venue.getStatus()) + .build()); + } + + + @Override + public Page getUsers(int page, int size) { + + Pageable pageable = PageRequest.of(page, size); + return userRepository + .findByRole(Role.USER, pageable) + .map(user -> AdminUserResponse.builder() + .id(user.getId()) + .name(user.getName()) + .email(user.getEmail()) + .phone(user.getPhone()) + .role(user.getRole()) + .build()); + } + + @Override + public Page getVendors(int page, int size) { + + Pageable pageable = PageRequest.of(page, size); + return userRepository + .findByRole(Role.VENDOR, pageable) + .map(user -> AdminUserResponse.builder() + .id(user.getId()) + .name(user.getName()) + .email(user.getEmail()) + .phone(user.getPhone()) + .role(user.getRole()) + .build()); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/controller/AuthController.java b/server/src/main/java/com/bookmyvenue/server/auth/controller/AuthController.java new file mode 100644 index 000000000..40dd0173b --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/controller/AuthController.java @@ -0,0 +1,144 @@ +package com.bookmyvenue.server.auth.controller; + +import com.bookmyvenue.server.auth.dto.request.ForgotPasswordRequest; +import com.bookmyvenue.server.auth.dto.request.LoginRequest; +import com.bookmyvenue.server.auth.dto.request.RegisterRequest; +import com.bookmyvenue.server.auth.dto.request.ResetPasswordRequest; +import com.bookmyvenue.server.auth.dto.response.AuthResponse; +import com.bookmyvenue.server.auth.dto.response.AuthResult; +import com.bookmyvenue.server.auth.security.CookieUtils; +import com.bookmyvenue.server.auth.service.AuthService; +import com.bookmyvenue.server.common.exception.BusinessException; +import com.bookmyvenue.server.common.exception.ErrorCode; +import com.bookmyvenue.server.common.response.ApiErrorResponse; +import com.bookmyvenue.server.common.response.MessageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@Tag(name = "Authentication", description = "Authentication and account management APIs") +public class AuthController { + + private final AuthService authService; + private final CookieUtils cookieUtils; + + @Operation(summary = "Register a new account", description = "Creates a new USER or VENDOR account and sends a verification email.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Registration successful"), + @ApiResponse(responseCode = "400", description = "Invalid request or ADMIN registration attempted", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "Email or phone already exists", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { + + MessageResponse response = authService.register(request); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(response); + } + + + + @Operation(summary = "Authenticate user", description = "Authenticates a user and sets authentication cookies.") + @ApiResponses({@ApiResponse(responseCode = "200", description = "Login successful"), + @ApiResponse(responseCode = "401", description = "Invalid credentials or email not verified", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + + AuthResult result = authService.login(request); + ResponseCookie accessCookie = cookieUtils.accessTokenCookie(result.accessToken()); + ResponseCookie refreshCookie = cookieUtils.refreshTokenCookie(result.refreshToken()); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, accessCookie.toString()) + .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) + .body(result.response()); + } + + + + @Operation(summary = "Refresh JWT tokens", description = "Generates new authentication cookies using a valid refresh token.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Tokens refreshed successfully"), + @ApiResponse(responseCode = "401", description = "Missing, invalid or expired refresh token", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) + @PostMapping("/refresh-token") + public ResponseEntity refreshToken(@CookieValue(value=CookieUtils.REFRESH_TOKEN,required = false) String refreshToken) { + + if (refreshToken == null) { + throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN); + } + AuthResult result = authService.refreshToken(refreshToken); + ResponseCookie accessCookie = cookieUtils.accessTokenCookie(result.accessToken()); + ResponseCookie refreshCookie = cookieUtils.refreshTokenCookie(result.refreshToken()); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, accessCookie.toString()) + .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) + .body(result.response()); + } + + + + + @Operation(summary = "Logout user", description = "Clears authentication cookies.") + @ApiResponse(responseCode = "204", description = "Logout successful") + @PostMapping("/logout") + public ResponseEntity logout() { + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, cookieUtils.clearAccessTokenCookie().toString()) + .header(HttpHeaders.SET_COOKIE, cookieUtils.clearRefreshTokenCookie().toString()) + .build(); + } + + @Operation(summary = "Forgot password", description = "Sends a password reset OTP to the registered email address.") + @ApiResponses({@ApiResponse(responseCode = "200", description = "Password reset OTP sent successfully"), + @ApiResponse(responseCode = "404", description = "User not found", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class)))}) + @PostMapping("/forgot-password") + public ResponseEntity forgotPassword(@Valid @RequestBody ForgotPasswordRequest request) { + + MessageResponse response = authService.forgotPassword(request.getEmail()); + return ResponseEntity.ok(response); + } + + @Operation(summary = "Reset password", description = "Resets the user's password using a valid OTP.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Password reset successfully"), + @ApiResponse(responseCode = "400", description = "Invalid or expired OTP", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "User not found", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) + @PostMapping("/reset-password") + public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest request) { + + MessageResponse response = authService.resetPassword( + request.getEmail(), + request.getOtp(), + request.getNewPassword() + ); + + return ResponseEntity.ok(response); + } + + + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/dto/request/ForgotPasswordRequest.java b/server/src/main/java/com/bookmyvenue/server/auth/dto/request/ForgotPasswordRequest.java new file mode 100644 index 000000000..d57e668e4 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/dto/request/ForgotPasswordRequest.java @@ -0,0 +1,18 @@ +package com.bookmyvenue.server.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "Forgot password request") +public class ForgotPasswordRequest { + + @Schema(description = "Registered email address", example = "ajay@gmail.com") + @NotBlank(message = "Email is required") + @Email(message = "Invalid email format") + private String email; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/dto/request/LoginRequest.java b/server/src/main/java/com/bookmyvenue/server/auth/dto/request/LoginRequest.java new file mode 100644 index 000000000..16a1df4f0 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/dto/request/LoginRequest.java @@ -0,0 +1,30 @@ +package com.bookmyvenue.server.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "User login request") +public class LoginRequest { + + @Schema( + description = "Registered email address", + example = "ajay@gmail.com" + ) + @Email(message = "Invalid email format") + @NotBlank(message = "Email is required") + private String email; + + @Schema( + description = "Account password", + example = "password123" + ) + @NotBlank(message = "Password is required") + private String password; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/dto/request/RefreshTokenRequest.java b/server/src/main/java/com/bookmyvenue/server/auth/dto/request/RefreshTokenRequest.java new file mode 100644 index 000000000..dab88bad5 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/dto/request/RefreshTokenRequest.java @@ -0,0 +1,21 @@ +package com.bookmyvenue.server.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "Refresh token request") +public class RefreshTokenRequest { + + @Schema( + description = "JWT refresh token", + example = "eyJhbGciOiJIUzM4NCJ9..." + ) + @NotBlank(message = "Refresh token is required") + private String refreshToken; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/dto/request/RegisterRequest.java b/server/src/main/java/com/bookmyvenue/server/auth/dto/request/RegisterRequest.java new file mode 100644 index 000000000..810118b7e --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/dto/request/RegisterRequest.java @@ -0,0 +1,42 @@ +package com.bookmyvenue.server.auth.dto.request; + +import com.bookmyvenue.server.user.enums.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "User registration request") +public class RegisterRequest { + + @Schema(description = "Full name of the user", example = "Ajay") + @NotBlank(message = "Name is required") + private String name; + + @Schema(description = "Unique email address", example = "ajay@gmail.com") + @Email(message = "Invalid email format") + @NotBlank(message = "Email is required") + private String email; + + @Schema(description = "10 digit phone number", example = "9876543210") + @Pattern(regexp = "^[0-9]{10}$", message = "Phone number must be exactly 10 digits") + @NotBlank(message = "Phone number is required") + private String phone; + + @Schema(description = "Account password", example = "Password@123") + @NotBlank(message = "Password is required") + @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,100}$", + message = "Password must contain at least 8 characters, one uppercase letter, one lowercase letter, one number and one special character") + private String password; + + @Schema(description = "Account role", example = "USER", allowableValues = {"USER", "VENDOR"}) + @NotNull(message = "Role is required") + private Role role; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/dto/request/ResetPasswordRequest.java b/server/src/main/java/com/bookmyvenue/server/auth/dto/request/ResetPasswordRequest.java new file mode 100644 index 000000000..bb1e54f4d --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/dto/request/ResetPasswordRequest.java @@ -0,0 +1,31 @@ +package com.bookmyvenue.server.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "Reset password request") +public class ResetPasswordRequest { + + @Schema(description = "Registered email address", example = "ajay@gmail.com") + @NotBlank(message = "Email is required") + @Email(message = "Invalid email format") + private String email; + + @Schema(description = "OTP sent to the registered email", example = "123456") + @NotBlank(message = "OTP is required") + private String otp; + + @Schema(description = "New account password", example = "Password@123") + @NotBlank(message = "Password is required") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,100}$", + message = "Password must contain at least 8 characters, one uppercase letter, one lowercase letter, one number and one special character" + ) + private String newPassword; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/dto/response/AuthResponse.java b/server/src/main/java/com/bookmyvenue/server/auth/dto/response/AuthResponse.java new file mode 100644 index 000000000..b80d4b026 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/dto/response/AuthResponse.java @@ -0,0 +1,29 @@ +package com.bookmyvenue.server.auth.dto.response; + +import com.bookmyvenue.server.user.enums.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.util.UUID; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "Authentication response returned after successful login or registration") +public class AuthResponse { + + @Schema(description = "Unique identifier of the authenticated user", example = "82b45eec-c37b-4ef5-b5fb-921ab399cb13") + private UUID userId; + + @Schema(description = "Full name of the user", example = "Ajay") + private String name; + + @Schema(description = "Registered email address", example = "ajay@gmail.com") + private String email; + + @Schema(description = "Role assigned to the user", example = "USER") + private Role role; + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/dto/response/AuthResult.java b/server/src/main/java/com/bookmyvenue/server/auth/dto/response/AuthResult.java new file mode 100644 index 000000000..597f897b3 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/dto/response/AuthResult.java @@ -0,0 +1,11 @@ +package com.bookmyvenue.server.auth.dto.response; + +/** + * Authentication result containing user details and generated tokens. + */ +public record AuthResult( + AuthResponse response, + String accessToken, + String refreshToken +) { +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/security/CookieUtils.java b/server/src/main/java/com/bookmyvenue/server/auth/security/CookieUtils.java new file mode 100644 index 000000000..f9363206c --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/security/CookieUtils.java @@ -0,0 +1,89 @@ +package com.bookmyvenue.server.auth.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +/** + * Utility methods for authentication cookies. + */ +@Component +@RequiredArgsConstructor +public class CookieUtils { + + /** + * Access token cookie name. + */ + public static final String ACCESS_TOKEN = "accessToken"; + + /** + * Refresh token cookie name. + */ + public static final String REFRESH_TOKEN = "refreshToken"; + + /** + * Cookie path used throughout the application. + */ + private static final String COOKIE_PATH = "/"; + + /** + * SameSite policy for authentication cookies. + */ + private static final String SAME_SITE_POLICY = "Lax"; + + + + private final JwtProperties jwtProperties; + + /** + * Creates an access token cookie. + */ + public ResponseCookie accessTokenCookie(String token) { + return ResponseCookie.from(ACCESS_TOKEN, token) + .httpOnly(true) + .secure(false) // Use true in production. + .path(COOKIE_PATH) + .sameSite(SAME_SITE_POLICY) + .maxAge(jwtProperties.getAccessTokenExpiration() / 1000) + .build(); + } + + /** + * Creates a refresh token cookie. + */ + public ResponseCookie refreshTokenCookie(String token) { + return ResponseCookie.from(REFRESH_TOKEN, token) + .httpOnly(true) + .secure(false) // Use true in production. + .path(COOKIE_PATH) + .sameSite(SAME_SITE_POLICY) + .maxAge(jwtProperties.getRefreshTokenExpiration() / 1000) + .build(); + } + + /** + * Creates an expired access token cookie. + */ + public ResponseCookie clearAccessTokenCookie() { + return ResponseCookie.from(ACCESS_TOKEN, "") + .httpOnly(true) + .secure(false) // Use true in production. + .path(COOKIE_PATH) + .sameSite(SAME_SITE_POLICY) + .maxAge(0) + .build(); + } + + /** + * Creates an expired refresh token cookie. + */ + public ResponseCookie clearRefreshTokenCookie() { + return ResponseCookie.from(REFRESH_TOKEN, "") + .httpOnly(true) + .secure(false) // Use true in production. + .path(COOKIE_PATH) + .sameSite(SAME_SITE_POLICY) + .maxAge(0) + .build(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/security/CustomUserDetailsService.java b/server/src/main/java/com/bookmyvenue/server/auth/security/CustomUserDetailsService.java new file mode 100644 index 000000000..db8f2d421 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/security/CustomUserDetailsService.java @@ -0,0 +1,35 @@ +package com.bookmyvenue.server.auth.security; + +import com.bookmyvenue.server.user.entity.User; +import com.bookmyvenue.server.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) + throws UsernameNotFoundException { + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> + new UsernameNotFoundException( + "User not found with email: " + email + ) + ); + + return new CustomUserPrincipal( + user.getId(), + user.getEmail(), + user.getPassword(), + user.getRole() + ); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/security/CustomUserPrincipal.java b/server/src/main/java/com/bookmyvenue/server/auth/security/CustomUserPrincipal.java new file mode 100644 index 000000000..fe168ed26 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/security/CustomUserPrincipal.java @@ -0,0 +1,45 @@ +package com.bookmyvenue.server.auth.security; + +import com.bookmyvenue.server.user.enums.Role; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +/** + * Custom authenticated user object stored in Spring Security's SecurityContext. + * Holds the user's identity and role information after successful authentication. + * Inorder to avoid the repeated db check. + */ +@Getter +@RequiredArgsConstructor +public class CustomUserPrincipal implements UserDetails { + + private final UUID id; + private final String email; + private final String password; + private final Role role; + + // Returns the user's role as a Spring Security authority + @Override + public Collection getAuthorities() { + return List.of( + new SimpleGrantedAuthority("ROLE_" + role.name()) + ); + } + + @Override + public String getUsername() { + return email; + } + + @Override + public String getPassword() { + return password; + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/security/JwtAuthenticationFilter.java b/server/src/main/java/com/bookmyvenue/server/auth/security/JwtAuthenticationFilter.java new file mode 100644 index 000000000..fae915227 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/security/JwtAuthenticationFilter.java @@ -0,0 +1,80 @@ +package com.bookmyvenue.server.auth.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Authenticates incoming requests using the JWT access token stored in cookies. + */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final CustomUserDetailsService userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + + // Extract access token from cookie + String token = extractAccessToken(request); + // No token -> continue as anonymous request + if (token == null) { + filterChain.doFilter(request, response); + return; + } + // Extract email from JWT + String email = jwtService.extractEmail(token); + // If email exists and user is not already authenticated + if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = userDetailsService.loadUserByUsername(email); + + // Validate JWT + if (jwtService.isTokenValid(token, userDetails.getUsername())) { + // Create authenticated user + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + // Store authentication in SecurityContext + SecurityContextHolder.getContext() + .setAuthentication(authentication); + } + } + + } catch (Exception ex) { + // Invalid token -> request remains unauthenticated + SecurityContextHolder.clearContext(); + } + filterChain.doFilter(request, response); + } + + /** + * Extracts access token from cookies. + */ + private String extractAccessToken(HttpServletRequest request) { + + if (request.getCookies() == null) { + return null; + } + + for (Cookie cookie : request.getCookies()) { + + if (CookieUtils.ACCESS_TOKEN.equals(cookie.getName())) { + return cookie.getValue(); + } + } + + return null; + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/security/JwtProperties.java b/server/src/main/java/com/bookmyvenue/server/auth/security/JwtProperties.java new file mode 100644 index 000000000..a9726cea8 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/security/JwtProperties.java @@ -0,0 +1,34 @@ +package com.bookmyvenue.server.auth.security; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Stores JWT-related configuration loaded from application properties. + * + * Values are sourced from environment variables to keep secrets + * and token settings outside the codebase. + */ +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.jwt") +public class JwtProperties { + + /** + * Secret key used to sign and verify JWT tokens. + */ + private String secret; + + /** + * Access token validity duration in milliseconds. + */ + private long accessTokenExpiration; + + /** + * Refresh token validity duration in milliseconds. + */ + private long refreshTokenExpiration; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/security/JwtService.java b/server/src/main/java/com/bookmyvenue/server/auth/security/JwtService.java new file mode 100644 index 000000000..feb84530c --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/security/JwtService.java @@ -0,0 +1,121 @@ +package com.bookmyvenue.server.auth.security; + +import com.bookmyvenue.server.user.enums.Role; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.function.Function; + +/** + * Handles JWT token generation, validation, and claim extraction. + */ +@Service +@RequiredArgsConstructor +public class JwtService { + + private final JwtProperties jwtProperties; + + /** + * Generates an access token for the given user. + */ + public String generateAccessToken(String email, Role role) { + return generateToken(email, role, jwtProperties.getAccessTokenExpiration()); + } + + /** + * Generates a refresh token for the given user. + */ + public String generateRefreshToken(String email, Role role) { + return generateToken(email, role, jwtProperties.getRefreshTokenExpiration()); + } + + /** + * Common token generation logic. + */ + private String generateToken(String email, Role role, long expiration) { + return Jwts.builder() + .subject(email) + .claim("role", role.name()) + .issuedAt(new Date()) + .expiration( + new Date( + System.currentTimeMillis() + expiration + ) + ) + .signWith(getSigningKey()) + .compact(); + } + + /** + * Extracts the email (subject) from a JWT token. + */ + public String extractEmail(String token) { + return extractClaim(token, Claims::getSubject); + } + + /** + * Extracts the role from a JWT token. + */ + public String extractRole(String token) { + return extractClaim( + token, + claims -> claims.get("role", String.class) + ); + } + + /** + * Extracts a specific claim from a token. + */ + public T extractClaim( + String token, + Function claimsResolver + ) { + Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + /** + * Validates whether a token belongs to the expected user + * and is not expired. + */ + public boolean isTokenValid(String token, String email) { + return email.equals(extractEmail(token)) + && !isTokenExpired(token); + } + + /** + * Checks whether a token has expired. + */ + private boolean isTokenExpired(String token) { + return extractClaim(token, Claims::getExpiration) + .before(new Date()); + } + + /** + * Extracts all claims from the token. + */ + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + /** + * Creates the signing key from the configured JWT secret. + */ + private SecretKey getSigningKey() { + byte[] keyBytes = Decoders.BASE64.decode( + jwtProperties.getSecret() + ); + + return Keys.hmacShaKeyFor(keyBytes); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/security/SecurityConfig.java b/server/src/main/java/com/bookmyvenue/server/auth/security/SecurityConfig.java new file mode 100644 index 000000000..bfac175eb --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/security/SecurityConfig.java @@ -0,0 +1,94 @@ +package com.bookmyvenue.server.auth.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + /** + * Password encoder used for hashing and verifying passwords. + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * Main Spring Security configuration. + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + http + // Disable CSRF because authentication is JWT-based + .csrf(csrf -> csrf.disable()) + // Enable configured CORS settings + .cors(Customizer.withDefaults()) + // No HTTP session should be created + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // Endpoint authorization rules + .authorizeHttpRequests(auth -> auth + // Public endpoints + .requestMatchers( + "/api/v1/auth/**", + "/swagger-ui/**", + "/v3/api-docs/**", + "/api/v1/venues/**", + "/api/v1/categories/**" + ).permitAll() + // Everything else requires authentication + .anyRequest().authenticated() + ) + // Disable default Spring Security login mechanisms + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + // Register JWT filter before Spring's authentication filter + .addFilterBefore( + jwtAuthenticationFilter, + UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + /** + * CORS configuration for frontend applications. + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("http://localhost:4200")); + config.setAllowedMethods(List.of( + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + // Required for sending cookies + config.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/service/AuthService.java b/server/src/main/java/com/bookmyvenue/server/auth/service/AuthService.java new file mode 100644 index 000000000..f75c57684 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/service/AuthService.java @@ -0,0 +1,31 @@ +package com.bookmyvenue.server.auth.service; + +import com.bookmyvenue.server.auth.dto.request.LoginRequest; +import com.bookmyvenue.server.auth.dto.request.RegisterRequest; +import com.bookmyvenue.server.auth.dto.response.AuthResult; +import com.bookmyvenue.server.common.response.MessageResponse; + + +public interface AuthService { + + /** + * Registers a new USER or VENDOR account. + */ + MessageResponse register(RegisterRequest request); + + /** + * Authenticates a user and returns JWT tokens. + */ + AuthResult login(LoginRequest request); + + + /** + * Generates a new access token using a valid refresh token. + */ + AuthResult refreshToken(String refreshToken); + + + MessageResponse forgotPassword(String email); + + MessageResponse resetPassword(String email,String otp, String newPassword); +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/service/AuthServiceImpl.java b/server/src/main/java/com/bookmyvenue/server/auth/service/AuthServiceImpl.java new file mode 100644 index 000000000..8742e696b --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/service/AuthServiceImpl.java @@ -0,0 +1,201 @@ +package com.bookmyvenue.server.auth.service; + +import com.bookmyvenue.server.auth.dto.request.LoginRequest; +import com.bookmyvenue.server.auth.dto.request.RegisterRequest; +import com.bookmyvenue.server.auth.dto.response.AuthResponse; +import com.bookmyvenue.server.auth.dto.response.AuthResult; +import com.bookmyvenue.server.auth.security.JwtService; +import com.bookmyvenue.server.common.exception.*; +import com.bookmyvenue.server.common.response.MessageResponse; +import com.bookmyvenue.server.user.entity.User; +import com.bookmyvenue.server.user.enums.Role; +import com.bookmyvenue.server.user.repository.UserRepository; +import com.bookmyvenue.server.verification.mail.service.MailService; +import com.bookmyvenue.server.verification.service.OtpRedisService; +import com.bookmyvenue.server.verification.service.VerificationService; +import com.bookmyvenue.server.verification.util.OtpGenerator; +import com.bookmyvenue.server.verification.util.RedisKeys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthServiceImpl implements AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final VerificationService verificationService; + private final OtpGenerator otpGenerator; + private final OtpRedisService otpRedisService; + private final MailService mailService; + + @Override + public MessageResponse register(RegisterRequest request) { + + log.info("Attempting registration for email: {}", request.getEmail()); + + if (userRepository.existsByEmail(request.getEmail())) { + log.warn("Registration failed. Email already exists: {}", request.getEmail()); + throw new BusinessException(ErrorCode.USER_ALREADY_EXISTS); + } + + if (userRepository.existsByPhone(request.getPhone())) { + log.warn("Registration failed. Phone already exists: {}", request.getPhone()); + throw new BusinessException(ErrorCode.PHONE_ALREADY_EXISTS); + } + + if (request.getRole() == Role.ADMIN) { + log.warn("Attempted ADMIN registration for email: {}", request.getEmail()); + throw new BusinessException( + ErrorCode.ADMIN_REGISTRATION_NOT_ALLOWED + ); + } + User user = User.builder() + .name(request.getName()) + .email(request.getEmail()) + .phone(request.getPhone()) + .password(passwordEncoder.encode(request.getPassword())) + .role(request.getRole()) + .emailVerified(false) + .phoneVerified(false) + .build(); + + User savedUser = userRepository.save(user); + + verificationService.sendEmailVerificationOtp(savedUser.getEmail()); + + log.info("Verification email sent to {}", savedUser.getEmail()); + return MessageResponse.builder() + .message("Registration successful. Please verify your email.") + .build(); + } + + + + @Override + public AuthResult login(LoginRequest request) { + log.info("Login attempt for email: {}", request.getEmail()); + User user = userRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> { + log.warn("Login failed. User not found: {}", request.getEmail()); + return new BusinessException(ErrorCode.INVALID_CREDENTIALS); + }); + + if (!user.isEmailVerified()) { + log.warn("Login failed. Email not verified: {}", request.getEmail()); + throw new BusinessException(ErrorCode.EMAIL_NOT_VERIFIED); + } + + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + log.warn("Login failed. Invalid password for email: {}", request.getEmail()); + throw new BusinessException(ErrorCode.INVALID_CREDENTIALS); + } + + String accessToken = jwtService.generateAccessToken(user.getEmail(), user.getRole()); + String refreshToken = jwtService.generateRefreshToken(user.getEmail(), user.getRole()); + + log.info("User logged in successfully: {}", user.getEmail()); + + AuthResponse response = AuthResponse.builder() + .userId(user.getId()) + .name(user.getName()) + .email(user.getEmail()) + .role(user.getRole()) + .build(); + + return new AuthResult( + response, + accessToken, + refreshToken + ); + } + + + @Override + public AuthResult refreshToken(String refreshToken) { + + String email = jwtService.extractEmail(refreshToken); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> + new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN) + ); + + if (!jwtService.isTokenValid(refreshToken, user.getEmail() + )) { + throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN); + } + String accessToken = jwtService.generateAccessToken(user.getEmail(), user.getRole()); + String newRefreshToken = jwtService.generateRefreshToken(user.getEmail(), user.getRole()); + + AuthResponse response = AuthResponse.builder() + .userId(user.getId()) + .name(user.getName()) + .email(user.getEmail()) + .role(user.getRole()) + .build(); + return new AuthResult( + response, + accessToken, + newRefreshToken + ); + } + + @Override + public MessageResponse forgotPassword(String email) { + + log.info("Forgot password requested for {}", email); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> + new BusinessException(ErrorCode.USER_NOT_FOUND)); + + String otp = otpGenerator.generateOtp(); + String key = RedisKeys.passwordReset(email); + otpRedisService.saveOtp(key, otp); + mailService.sendPasswordResetOtp(email, otp); + + log.info("Password reset OTP sent to {}", email); + return MessageResponse.builder() + .message("Password reset OTP sent successfully.") + .build(); + } + + @Override + public MessageResponse resetPassword(String email, String otp, String newPassword) + { + + log.info("Password reset requested for {}", email); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> + new BusinessException(ErrorCode.USER_NOT_FOUND)); + + + String key = RedisKeys.passwordReset(email); + String savedOtp = otpRedisService.getOtp(key); + + if (savedOtp == null) { + throw new BusinessException(ErrorCode.OTP_EXPIRED); + } + + if (!savedOtp.equals(otp)) { + throw new BusinessException(ErrorCode.INVALID_OTP); + } + + user.setPassword(passwordEncoder.encode(newPassword)); + userRepository.save(user); + otpRedisService.deleteOtp(key); + log.info("Password reset successfully for {}", email); + return MessageResponse.builder() + .message("Password reset successful.") + .build(); + } + + + + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/auth/service/AuthenticatedUserService.java b/server/src/main/java/com/bookmyvenue/server/auth/service/AuthenticatedUserService.java new file mode 100644 index 000000000..0809e839e --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/auth/service/AuthenticatedUserService.java @@ -0,0 +1,28 @@ +package com.bookmyvenue.server.auth.service; + +import com.bookmyvenue.server.common.exception.BusinessException; +import com.bookmyvenue.server.common.exception.ErrorCode; +import com.bookmyvenue.server.user.entity.User; +import com.bookmyvenue.server.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthenticatedUserService { + + private final UserRepository userRepository; + + public User getCurrentUser() { + + String email = SecurityContextHolder + .getContext() + .getAuthentication() + .getName(); + + return userRepository.findByEmail(email) + .orElseThrow(() -> + new BusinessException(ErrorCode.USER_NOT_FOUND)); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/booking/controller/AvailabilityController.java b/server/src/main/java/com/bookmyvenue/server/booking/controller/AvailabilityController.java new file mode 100644 index 000000000..e71a7cf68 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/booking/controller/AvailabilityController.java @@ -0,0 +1,36 @@ +package com.bookmyvenue.server.booking.controller; + +import com.bookmyvenue.server.booking.dto.response.AvailabilityResponse; +import com.bookmyvenue.server.booking.service.AvailabilityService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@Tag(name = "Availability") +@RequestMapping("/api/v1/venues") +@RequiredArgsConstructor +public class AvailabilityController { + + private final AvailabilityService availabilityService; + @GetMapping("/{venueId}/availability") + @Operation(summary = "Get Venue Availability") + public ResponseEntity> + getAvailability( + @PathVariable Long venueId, + @RequestParam LocalDate date + ) { + + return ResponseEntity.ok( + availabilityService.getAvailability( + venueId, + date + ) + ); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/booking/controller/BookingController.java b/server/src/main/java/com/bookmyvenue/server/booking/controller/BookingController.java new file mode 100644 index 000000000..86561b6cb --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/booking/controller/BookingController.java @@ -0,0 +1,59 @@ +package com.bookmyvenue.server.booking.controller; + +import com.bookmyvenue.server.booking.dto.request.BookingRequest; +import com.bookmyvenue.server.booking.dto.response.BookingResponse; +import com.bookmyvenue.server.booking.service.BookingService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@Tag(name = "Booking Management") +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class BookingController { + + private final BookingService bookingService; + + @PostMapping( + "/venues/{venueId}/slots/{slotTemplateId}/bookings" + ) + @Operation(summary = "Create Booking") + @ResponseStatus(HttpStatus.CREATED) + public BookingResponse createBooking( + @PathVariable Long venueId, + @PathVariable Long slotTemplateId, + @RequestBody BookingRequest request + ) { + + return bookingService.createBooking( + venueId, + slotTemplateId, + request + ); + } + + @GetMapping("/bookings/my-bookings") + @Operation(summary = "Get My Bookings") + @ResponseStatus(HttpStatus.OK) + public List getMyBookings() { + + return bookingService.getMyBookings(); + } + + @PostMapping("/bookings/{bookingId}/cancel") + @Operation(summary = "Cancel Booking") + @ResponseStatus(HttpStatus.OK) + public void cancelBooking( + @PathVariable Long bookingId + ) { + + bookingService.cancelBooking( + bookingId + ); + } +} diff --git a/server/src/main/java/com/bookmyvenue/server/booking/dto/request/BookingRequest.java b/server/src/main/java/com/bookmyvenue/server/booking/dto/request/BookingRequest.java new file mode 100644 index 000000000..c214e834c --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/booking/dto/request/BookingRequest.java @@ -0,0 +1,9 @@ +package com.bookmyvenue.server.booking.dto.request; + +import java.time.LocalDate; + +public record BookingRequest( + LocalDate bookingDate +) { + +} diff --git a/server/src/main/java/com/bookmyvenue/server/booking/dto/response/AvailabilityResponse.java b/server/src/main/java/com/bookmyvenue/server/booking/dto/response/AvailabilityResponse.java new file mode 100644 index 000000000..81c6c1de8 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/booking/dto/response/AvailabilityResponse.java @@ -0,0 +1,10 @@ +package com.bookmyvenue.server.booking.dto.response; + +import java.time.LocalTime; + +public record AvailabilityResponse( + Long slotTemplateId, + LocalTime startTime, + LocalTime endTime +) { +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/booking/dto/response/BookingResponse.java b/server/src/main/java/com/bookmyvenue/server/booking/dto/response/BookingResponse.java new file mode 100644 index 000000000..21a126f90 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/booking/dto/response/BookingResponse.java @@ -0,0 +1,18 @@ +package com.bookmyvenue.server.booking.dto.response; + +import com.bookmyvenue.server.booking.enums.BookingStatus; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +public record BookingResponse( + Long id, + Long venueId, + Long slotTemplateId, + LocalDate bookingDate, + BookingStatus status, + BigDecimal totalAmount, + LocalDateTime expiresAt +) { +} diff --git a/server/src/main/java/com/bookmyvenue/server/booking/entity/Booking.java b/server/src/main/java/com/bookmyvenue/server/booking/entity/Booking.java new file mode 100644 index 000000000..166202cd7 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/booking/entity/Booking.java @@ -0,0 +1,47 @@ +package com.bookmyvenue.server.booking.entity; + +import com.bookmyvenue.server.booking.enums.BookingStatus; +import com.bookmyvenue.server.slot.entity.SlotTemplate; +import com.bookmyvenue.server.user.entity.User; +import com.bookmyvenue.server.venue.entity.Venue; +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "booking") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Booking { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private LocalDate bookingDate; + + @Enumerated(EnumType.STRING) + private BookingStatus status; + + private BigDecimal totalAmount; + + private LocalDateTime expiresAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "venue_id") + private Venue venue; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "slot_template_id") + private SlotTemplate slotTemplate; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/booking/enums/BookingStatus.java b/server/src/main/java/com/bookmyvenue/server/booking/enums/BookingStatus.java new file mode 100644 index 000000000..16f3efd1c --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/booking/enums/BookingStatus.java @@ -0,0 +1,9 @@ +package com.bookmyvenue.server.booking.enums; + +public enum BookingStatus { + + PENDING, // Reserved, waiting for advance payment + CONFIRMED, // Advance payment successful + CANCELLED, // Cancelled by user/admin + EXPIRED // Reservation timeout +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/booking/repository/BookingRepository.java b/server/src/main/java/com/bookmyvenue/server/booking/repository/BookingRepository.java new file mode 100644 index 000000000..e94387d2b --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/booking/repository/BookingRepository.java @@ -0,0 +1,36 @@ +package com.bookmyvenue.server.booking.repository; + +import com.bookmyvenue.server.booking.entity.Booking; +import com.bookmyvenue.server.booking.enums.BookingStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Repository +public interface BookingRepository + extends JpaRepository { + + boolean existsBySlotTemplateIdAndBookingDateAndStatusIn( + Long slotTemplateId, + LocalDate bookingDate, + List statuses + ); + + List findByUserId(UUID userId); + + List findByVenueIdAndBookingDateAndStatusIn( + Long venueId, + LocalDate bookingDate, + List statuses + ); + + List findByStatusAndExpiresAtBefore( + BookingStatus status, + LocalDateTime time + ); + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/booking/scheduler/BookingExpiryScheduler.java b/server/src/main/java/com/bookmyvenue/server/booking/scheduler/BookingExpiryScheduler.java new file mode 100644 index 000000000..13ff93637 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/booking/scheduler/BookingExpiryScheduler.java @@ -0,0 +1,49 @@ +package com.bookmyvenue.server.booking.scheduler; + +import com.bookmyvenue.server.booking.entity.Booking; +import com.bookmyvenue.server.booking.enums.BookingStatus; +import com.bookmyvenue.server.booking.repository.BookingRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class BookingExpiryScheduler { + + private final BookingRepository bookingRepository; + + @Scheduled(fixedRate = 60000) + @Transactional + public void expireBookings() { + + List expiredBookings = + bookingRepository + .findByStatusAndExpiresAtBefore( + BookingStatus.PENDING, + LocalDateTime.now() + ); + + expiredBookings.forEach( + booking -> + booking.setStatus( + BookingStatus.EXPIRED + ) + ); + + bookingRepository.saveAll(expiredBookings); + + if (!expiredBookings.isEmpty()) { + log.info( + "Expired {} bookings", + expiredBookings.size() + ); + } + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/booking/service/AvailabilityService.java b/server/src/main/java/com/bookmyvenue/server/booking/service/AvailabilityService.java new file mode 100644 index 000000000..ccbf9fd86 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/booking/service/AvailabilityService.java @@ -0,0 +1,14 @@ +package com.bookmyvenue.server.booking.service; + +import com.bookmyvenue.server.booking.dto.response.AvailabilityResponse; + +import java.time.LocalDate; +import java.util.List; + +public interface AvailabilityService { + + List getAvailability( + Long venueId, + LocalDate date + ); +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/booking/service/AvailabilityServiceImpl.java b/server/src/main/java/com/bookmyvenue/server/booking/service/AvailabilityServiceImpl.java new file mode 100644 index 000000000..e877b14dd --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/booking/service/AvailabilityServiceImpl.java @@ -0,0 +1,102 @@ +package com.bookmyvenue.server.booking.service; + +import com.bookmyvenue.server.booking.dto.response.AvailabilityResponse; +import com.bookmyvenue.server.booking.entity.Booking; +import com.bookmyvenue.server.booking.enums.BookingStatus; +import com.bookmyvenue.server.booking.repository.BookingRepository; +import com.bookmyvenue.server.common.exception.BusinessException; +import com.bookmyvenue.server.common.exception.ErrorCode; +import com.bookmyvenue.server.slot.entity.SlotTemplate; +import com.bookmyvenue.server.slot.repository.SlotTemplateRepository; +import com.bookmyvenue.server.venue.entity.Venue; +import com.bookmyvenue.server.venue.repository.VenueRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AvailabilityServiceImpl + implements AvailabilityService { + + private final VenueRepository venueRepository; + private final SlotTemplateRepository slotTemplateRepository; + private final BookingRepository bookingRepository; + + @Override + public List getAvailability( + Long venueId, + LocalDate date + ) { + log.info( + "Fetching availability for venueId={} date={}", + venueId, + date + ); + Venue venue = venueRepository.findById(venueId) + .orElseThrow(() -> + new BusinessException( + ErrorCode.VENUE_NOT_FOUND + )); + + DayOfWeek dayOfWeek = date.getDayOfWeek(); + + // Get all configured slots for the selected day + List templates = + slotTemplateRepository + .findByVenueIdAndDayOfWeek( + venue.getId(), + dayOfWeek + ); + + // Active bookings block slot availability + List activeBookings = + bookingRepository + .findByVenueIdAndBookingDateAndStatusIn( + venueId, + date, + List.of( + BookingStatus.PENDING, + BookingStatus.CONFIRMED + ) + ); + + // Collect booked slot template ids for filtering + Set bookedTemplateIds = + activeBookings.stream() + .map(booking -> + booking.getSlotTemplate().getId() + ) + .collect(Collectors.toSet()); + + log.info( + "Found {} configured slots and {} active bookings for venueId={} date={}", + templates.size(), + activeBookings.size(), + venueId, + date + ); + + return templates.stream() + .filter(template -> + !bookedTemplateIds.contains( + template.getId() + ) + ) + .map(template -> + new AvailabilityResponse( + template.getId(), + template.getStartTime(), + template.getEndTime() + ) + ) + .toList(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/booking/service/BookingService.java b/server/src/main/java/com/bookmyvenue/server/booking/service/BookingService.java new file mode 100644 index 000000000..ec6e90ef2 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/booking/service/BookingService.java @@ -0,0 +1,19 @@ +package com.bookmyvenue.server.booking.service; + +import com.bookmyvenue.server.booking.dto.request.BookingRequest; +import com.bookmyvenue.server.booking.dto.response.BookingResponse; + +import java.util.List; + +public interface BookingService { + + BookingResponse createBooking( + Long venueId, + Long slotTemplateId, + BookingRequest request + ); + + List getMyBookings(); + + void cancelBooking(Long bookingId); +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/booking/service/BookingServiceImpl.java b/server/src/main/java/com/bookmyvenue/server/booking/service/BookingServiceImpl.java new file mode 100644 index 000000000..c0960d4e9 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/booking/service/BookingServiceImpl.java @@ -0,0 +1,205 @@ +package com.bookmyvenue.server.booking.service; + +import com.bookmyvenue.server.auth.service.AuthenticatedUserService; +import com.bookmyvenue.server.booking.dto.request.BookingRequest; +import com.bookmyvenue.server.booking.dto.response.BookingResponse; +import com.bookmyvenue.server.booking.entity.Booking; +import com.bookmyvenue.server.booking.enums.BookingStatus; +import com.bookmyvenue.server.booking.repository.BookingRepository; +import com.bookmyvenue.server.common.exception.BusinessException; +import com.bookmyvenue.server.common.exception.ErrorCode; +import com.bookmyvenue.server.payment.enums.PaymentType; +import com.bookmyvenue.server.payment.service.PaymentService; +import com.bookmyvenue.server.slot.entity.SlotTemplate; +import com.bookmyvenue.server.slot.repository.SlotTemplateRepository; +import com.bookmyvenue.server.user.entity.User; +import com.bookmyvenue.server.venue.entity.Venue; +import com.bookmyvenue.server.venue.repository.VenueRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class BookingServiceImpl implements BookingService { + + private final BookingRepository bookingRepository; + private final VenueRepository venueRepository; + private final SlotTemplateRepository slotTemplateRepository; + private final AuthenticatedUserService authenticatedUserService; + + @Override + public BookingResponse createBooking( + Long venueId, + Long slotTemplateId, + BookingRequest request + ) { + + log.info( + "Creating booking. venueId={}, slotTemplateId={}, date={}", + venueId, + slotTemplateId, + request.bookingDate() + ); + + User currentUser = + authenticatedUserService.getCurrentUser(); + + Venue venue = venueRepository.findById(venueId) + .orElseThrow(() -> + new BusinessException( + ErrorCode.VENUE_NOT_FOUND + )); + + BigDecimal totalAmount = venue.getPricePerSlot(); + + BigDecimal advanceAmount = + totalAmount + .multiply( + venue.getAdvancePercentage() + ) + .divide(BigDecimal.valueOf(100)); + + SlotTemplate slotTemplate = + slotTemplateRepository.findById(slotTemplateId) + .orElseThrow(() -> + new BusinessException( + ErrorCode.SLOT_TEMPLATE_NOT_FOUND + )); + if (!slotTemplate.getVenue().getId().equals(venueId)) { + throw new BusinessException( + ErrorCode.ACCESS_DENIED + ); + } + if (!slotTemplate.isActive()) { + throw new BusinessException( + ErrorCode.SLOT_TEMPLATE_NOT_FOUND + ); + } + + DayOfWeek bookingDay = + request.bookingDate().getDayOfWeek(); + + if (!slotTemplate.getDayOfWeek().equals(bookingDay)) { + + throw new BusinessException( + ErrorCode.INVALID_BOOKING_DATE + ); + } + + + boolean alreadyBooked = + bookingRepository + .existsBySlotTemplateIdAndBookingDateAndStatusIn( + slotTemplateId, + request.bookingDate(), + List.of( + BookingStatus.PENDING, + BookingStatus.CONFIRMED + ) + ); + + if (alreadyBooked) { + + log.warn( + "Slot already booked. slotTemplateId={}, date={}", + slotTemplateId, + request.bookingDate() + ); + + throw new BusinessException( + ErrorCode.SLOT_ALREADY_BOOKED + ); + } + + Booking booking = Booking.builder() + .user(currentUser) + .venue(venue) + .slotTemplate(slotTemplate) + .bookingDate(request.bookingDate()) + .status(BookingStatus.PENDING) + .totalAmount(totalAmount) + .expiresAt( + LocalDateTime.now().plusMinutes(10) + ) + .build(); + + bookingRepository.save(booking); + + log.info( + "Booking created successfully. bookingId={}", + booking.getId() + ); + + + return mapToResponse(booking); + } + + @Override + public List getMyBookings() { + + User currentUser = + authenticatedUserService.getCurrentUser(); + + return bookingRepository + .findByUserId(currentUser.getId()) + .stream() + .map(this::mapToResponse) + .toList(); + } + + @Override + public void cancelBooking(Long bookingId) { + + Booking booking = bookingRepository.findById(bookingId) + .orElseThrow(() -> + new BusinessException( + ErrorCode.BOOKING_NOT_FOUND + )); + + User currentUser = + authenticatedUserService.getCurrentUser(); + + if (!booking.getUser().getId() + .equals(currentUser.getId())) { + + throw new BusinessException( + ErrorCode.ACCESS_DENIED + ); + } + + booking.setStatus( + BookingStatus.CANCELLED + ); + + bookingRepository.save(booking); + + log.info( + "Booking cancelled. bookingId={}", + bookingId + ); + } + + private BookingResponse mapToResponse( + Booking booking + ) { + + return new BookingResponse( + booking.getId(), + booking.getVenue().getId(), + booking.getSlotTemplate().getId(), + booking.getBookingDate(), + booking.getStatus(), + booking.getTotalAmount(), + booking.getExpiresAt() + ); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/common/exception/BusinessException.java b/server/src/main/java/com/bookmyvenue/server/common/exception/BusinessException.java new file mode 100644 index 000000000..5e512a637 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/common/exception/BusinessException.java @@ -0,0 +1,41 @@ +package com.bookmyvenue.server.common.exception; + + + +import lombok.Getter; + +/** + * Base exception for business and domain rule violations. + * + * Used when a request cannot be processed because it violates + * application-specific rules or constraints. + * + * Examples: + * - User already exists + * - Invalid credentials + * - Venue not found + * - Booking slot unavailable + * - Access denied + * + * Each exception is associated with an {@link ErrorCode}, + * which determines the HTTP status, error code, + * and default error message returned to API clients. + */ +@Getter +public class BusinessException extends RuntimeException { + + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public BusinessException( + ErrorCode errorCode, + String message + ) { + super(message); + this.errorCode = errorCode; + } +} diff --git a/server/src/main/java/com/bookmyvenue/server/common/exception/ErrorCode.java b/server/src/main/java/com/bookmyvenue/server/common/exception/ErrorCode.java new file mode 100644 index 000000000..7574b478b --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/common/exception/ErrorCode.java @@ -0,0 +1,162 @@ +package com.bookmyvenue.server.common.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * Defines all application-specific error codes. + * + * Each error code contains: + * - HTTP status to return + * - Unique error code for clients + * - Default error message + */ +@Getter +public enum ErrorCode { + + USER_ALREADY_EXISTS( + HttpStatus.CONFLICT, + "USER_ALREADY_EXISTS", + "User already exists" + ), + PHONE_ALREADY_EXISTS( + HttpStatus.CONFLICT, + "PHONE_ALREADY_EXISTS", + "phone number already registered" + ), + ADMIN_REGISTRATION_NOT_ALLOWED( + HttpStatus.BAD_REQUEST, + "ADMIN_REGISTRATION_NOT_ALLOWED", + "admin registration not allowed" + ), + + INVALID_CREDENTIALS( + HttpStatus.UNAUTHORIZED, + "INVALID_CREDENTIALS", + "Invalid credentials" + ), + + VENUE_NOT_FOUND( + HttpStatus.NOT_FOUND, + "VENUE_NOT_FOUND", + "Venue not found" + ), + + VENUE_CATEGORY_NOT_FOUND( + HttpStatus.NOT_FOUND, + "VENUE_CATEGORY_NOT_FOUND", + "Venue category not found" + ), + + BOOKING_NOT_FOUND( + HttpStatus.NOT_FOUND, + "BOOKING_NOT_FOUND", + "Booking not found" + ), + + ACCESS_DENIED( + HttpStatus.FORBIDDEN, + "ACCESS_DENIED", + "Access denied" + ), + + BAD_REQUEST( + HttpStatus.BAD_REQUEST, + "BAD_REQUEST", + "Invalid request" + ), + USER_NOT_FOUND( + HttpStatus.NOT_FOUND, + "USER NOT FOUND", + "user not found" + ), + + INVALID_REFRESH_TOKEN( + HttpStatus.UNAUTHORIZED, + "INVALID_REFRESH_TOKEN", + "Invalid or expired token" + ), + INVALID_TIME_RANGE(HttpStatus.BAD_REQUEST, + " INVALID_TIME_RANGE", + "End time must be after start time"), + + SLOT_TEMPLATE_NOT_FOUND(HttpStatus.NOT_FOUND, + " SLOT_TEMPLATE_NOT_FOUND", + "Slot template not found"), + + OVERLAPPING_SLOT_TEMPLATE(HttpStatus.BAD_REQUEST, + "OVERLAPPING_SLOT_TEMPLATE", + "Slot template overlaps with existing schedule"), + SLOT_ALREADY_BOOKED( + HttpStatus.CONFLICT, + "SLOT_ALREADY_BOOKED", + "Selected slot is already booked" + ), + INVALID_BOOKING_DATE( + HttpStatus.BAD_REQUEST, + "INVALID_BOOKING_DATE", + "Selected date does not match the slot template schedule" + ), + INVALID_BOOKING_STATUS( + HttpStatus.BAD_REQUEST, + "INVALID_BOOKING_STATUS", + "Booking is not eligible for payment" + ), + PAYMENT_ALREADY_EXISTS( + HttpStatus.CONFLICT, + "PAYMENT_ALREADY_EXISTS", + "Payment already exists for this booking" + ), + PAYMENT_NOT_FOUND( + HttpStatus.NOT_FOUND, + "PAYMENT_NOT_FOUND", + "Payment not found" + ), + INVALID_PAYMENT_SIGNATURE( + HttpStatus.BAD_REQUEST, + "INVALID_PAYMENT_SIGNATURE", + "Invalid payment signature" + ), + INVALID_OTP( + HttpStatus.BAD_REQUEST, + "INVALID_OTP", + "Invalid OTP." + ), + + OTP_EXPIRED( + HttpStatus.BAD_REQUEST, + "OTP_EXPIRED", + "OTP has expired. Please request a new one." + ), + + EMAIL_ALREADY_VERIFIED( + HttpStatus.BAD_REQUEST, + "EMAIL_ALREADY_VERIFIED", + "Email is already verified." + ), + EMAIL_NOT_VERIFIED( + HttpStatus.FORBIDDEN, + "EMAIL_NOT_VERIFIED", + "Email is not verified." + ), + INVALID_VENUE_STATUS( + HttpStatus.BAD_REQUEST, + "INVALID_VENUE_STATUS", + "Invalid venue status" + ); + + + private final HttpStatus status; + private final String code; + private final String message; + + ErrorCode( + HttpStatus status, + String code, + String message + ) { + this.status = status; + this.code = code; + this.message = message; + } +} diff --git a/server/src/main/java/com/bookmyvenue/server/common/exception/GlobalExceptionHandler.java b/server/src/main/java/com/bookmyvenue/server/common/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..9cf5a083f --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,137 @@ +package com.bookmyvenue.server.common.exception; + +import com.bookmyvenue.server.common.response.ApiErrorResponse; +import io.jsonwebtoken.JwtException; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; + +/** + * Centralized exception handler for the entire application. + * + * Converts application exceptions into consistent API error responses, + * ensuring clients receive meaningful HTTP status codes and messages. + */ +@Hidden +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * Handles business and domain rule violations. + * + * Examples: + * - User already exists + * - Invalid credentials + * - Venue not found + * - Booking slot unavailable + * - Access denied + * + * The HTTP status and error details are determined + * by the associated {@link ErrorCode}. + */ + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException( + BusinessException ex, + HttpServletRequest request + ) { + + ErrorCode errorCode = ex.getErrorCode(); + + log.warn( + "Business exception. code={}, message={}, path={}", + errorCode.getCode(), + ex.getMessage(), + request.getRequestURI() + ); + + ApiErrorResponse response = ApiErrorResponse.builder() + .status(errorCode.getStatus().value()) + .code(errorCode.getCode()) + .error(errorCode.getStatus().getReasonPhrase()) + .message(ex.getMessage()) + .path(request.getRequestURI()) + .timestamp(LocalDateTime.now()) + .build(); + + return ResponseEntity + .status(errorCode.getStatus()) + .body(response); + } + + + /** + * Handles JWT-related authentication failures. + * + * Examples: + * - Expired token + * - Invalid token signature + * - Malformed token + * - Tampered token + * + * Returns HTTP 401 (Unauthorized). + */ + @ExceptionHandler(JwtException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public ApiErrorResponse handleJwtException( + JwtException ex, + HttpServletRequest request + ) { + return buildErrorResponse( + HttpStatus.UNAUTHORIZED, + "Invalid or expired token", + request + ); + } + + + /** + * Creates a standardized error response object used across the API. + */ + private ApiErrorResponse buildErrorResponse( + HttpStatus status, + String message, + HttpServletRequest request + ) { + return ApiErrorResponse.builder() + .status(status.value()) + .error(status.getReasonPhrase()) + .message(message) + .path(request.getRequestURI()) + .timestamp(LocalDateTime.now()) + .build(); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException( + MethodArgumentNotValidException ex, + HttpServletRequest request + ) { + String message = ex.getBindingResult() + .getFieldErrors() + .stream() + .findFirst() + .map(FieldError::getDefaultMessage) + .orElse("Validation failed"); + + ApiErrorResponse response = ApiErrorResponse.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .code(ErrorCode.BAD_REQUEST.getCode()) + .error(HttpStatus.BAD_REQUEST.getReasonPhrase()) + .message(message) + .path(request.getRequestURI()) + .timestamp(LocalDateTime.now()) + .build(); + + return ResponseEntity.badRequest().body(response); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/common/response/ApiErrorResponse.java b/server/src/main/java/com/bookmyvenue/server/common/response/ApiErrorResponse.java new file mode 100644 index 000000000..3b0df2d25 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/common/response/ApiErrorResponse.java @@ -0,0 +1,36 @@ +package com.bookmyvenue.server.common.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * Standard API error response. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "Standard API error response") +public class ApiErrorResponse { + + @Schema(description = "HTTP status code", example = "401") + private int status; + + @Schema(description = "Application error code", example = "RESOURCE_NOT_FOUND") + private String code; + + @Schema(description = "Error type", example = "Unauthorized") + private String error; + + @Schema(description = "Detailed error message", example = "Invalid credentials") + private String message; + + @Schema(description = "Request path that triggered the error", example = "/api/resource") + private String path; + + @Schema(description = "Timestamp of the error", example = "2026-06-04T01:22:49") + private LocalDateTime timestamp; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/common/response/MessageResponse.java b/server/src/main/java/com/bookmyvenue/server/common/response/MessageResponse.java new file mode 100644 index 000000000..2fdc5ebb4 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/common/response/MessageResponse.java @@ -0,0 +1,14 @@ +package com.bookmyvenue.server.common.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class MessageResponse { + + private String message; + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/payment/config/RazorpayConfig.java b/server/src/main/java/com/bookmyvenue/server/payment/config/RazorpayConfig.java new file mode 100644 index 000000000..dfba69e38 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/payment/config/RazorpayConfig.java @@ -0,0 +1,22 @@ +package com.bookmyvenue.server.payment.config; + +import com.razorpay.RazorpayClient; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class RazorpayConfig { + + private final RazorpayProperties razorpayProperties; + + @Bean + public RazorpayClient razorpayClient() throws Exception { + + return new RazorpayClient( + razorpayProperties.getKeyId(), + razorpayProperties.getKeySecret() + ); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/payment/config/RazorpayProperties.java b/server/src/main/java/com/bookmyvenue/server/payment/config/RazorpayProperties.java new file mode 100644 index 000000000..1ac6612af --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/payment/config/RazorpayProperties.java @@ -0,0 +1,16 @@ +package com.bookmyvenue.server.payment.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.razorpay") +public class RazorpayProperties { + + private String keyId; + private String keySecret; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/payment/controller/PaymentController.java b/server/src/main/java/com/bookmyvenue/server/payment/controller/PaymentController.java new file mode 100644 index 000000000..b311ed803 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/payment/controller/PaymentController.java @@ -0,0 +1,39 @@ +package com.bookmyvenue.server.payment.controller; + +import com.bookmyvenue.server.payment.dto.request.VerifyPaymentRequest; +import com.bookmyvenue.server.payment.dto.response.PaymentResponse; +import com.bookmyvenue.server.payment.service.PaymentService; +import com.razorpay.RazorpayException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/payments") +@Tag(name = "Payment Management") +@PreAuthorize("hasRole('USER')") +public class PaymentController { + + private final PaymentService paymentService; + + @PostMapping("/{bookingId}") + @Operation(summary = "Create Payment Order") + public ResponseEntity createPayment(@PathVariable Long bookingId) throws RazorpayException { + + return ResponseEntity.ok( + paymentService.createPayment(bookingId)); + } + + + @PostMapping("/verify") + @Operation(summary = "Verify Payment") + public ResponseEntity verifyPayment(@RequestBody VerifyPaymentRequest request) throws RazorpayException { + + paymentService.verifyPayment(request); + return ResponseEntity.ok().build(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/payment/dto/request/VerifyPaymentRequest.java b/server/src/main/java/com/bookmyvenue/server/payment/dto/request/VerifyPaymentRequest.java new file mode 100644 index 000000000..a0212d993 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/payment/dto/request/VerifyPaymentRequest.java @@ -0,0 +1,10 @@ + +package com.bookmyvenue.server.payment.dto.request; + +public record VerifyPaymentRequest( + + String razorpayOrderId, + String razorpayPaymentId, + String razorpaySignature +) { +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/payment/dto/response/PaymentResponse.java b/server/src/main/java/com/bookmyvenue/server/payment/dto/response/PaymentResponse.java new file mode 100644 index 000000000..16085bad8 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/payment/dto/response/PaymentResponse.java @@ -0,0 +1,14 @@ +package com.bookmyvenue.server.payment.dto.response; + +import com.bookmyvenue.server.payment.enums.PaymentStatus; + +import java.math.BigDecimal; + +public record PaymentResponse( + Long paymentId, + Long bookingId, + String razorpayOrderId, + BigDecimal amount, + PaymentStatus status +) { +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/payment/entity/Payment.java b/server/src/main/java/com/bookmyvenue/server/payment/entity/Payment.java new file mode 100644 index 000000000..568935877 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/payment/entity/Payment.java @@ -0,0 +1,51 @@ +package com.bookmyvenue.server.payment.entity; + +import com.bookmyvenue.server.booking.entity.Booking; +import com.bookmyvenue.server.payment.enums.PaymentStatus; +import com.bookmyvenue.server.payment.enums.PaymentType; +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "payment") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Payment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn( + name = "booking_id", + nullable = false, + unique = true + ) + private Booking booking; + + @Column(nullable = false) + private BigDecimal amount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PaymentStatus status; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PaymentType paymentType; + + private String razorpayOrderId; + + private String razorpayPaymentId; + + private String razorpaySignature; + + private LocalDateTime paidAt; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/payment/enums/PaymentStatus.java b/server/src/main/java/com/bookmyvenue/server/payment/enums/PaymentStatus.java new file mode 100644 index 000000000..6b6f4aba7 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/payment/enums/PaymentStatus.java @@ -0,0 +1,7 @@ +package com.bookmyvenue.server.payment.enums; + +public enum PaymentStatus { + PENDING, + SUCCESS, + FAILED +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/payment/enums/PaymentType.java b/server/src/main/java/com/bookmyvenue/server/payment/enums/PaymentType.java new file mode 100644 index 000000000..64149b792 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/payment/enums/PaymentType.java @@ -0,0 +1,6 @@ +package com.bookmyvenue.server.payment.enums; + +public enum PaymentType { + ADVANCE, + BALANCE +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/payment/repository/PaymentRepository.java b/server/src/main/java/com/bookmyvenue/server/payment/repository/PaymentRepository.java new file mode 100644 index 000000000..081d700b6 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/payment/repository/PaymentRepository.java @@ -0,0 +1,15 @@ +package com.bookmyvenue.server.payment.repository; + +import com.bookmyvenue.server.payment.entity.Payment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PaymentRepository extends JpaRepository { + + Optional findByRazorpayOrderId(String razorpayOrderId); + + Optional findByBookingId(Long bookingId); + + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/payment/service/PaymentService.java b/server/src/main/java/com/bookmyvenue/server/payment/service/PaymentService.java new file mode 100644 index 000000000..0749c2f61 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/payment/service/PaymentService.java @@ -0,0 +1,12 @@ +package com.bookmyvenue.server.payment.service; + +import com.bookmyvenue.server.payment.dto.request.VerifyPaymentRequest; +import com.bookmyvenue.server.payment.dto.response.PaymentResponse; +import com.razorpay.RazorpayException; + +public interface PaymentService { + + PaymentResponse createPayment(Long bookingId) throws RazorpayException; + + void verifyPayment(VerifyPaymentRequest request) throws RazorpayException; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/payment/service/PaymentServiceImpl.java b/server/src/main/java/com/bookmyvenue/server/payment/service/PaymentServiceImpl.java new file mode 100644 index 000000000..d861d2875 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/payment/service/PaymentServiceImpl.java @@ -0,0 +1,118 @@ +package com.bookmyvenue.server.payment.service; + +import com.bookmyvenue.server.booking.entity.Booking; +import com.bookmyvenue.server.booking.enums.BookingStatus; +import com.bookmyvenue.server.booking.repository.BookingRepository; +import com.bookmyvenue.server.common.exception.BusinessException; +import com.bookmyvenue.server.common.exception.ErrorCode; +import com.bookmyvenue.server.payment.config.RazorpayProperties; +import com.bookmyvenue.server.payment.dto.request.VerifyPaymentRequest; +import com.bookmyvenue.server.payment.dto.response.PaymentResponse; +import com.bookmyvenue.server.payment.enums.PaymentStatus; +import com.bookmyvenue.server.payment.repository.PaymentRepository; +import com.razorpay.Order; +import com.bookmyvenue.server.payment.entity.Payment; +import com.razorpay.RazorpayClient; +import com.razorpay.RazorpayException; +import com.razorpay.Utils; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.json.JSONObject; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class PaymentServiceImpl implements PaymentService { + + private final BookingRepository bookingRepository; + private final PaymentRepository paymentRepository; + private final RazorpayClient razorpayClient; + private final RazorpayProperties razorpayProperties; + + @Override + public PaymentResponse createPayment(Long bookingId) throws RazorpayException { + + Booking booking = bookingRepository.findById(bookingId) + .orElseThrow(() -> + new BusinessException(ErrorCode.BOOKING_NOT_FOUND)); + + if (booking.getStatus() != BookingStatus.PENDING) { + throw new BusinessException(ErrorCode.INVALID_BOOKING_STATUS); + } + + paymentRepository.findByBookingId(bookingId) + .ifPresent(payment -> { + throw new BusinessException(ErrorCode.PAYMENT_ALREADY_EXISTS); + }); + JSONObject options = new JSONObject(); + options.put("amount", booking.getTotalAmount() + .multiply(BigDecimal.valueOf(100)) + .intValue()); + + options.put("currency", "INR"); + options.put("receipt", "booking_" + booking.getId()); + Order razorpayOrder = razorpayClient.orders.create(options); + + Payment payment = Payment.builder() + .booking(booking) + .amount(booking.getTotalAmount()) + .status(PaymentStatus.PENDING) + .razorpayOrderId( + razorpayOrder.get("id") + .toString() + ) + .build(); + + payment = paymentRepository.save(payment); + return new PaymentResponse( + payment.getId(), + booking.getId(), + payment.getRazorpayOrderId(), + payment.getAmount(), + payment.getStatus() + ); + } + @Override + public void verifyPayment(VerifyPaymentRequest request) throws RazorpayException { + + Payment payment = paymentRepository + .findByRazorpayOrderId(request.razorpayOrderId()) + .orElseThrow(() -> + new BusinessException(ErrorCode.PAYMENT_NOT_FOUND) + ); + + if (payment.getStatus() == PaymentStatus.SUCCESS) { + return; + } + + + boolean isValid = Utils.verifySignature( + request.razorpayOrderId() + + "|" + + request.razorpayPaymentId(), + request.razorpaySignature(), + razorpayProperties.getKeySecret() + ); + + if (!isValid) { + throw new BusinessException(ErrorCode.INVALID_PAYMENT_SIGNATURE); + } + + payment.setStatus(PaymentStatus.SUCCESS); + payment.setRazorpayPaymentId(request.razorpayPaymentId()); + payment.setRazorpaySignature(request.razorpaySignature()); + payment.setPaidAt(LocalDateTime.now()); + + Booking booking = payment.getBooking(); + + booking.setStatus(BookingStatus.CONFIRMED); + + paymentRepository.save(payment); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/slot/controller/SlotTemplateController.java b/server/src/main/java/com/bookmyvenue/server/slot/controller/SlotTemplateController.java new file mode 100644 index 000000000..7fec8ff1b --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/slot/controller/SlotTemplateController.java @@ -0,0 +1,77 @@ +package com.bookmyvenue.server.slot.controller; + +import com.bookmyvenue.server.slot.dto.request.CreateSlotTemplateRequest; +import com.bookmyvenue.server.slot.dto.response.SlotTemplateResponse; +import com.bookmyvenue.server.slot.service.SlotTemplateService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@Tag(name = "Slot Management") +@PreAuthorize("hasRole('VENDOR')") +@RequestMapping("/api/v1/venues") +@RequiredArgsConstructor +public class SlotTemplateController { + + private final SlotTemplateService slotTemplateService; + + /** + * Creates a new slot template for a venue. + */ + @PostMapping("/{venueId}/slots") + @Operation(summary = "Create Slot Template") + public ResponseEntity createTemplate( + @PathVariable Long venueId, + @Valid @RequestBody CreateSlotTemplateRequest request + ) { + + SlotTemplateResponse response = + slotTemplateService.createTemplate( + venueId, + request + ); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(response); + } + + /** + * Returns all slot templates configured for a venue. + */ + @GetMapping("/{venueId}/slots") + @Operation(summary = "Get Slot Templates") + public ResponseEntity> getVenueTemplates( + @PathVariable Long venueId + ) { + + return ResponseEntity.ok( + slotTemplateService.getVenueTemplates( + venueId + ) + ); + } + + /** + * Deletes a slot template. + */ + + @DeleteMapping("/slots/{templateId}") + @Operation(summary = "Delete Slot Template") + public ResponseEntity deleteTemplate( + @PathVariable Long templateId + ) { + + slotTemplateService.deleteTemplate(templateId); + + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/slot/dto/request/CreateSlotTemplateRequest.java b/server/src/main/java/com/bookmyvenue/server/slot/dto/request/CreateSlotTemplateRequest.java new file mode 100644 index 000000000..f0ad6ecb9 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/slot/dto/request/CreateSlotTemplateRequest.java @@ -0,0 +1,11 @@ +package com.bookmyvenue.server.slot.dto.request; + +import java.time.DayOfWeek; +import java.time.LocalTime; + +public record CreateSlotTemplateRequest( + DayOfWeek dayOfWeek, + LocalTime startTime, + LocalTime endTime +) { +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/slot/dto/response/SlotTemplateResponse.java b/server/src/main/java/com/bookmyvenue/server/slot/dto/response/SlotTemplateResponse.java new file mode 100644 index 000000000..39d73adef --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/slot/dto/response/SlotTemplateResponse.java @@ -0,0 +1,19 @@ +package com.bookmyvenue.server.slot.dto.response; + +import java.time.DayOfWeek; +import java.time.LocalTime; + +public record SlotTemplateResponse( + + Long id, + + DayOfWeek dayOfWeek, + + LocalTime startTime, + + LocalTime endTime, + + boolean active + +) { +} diff --git a/server/src/main/java/com/bookmyvenue/server/slot/entity/SlotTemplate.java b/server/src/main/java/com/bookmyvenue/server/slot/entity/SlotTemplate.java new file mode 100644 index 000000000..c26b64f03 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/slot/entity/SlotTemplate.java @@ -0,0 +1,39 @@ +package com.bookmyvenue.server.slot.entity; + +import com.bookmyvenue.server.venue.entity.Venue; +import jakarta.persistence.*; +import lombok.*; + +import java.time.DayOfWeek; +import java.time.LocalTime; + +@Entity +@Table(name = "venue_availability_template") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SlotTemplate { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "day_of_week", nullable = false) + private DayOfWeek dayOfWeek; + + @Column(name = "start_time", nullable = false) + private LocalTime startTime; + + @Column(name = "end_time", nullable = false) + private LocalTime endTime; + + @Column(nullable = false) + private boolean active = true; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "venue_id", nullable = false) + private Venue venue; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/slot/repository/SlotTemplateRepository.java b/server/src/main/java/com/bookmyvenue/server/slot/repository/SlotTemplateRepository.java new file mode 100644 index 000000000..33e02027c --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/slot/repository/SlotTemplateRepository.java @@ -0,0 +1,16 @@ +package com.bookmyvenue.server.slot.repository; + +import com.bookmyvenue.server.slot.entity.SlotTemplate; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.DayOfWeek; +import java.util.List; + +public interface SlotTemplateRepository extends JpaRepository { +List findByVenueId(Long venueId); + +List findByVenueIdAndDayOfWeek( + Long venueId, + DayOfWeek dayOfWeek +); +} diff --git a/server/src/main/java/com/bookmyvenue/server/slot/service/SlotTemplateService.java b/server/src/main/java/com/bookmyvenue/server/slot/service/SlotTemplateService.java new file mode 100644 index 000000000..9fa63a8f6 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/slot/service/SlotTemplateService.java @@ -0,0 +1,22 @@ +package com.bookmyvenue.server.slot.service; + +import com.bookmyvenue.server.slot.dto.request.CreateSlotTemplateRequest; +import com.bookmyvenue.server.slot.dto.response.SlotTemplateResponse; + +import java.util.List; + +public interface SlotTemplateService { + + SlotTemplateResponse createTemplate( + Long venueId, + CreateSlotTemplateRequest request + ); + + List getVenueTemplates( + Long venueId + ); + + void deleteTemplate( + Long templateId + ); +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/slot/service/SlotTemplateServiceImpl.java b/server/src/main/java/com/bookmyvenue/server/slot/service/SlotTemplateServiceImpl.java new file mode 100644 index 000000000..02ce84d20 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/slot/service/SlotTemplateServiceImpl.java @@ -0,0 +1,210 @@ +package com.bookmyvenue.server.slot.service; + +import com.bookmyvenue.server.auth.service.AuthenticatedUserService; +import com.bookmyvenue.server.common.exception.BusinessException; +import com.bookmyvenue.server.common.exception.ErrorCode; +import com.bookmyvenue.server.slot.dto.request.CreateSlotTemplateRequest; +import com.bookmyvenue.server.slot.dto.response.SlotTemplateResponse; +import com.bookmyvenue.server.slot.entity.SlotTemplate; +import com.bookmyvenue.server.slot.repository.SlotTemplateRepository; +import com.bookmyvenue.server.user.entity.User; +import com.bookmyvenue.server.venue.entity.Venue; +import com.bookmyvenue.server.venue.repository.VenueRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SlotTemplateServiceImpl + implements SlotTemplateService { + + private final SlotTemplateRepository slotTemplateRepository; + private final VenueRepository venueRepository; + private final AuthenticatedUserService authenticatedUserService; + + @Override + public SlotTemplateResponse createTemplate( + Long venueId, + CreateSlotTemplateRequest request + ) { + + log.info("Creating slot template for venueId={}", venueId); + + Venue venue = venueRepository.findById(venueId) + .orElseThrow(() -> { + log.warn("Venue not found. venueId={}", venueId); + return new BusinessException(ErrorCode.VENUE_NOT_FOUND); + }); + + User currentUser = + authenticatedUserService.getCurrentUser(); + + if (!venue.getOwner().getId().equals(currentUser.getId())) { + + log.warn( + "Unauthorized slot template creation attempt. venueId={}, userId={}", + venueId, + currentUser.getId() + ); + + throw new BusinessException(ErrorCode.ACCESS_DENIED); + } + + if (!request.endTime().isAfter(request.startTime())) { + + log.warn( + "Invalid time range. startTime={}, endTime={}", + request.startTime(), + request.endTime() + ); + throw new BusinessException(ErrorCode.INVALID_TIME_RANGE); + } + + List existingTemplates = + slotTemplateRepository.findByVenueIdAndDayOfWeek( + venueId, + request.dayOfWeek() + ); + + // Ensure the new slot does not overlap with any existing slot + // for the same venue and day of the week. + boolean hasOverlap = existingTemplates.stream() + .anyMatch(template -> + request.startTime().isBefore(template.getEndTime()) + && request.endTime().isAfter(template.getStartTime()) + ); + + if (hasOverlap) { + + log.warn( + "Overlapping slot template detected. venueId={}, day={}, startTime={}, endTime={}", + venueId, + request.dayOfWeek(), + request.startTime(), + request.endTime() + ); + + throw new BusinessException( + ErrorCode.OVERLAPPING_SLOT_TEMPLATE + ); + } + + SlotTemplate slotTemplate = + SlotTemplate.builder() + .venue(venue) + .dayOfWeek(request.dayOfWeek()) + .startTime(request.startTime()) + .endTime(request.endTime()) + .active(true) + .build(); + + slotTemplateRepository.save(slotTemplate); + + log.info( + "Slot template created successfully. templateId={}, venueId={}", + slotTemplate.getId(), + venueId + ); + + return new SlotTemplateResponse( + slotTemplate.getId(), + slotTemplate.getDayOfWeek(), + slotTemplate.getStartTime(), + slotTemplate.getEndTime(), + slotTemplate.isActive() + ); + } + + @Override + public List getVenueTemplates( + Long venueId + ) { + + log.info( + "Fetching slot templates for venueId={}", + venueId + ); + Venue venue = venueRepository.findById(venueId) + .orElseThrow(() -> + new BusinessException(ErrorCode.VENUE_NOT_FOUND)); + + User currentUser = authenticatedUserService.getCurrentUser(); + + if (!venue.getOwner().getId().equals(currentUser.getId())) { + log.warn( + "Unauthorized slot template access. venueId={}, userId={}", + venueId, + currentUser.getId() + ); + throw new BusinessException(ErrorCode.ACCESS_DENIED); + } + + log.info( + "Successfully fetched slot templates for venueId={}", + venueId + ); + return slotTemplateRepository.findByVenueId(venueId) + .stream() + .map(template -> + new SlotTemplateResponse( + template.getId(), + template.getDayOfWeek(), + template.getStartTime(), + template.getEndTime(), + template.isActive() + ) + ) + .toList(); + } + + @Override + public void deleteTemplate(Long templateId) { + + log.info( + "Deleting slot template. templateId={}", + templateId + ); + + SlotTemplate template = + slotTemplateRepository.findById(templateId) + .orElseThrow(() -> { + log.warn( + "Slot template not found. templateId={}", + templateId + ); + return new BusinessException( + ErrorCode.SLOT_TEMPLATE_NOT_FOUND + ); + }); + + User currentUser = + authenticatedUserService.getCurrentUser(); + + if (!template.getVenue() + .getOwner() + .getId() + .equals(currentUser.getId())) { + + log.warn( + "Unauthorized slot template deletion attempt. templateId={}, userId={}", + templateId, + currentUser.getId() + ); + + throw new BusinessException( + ErrorCode.ACCESS_DENIED + ); + } + + slotTemplateRepository.delete(template); + + log.info( + "Slot template deleted successfully. templateId={}", + templateId + ); + } +} diff --git a/server/src/main/java/com/bookmyvenue/server/user/controller/UserController.java b/server/src/main/java/com/bookmyvenue/server/user/controller/UserController.java new file mode 100644 index 000000000..d193424a4 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/user/controller/UserController.java @@ -0,0 +1,27 @@ +package com.bookmyvenue.server.user.controller; + +import com.bookmyvenue.server.user.dto.response.UserProfileResponse; +import com.bookmyvenue.server.user.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +@PreAuthorize("isAuthenticated()") +public class UserController { + + private final UserService userService; + + @Operation(summary = "Get user profile", description = "Returns the profile details of the authenticated user.") + @ApiResponse(responseCode = "200", description = "Profile retrieved successfully") + @GetMapping("/profile") + public UserProfileResponse getProfile() { + return userService.getCurrentUserProfile(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/user/dto/response/UserProfileResponse.java b/server/src/main/java/com/bookmyvenue/server/user/dto/response/UserProfileResponse.java new file mode 100644 index 000000000..1b3c82aac --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/user/dto/response/UserProfileResponse.java @@ -0,0 +1,18 @@ +package com.bookmyvenue.server.user.dto.response; + +import com.bookmyvenue.server.user.enums.Role; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Builder +public record UserProfileResponse( + UUID id, + String name, + String email, + String phone, + Role role, + LocalDateTime createdAt +) { +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/user/entity/User.java b/server/src/main/java/com/bookmyvenue/server/user/entity/User.java new file mode 100644 index 000000000..640293667 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/user/entity/User.java @@ -0,0 +1,70 @@ +package com.bookmyvenue.server.user.entity; + +import com.bookmyvenue.server.user.enums.Role; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "users") // Maps this entity to the 'users' table in PostgreSQL +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) // Auto-generates a unique UUID for each user + private UUID id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false, unique = true) + private String phone; + + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; // USER, VENDOR, or ADMIN + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; // Record creation timestamp + + @Column(nullable = false) + private LocalDateTime updatedAt; // Last update timestamp + + @Column(nullable = false) + private boolean emailVerified = false; + + @Column(nullable = false) + private boolean phoneVerified = false; + + /** + * Automatically sets creation and update timestamps + * before the entity is inserted into the database. + */ + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + /** + * Automatically updates the updatedAt timestamp + * whenever the entity is modified. + */ + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/server/src/main/java/com/bookmyvenue/server/user/enums/Role.java b/server/src/main/java/com/bookmyvenue/server/user/enums/Role.java new file mode 100644 index 000000000..d53c13957 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/user/enums/Role.java @@ -0,0 +1,10 @@ +package com.bookmyvenue.server.user.enums; + +/** + * Defines the roles available in the BookMyVenue platform. + */ +public enum Role { + USER, + VENDOR, + ADMIN +} diff --git a/server/src/main/java/com/bookmyvenue/server/user/repository/UserRepository.java b/server/src/main/java/com/bookmyvenue/server/user/repository/UserRepository.java new file mode 100644 index 000000000..67f7f12d8 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/user/repository/UserRepository.java @@ -0,0 +1,43 @@ +package com.bookmyvenue.server.user.repository; + +import com.bookmyvenue.server.user.entity.User; +import com.bookmyvenue.server.user.enums.Role; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository extends JpaRepository { + + /** + * Finds a user by email address. + * Used during authentication and login. + */ + Optional findByEmail(String email); + + /** + * Checks whether an email is already registered. + * Used during user registration. + */ + boolean existsByEmail(String email); + + /** + * Checks whether a phone number is already registered. + * Used during user registration. + */ + boolean existsByPhone(String phone); + + UUID id(UUID id); + + /** + * To check by role of the user + * @param role + * @return + */ + long countByRole(Role role); + + + Page findByRole(Role role, Pageable pageable); +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/user/service/UserService.java b/server/src/main/java/com/bookmyvenue/server/user/service/UserService.java new file mode 100644 index 000000000..4c12d9fd7 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/user/service/UserService.java @@ -0,0 +1,9 @@ +package com.bookmyvenue.server.user.service; + +import com.bookmyvenue.server.user.dto.response.UserProfileResponse; + +public interface UserService { + + UserProfileResponse getCurrentUserProfile(); + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/user/service/UserServiceImpl.java b/server/src/main/java/com/bookmyvenue/server/user/service/UserServiceImpl.java new file mode 100644 index 000000000..1536d5da8 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/user/service/UserServiceImpl.java @@ -0,0 +1,29 @@ +package com.bookmyvenue.server.user.service; + +import com.bookmyvenue.server.auth.service.AuthenticatedUserService; +import com.bookmyvenue.server.user.dto.response.UserProfileResponse; +import com.bookmyvenue.server.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final AuthenticatedUserService authenticatedUserService; + + @Override + public UserProfileResponse getCurrentUserProfile() { + + User user = authenticatedUserService.getCurrentUser(); + + return UserProfileResponse.builder() + .id(user.getId()) + .name(user.getName()) + .email(user.getEmail()) + .phone(user.getPhone()) + .role(user.getRole()) + .createdAt(user.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/venue/controller/VenueCategoryController.java b/server/src/main/java/com/bookmyvenue/server/venue/controller/VenueCategoryController.java new file mode 100644 index 000000000..3c0c90442 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/controller/VenueCategoryController.java @@ -0,0 +1,24 @@ +package com.bookmyvenue.server.venue.controller; + + +import com.bookmyvenue.server.venue.dto.response.VenueCategoryResponse; +import com.bookmyvenue.server.venue.service.VenueCategoryService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/categories") +@RequiredArgsConstructor +public class VenueCategoryController { + + private final VenueCategoryService venueCategoryService; + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public List getCategories() { + return venueCategoryService.getCategories(); + } +} diff --git a/server/src/main/java/com/bookmyvenue/server/venue/controller/VenueController.java b/server/src/main/java/com/bookmyvenue/server/venue/controller/VenueController.java new file mode 100644 index 000000000..9151b8273 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/controller/VenueController.java @@ -0,0 +1,89 @@ +package com.bookmyvenue.server.venue.controller; + +import com.bookmyvenue.server.venue.dto.request.CreateVenueRequest; +import com.bookmyvenue.server.venue.dto.request.UpdateVenueRequest; +import com.bookmyvenue.server.venue.dto.response.VenueResponse; +import com.bookmyvenue.server.venue.service.VenueService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@Tag(name = "Venue Management") +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class VenueController { + + private final VenueService venueService; + + @PostMapping("/vendor/venues") + @Operation(summary = "Create Venue") + @PreAuthorize("hasRole('VENDOR')") + @ResponseStatus(HttpStatus.CREATED) + public VenueResponse createVenue( + @RequestBody CreateVenueRequest request + ) { + System.out.println("CREATE VENUE CONTROLLER HIT"); + return venueService.createVenue(request); + } + + @GetMapping("/vendor/venues") + @PreAuthorize("hasRole('VENDOR')") + @Operation(summary = "Get Vendor Venues") + @ResponseStatus(HttpStatus.OK) + public List getAllVenues(){ + return venueService.getAllVenues(); + } + + @GetMapping("/vendor/venues/{venueId}") + @Operation(summary = "Get Vendor Venue") + @ResponseStatus(HttpStatus.OK) + @PreAuthorize("hasRole('VENDOR')") + public VenueResponse getVenue(@PathVariable Long venueId){ + return venueService.getVenue(venueId); + } + + @GetMapping("/venues") + @ResponseStatus(HttpStatus.OK) + public List getApprovedVenues( + @RequestParam(required = false) String search, + @RequestParam(required = false) String district, + @RequestParam(required = false) Long categoryId) { + return venueService.getApprovedVenues(search,district,categoryId); + } + + @GetMapping("/venues/{id}") + @Operation(summary = "Browse Venues") + @ResponseStatus(HttpStatus.OK) + public VenueResponse getApprovedVenue( + @PathVariable Long id + ) { + return venueService.getApprovedVenue(id); + } + + @PatchMapping("/venues/{id}") + @Operation(summary = "Update Venue") + @PreAuthorize("hasRole('VENDOR')") + @ResponseStatus(HttpStatus.OK) + public VenueResponse updateVenue( + @PathVariable Long id, + @RequestBody UpdateVenueRequest request + ) { + return venueService.updateVenue(id, request); + } + + @DeleteMapping("/venues/{id}") + @Operation(summary = "Delete Venue") + @PreAuthorize("hasRole('VENDOR')") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteVenue( + @PathVariable Long id + ) { + venueService.deleteVenue(id); + } +} diff --git a/server/src/main/java/com/bookmyvenue/server/venue/dto/request/CreateVenueRequest.java b/server/src/main/java/com/bookmyvenue/server/venue/dto/request/CreateVenueRequest.java new file mode 100644 index 000000000..8988f7242 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/dto/request/CreateVenueRequest.java @@ -0,0 +1,32 @@ +package com.bookmyvenue.server.venue.dto.request; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import lombok.*; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreateVenueRequest { + + private String name; + private String description; + private String address; + private String district; + + private Integer capacity; + + private BigDecimal pricePerSlot; + @DecimalMin("0.00") + @DecimalMax("100.00") + private BigDecimal advancePercentage; + private Long categoryId; + + private List imageUrls; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/venue/dto/request/UpdateVenueRequest.java b/server/src/main/java/com/bookmyvenue/server/venue/dto/request/UpdateVenueRequest.java new file mode 100644 index 000000000..fe9da4d00 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/dto/request/UpdateVenueRequest.java @@ -0,0 +1,26 @@ +package com.bookmyvenue.server.venue.dto.request; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import lombok.*; + +import java.math.BigDecimal; +import java.util.UUID; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class UpdateVenueRequest { + private String name; + private String description; + private String address; + private String district; + private Integer capacity; + private BigDecimal pricePerSlot; + @DecimalMin("0.00") + @DecimalMax("100.00") + private BigDecimal advancePercentage; + private Long categoryId; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/venue/dto/response/VenueCategoryResponse.java b/server/src/main/java/com/bookmyvenue/server/venue/dto/response/VenueCategoryResponse.java new file mode 100644 index 000000000..f424c1ecb --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/dto/response/VenueCategoryResponse.java @@ -0,0 +1,17 @@ +package com.bookmyvenue.server.venue.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VenueCategoryResponse { + + private Long id; + private String name; + private String description; +} diff --git a/server/src/main/java/com/bookmyvenue/server/venue/dto/response/VenueResponse.java b/server/src/main/java/com/bookmyvenue/server/venue/dto/response/VenueResponse.java new file mode 100644 index 000000000..5d45775d6 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/dto/response/VenueResponse.java @@ -0,0 +1,37 @@ +package com.bookmyvenue.server.venue.dto.response; + +import com.bookmyvenue.server.venue.entity.VenueStatus; +import lombok.*; + +import java.math.BigDecimal; +import java.util.List; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class VenueResponse { + + private Long id; + + private String name; + + private String description; + + private String address; + + private String district; + + private Integer capacity; + + private BigDecimal pricePerSlot; + + private BigDecimal advancePercentage; + + private String category; + + private VenueStatus status; + + private List imageUrls; +} diff --git a/server/src/main/java/com/bookmyvenue/server/venue/entity/Venue.java b/server/src/main/java/com/bookmyvenue/server/venue/entity/Venue.java new file mode 100644 index 000000000..dea727a5d --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/entity/Venue.java @@ -0,0 +1,80 @@ +package com.bookmyvenue.server.venue.entity; + +import com.bookmyvenue.server.user.entity.User; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "venue") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Venue { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_id", nullable = false) + private User owner; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id", nullable = false) + private VenueCategory category; + + @Column(nullable = false) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(nullable = false, columnDefinition = "TEXT") + private String address; + + @Column(length = 100) + private String district; + + @Column(nullable = false) + private Integer capacity; + + @Column(name = "price_per_slot", nullable = false, precision = 10, scale = 2) + private BigDecimal pricePerSlot; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private VenueStatus status; + + @OneToMany( + mappedBy = "venue", + cascade = CascadeType.ALL, + orphanRemoval = true + ) + @Builder.Default + private List images = new ArrayList<>(); + + @Column( + name = "advance_percentage", + nullable = false, + precision = 5, + scale = 2 + ) + private BigDecimal advancePercentage; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/venue/entity/VenueCategory.java b/server/src/main/java/com/bookmyvenue/server/venue/entity/VenueCategory.java new file mode 100644 index 000000000..217bcae44 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/entity/VenueCategory.java @@ -0,0 +1,36 @@ +package com.bookmyvenue.server.venue.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "venue_category") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VenueCategory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 100) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/venue/entity/VenueImage.java b/server/src/main/java/com/bookmyvenue/server/venue/entity/VenueImage.java new file mode 100644 index 000000000..2f670cdb5 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/entity/VenueImage.java @@ -0,0 +1,36 @@ +package com.bookmyvenue.server.venue.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "venue_image") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VenueImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "venue_id", nullable = false) + private Venue venue; + + @Column(name = "image_url", nullable = false, length = 1000) + private String imageUrl; + + @Builder.Default + @Column(name = "is_primary", nullable = false) + private Boolean isPrimary = false; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/server/src/main/java/com/bookmyvenue/server/venue/entity/VenueStatus.java b/server/src/main/java/com/bookmyvenue/server/venue/entity/VenueStatus.java new file mode 100644 index 000000000..0c1ee948c --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/entity/VenueStatus.java @@ -0,0 +1,8 @@ +package com.bookmyvenue.server.venue.entity; + +public enum VenueStatus { + PENDING_APPROVAL, + APPROVED, + REJECTED, + INACTIVE +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/venue/mapper/VenueMapper.java b/server/src/main/java/com/bookmyvenue/server/venue/mapper/VenueMapper.java new file mode 100644 index 000000000..7ee0742ec --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/mapper/VenueMapper.java @@ -0,0 +1,41 @@ +package com.bookmyvenue.server.venue.mapper; + +import com.bookmyvenue.server.venue.dto.response.VenueResponse; +import com.bookmyvenue.server.venue.entity.Venue; +import com.bookmyvenue.server.venue.entity.VenueImage; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class VenueMapper { + + public VenueResponse toResponse(Venue venue) { + return VenueResponse.builder() + .id(venue.getId()) + .name(venue.getName()) + .description(venue.getDescription()) + .address(venue.getAddress()) + .district(venue.getDistrict()) + .capacity(venue.getCapacity()) + .pricePerSlot(venue.getPricePerSlot()) + .advancePercentage( + venue.getAdvancePercentage() + ) + .category(venue.getCategory().getName()) + .status(venue.getStatus()) + .imageUrls( + venue.getImages() + .stream() + .map(VenueImage::getImageUrl) + .toList() + ) + .build(); + } + + public List toResponse(List venues) { + return venues.stream() + .map(this::toResponse) + .toList(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/venue/repository/VenueCategoryRepository.java b/server/src/main/java/com/bookmyvenue/server/venue/repository/VenueCategoryRepository.java new file mode 100644 index 000000000..3e917dbf4 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/repository/VenueCategoryRepository.java @@ -0,0 +1,12 @@ +package com.bookmyvenue.server.venue.repository; + +import com.bookmyvenue.server.venue.entity.VenueCategory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface VenueCategoryRepository extends JpaRepository { + List findAllByOrderByNameAsc(); +} diff --git a/server/src/main/java/com/bookmyvenue/server/venue/repository/VenueImageRepository.java b/server/src/main/java/com/bookmyvenue/server/venue/repository/VenueImageRepository.java new file mode 100644 index 000000000..8d4971913 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/repository/VenueImageRepository.java @@ -0,0 +1,9 @@ +package com.bookmyvenue.server.venue.repository; + +import com.bookmyvenue.server.venue.entity.VenueImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface VenueImageRepository extends JpaRepository { +} diff --git a/server/src/main/java/com/bookmyvenue/server/venue/repository/VenueRepository.java b/server/src/main/java/com/bookmyvenue/server/venue/repository/VenueRepository.java new file mode 100644 index 000000000..e210d1363 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/repository/VenueRepository.java @@ -0,0 +1,47 @@ +package com.bookmyvenue.server.venue.repository; + +import com.bookmyvenue.server.venue.entity.Venue; +import com.bookmyvenue.server.venue.entity.VenueStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface VenueRepository extends JpaRepository { + List findByStatus(VenueStatus status); + Optional findByIdAndStatus( + Long id, + VenueStatus status + ); + List findByStatusAndDistrictContainingIgnoreCase( + VenueStatus status, + String district + ); + + List findByStatusAndCategoryId( + VenueStatus status, + Long categoryId + ); + + List findByStatusAndDistrictIgnoreCaseAndCategoryId( + VenueStatus status, + String district, + Long categoryId + ); + List findByStatusAndNameContainingIgnoreCase( + VenueStatus status, + String keyword + ); + + List findByOwnerId(UUID ownerId); + + Optional findByIdAndOwnerId(Long id, UUID ownerId); + + + long countByStatus(VenueStatus status); + + Page findByStatus(VenueStatus status, Pageable pageable); +} diff --git a/server/src/main/java/com/bookmyvenue/server/venue/service/VenueCategoryService.java b/server/src/main/java/com/bookmyvenue/server/venue/service/VenueCategoryService.java new file mode 100644 index 000000000..a8fa44d2d --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/service/VenueCategoryService.java @@ -0,0 +1,10 @@ +package com.bookmyvenue.server.venue.service; + +import com.bookmyvenue.server.venue.dto.response.VenueCategoryResponse; + +import java.util.List; + +public interface VenueCategoryService { + + List getCategories(); +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/venue/service/VenueCategoryServiceImpl.java b/server/src/main/java/com/bookmyvenue/server/venue/service/VenueCategoryServiceImpl.java new file mode 100644 index 000000000..f7eff23c5 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/service/VenueCategoryServiceImpl.java @@ -0,0 +1,31 @@ +package com.bookmyvenue.server.venue.service; + +import com.bookmyvenue.server.venue.dto.response.VenueCategoryResponse; +import com.bookmyvenue.server.venue.repository.VenueCategoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class VenueCategoryServiceImpl + implements VenueCategoryService { + + private final VenueCategoryRepository venueCategoryRepository; + + @Override + public List getCategories() { + + return venueCategoryRepository.findAllByOrderByNameAsc() + .stream() + .map(category -> + VenueCategoryResponse.builder() + .id(category.getId()) + .name(category.getName()) + .description(category.getDescription()) + .build() + ) + .toList(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/venue/service/VenueService.java b/server/src/main/java/com/bookmyvenue/server/venue/service/VenueService.java new file mode 100644 index 000000000..7b607363c --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/service/VenueService.java @@ -0,0 +1,26 @@ +package com.bookmyvenue.server.venue.service; + +import com.bookmyvenue.server.venue.dto.request.CreateVenueRequest; +import com.bookmyvenue.server.venue.dto.request.UpdateVenueRequest; +import com.bookmyvenue.server.venue.dto.response.VenueResponse; + +import java.util.List; +import java.util.UUID; + +public interface VenueService { + + VenueResponse createVenue(CreateVenueRequest request); + + List getAllVenues(); + + VenueResponse getVenue(Long venueId); + + List getApprovedVenues(String keyword, String district,Long categoryId); + + VenueResponse getApprovedVenue(Long id); + + VenueResponse updateVenue(Long venueId, UpdateVenueRequest request); + + void deleteVenue(Long venueId); + +} diff --git a/server/src/main/java/com/bookmyvenue/server/venue/service/VenueServiceImpl.java b/server/src/main/java/com/bookmyvenue/server/venue/service/VenueServiceImpl.java new file mode 100644 index 000000000..b99c290a9 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/venue/service/VenueServiceImpl.java @@ -0,0 +1,250 @@ +package com.bookmyvenue.server.venue.service; + +import com.bookmyvenue.server.auth.service.AuthenticatedUserService; +import com.bookmyvenue.server.common.exception.BusinessException; +import com.bookmyvenue.server.common.exception.ErrorCode; +import com.bookmyvenue.server.user.entity.User; +import com.bookmyvenue.server.venue.dto.request.CreateVenueRequest; +import com.bookmyvenue.server.venue.dto.request.UpdateVenueRequest; +import com.bookmyvenue.server.venue.dto.response.VenueResponse; +import com.bookmyvenue.server.venue.entity.Venue; +import com.bookmyvenue.server.venue.entity.VenueCategory; +import com.bookmyvenue.server.venue.entity.VenueImage; +import com.bookmyvenue.server.venue.entity.VenueStatus; +import com.bookmyvenue.server.venue.mapper.VenueMapper; +import com.bookmyvenue.server.venue.repository.VenueCategoryRepository; +import com.bookmyvenue.server.venue.repository.VenueImageRepository; +import com.bookmyvenue.server.venue.repository.VenueRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + + +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class VenueServiceImpl implements VenueService { + + private final VenueRepository venueRepository; + private final VenueCategoryRepository venueCategoryRepository; + private final VenueMapper venueMapper; + private final AuthenticatedUserService authenticatedUserService; + private final VenueImageRepository venueImageRepository; + + @Override + public VenueResponse createVenue(CreateVenueRequest request) { + log.info("Creating venue with name: {}, categoryId: {}", + request.getName(), + request.getCategoryId()); + VenueCategory category = venueCategoryRepository + .findById(request.getCategoryId()) + .orElseThrow(() -> + new BusinessException(ErrorCode.VENUE_CATEGORY_NOT_FOUND)); + + // Get currently authenticated venue owner + User currentUser = + authenticatedUserService.getCurrentUser(); + System.out.println("CURRENT USER = " + currentUser.getEmail()); + + + // New venues require admin approval before becoming visible + Venue venue = Venue.builder() + .name(request.getName()) + .description(request.getDescription()) + .address(request.getAddress()) + .district(request.getDistrict()) + .capacity(request.getCapacity()) + .pricePerSlot(request.getPricePerSlot()) + .advancePercentage(request.getAdvancePercentage()) + .category(category) + .status(VenueStatus.PENDING_APPROVAL) + .owner(currentUser) + .build(); + + if (request.getImageUrls() != null) { + request.getImageUrls().forEach(url -> { + VenueImage image = VenueImage.builder() + .venue(venue) + .imageUrl(url) + .build(); + + venue.getImages().add(image); + }); + } + + Venue savedVenue = venueRepository.save(venue); + + return venueMapper.toResponse(savedVenue); + } + + @Override + public List getAllVenues() { + User currentUser = authenticatedUserService.getCurrentUser(); + List venues = venueRepository.findByOwnerId(currentUser.getId()); + + return venueMapper.toResponse(venues); + } + + @Override + public VenueResponse getVenue(Long venueId) { + User currentUser = authenticatedUserService.getCurrentUser(); + + Venue venue = venueRepository + .findByIdAndOwnerId(venueId, currentUser.getId()) + .orElseThrow(() -> + new BusinessException(ErrorCode.VENUE_NOT_FOUND)); + return venueMapper.toResponse(venue); + } + + @Override + public List getApprovedVenues( + String keyword, + String district, + Long categoryId + ) { + + List venues; + + if (keyword != null && !keyword.isBlank()) { + + venues = venueRepository.findByStatusAndNameContainingIgnoreCase( + VenueStatus.APPROVED, + keyword + ); + + } else if (district != null && categoryId != null) { + + venues = venueRepository + .findByStatusAndDistrictIgnoreCaseAndCategoryId( + VenueStatus.APPROVED, + district, + categoryId + ); + + } else if (district != null) { + + venues = venueRepository + .findByStatusAndDistrictContainingIgnoreCase( + VenueStatus.APPROVED, + district + ); + + } else if (categoryId != null) { + + venues = venueRepository + .findByStatusAndCategoryId( + VenueStatus.APPROVED, + categoryId + ); + + } + + + else { + + venues = venueRepository.findByStatus( + VenueStatus.APPROVED + ); + } + + return venues.stream() + .map(venueMapper::toResponse) + .toList(); + } + + @Override + public VenueResponse getApprovedVenue(Long id) { + + Venue venue = venueRepository + .findByIdAndStatus(id, VenueStatus.APPROVED) + .orElseThrow(()-> + new BusinessException(ErrorCode.VENUE_NOT_FOUND)); + + return venueMapper.toResponse(venue); + } + + @Override + public VenueResponse updateVenue( + Long venueId, + UpdateVenueRequest request + ) { + + // Ensure only the venue owner can modify venue details + User currentUser = authenticatedUserService.getCurrentUser(); + Venue venue = venueRepository + .findByIdAndOwnerId(venueId, currentUser.getId()) + .orElseThrow(() -> + new BusinessException(ErrorCode.VENUE_NOT_FOUND)); + + if (request.getName() != null) { + venue.setName(request.getName()); + } + + if (request.getDescription() != null) { + venue.setDescription(request.getDescription()); + } + + if (request.getAddress() != null) { + venue.setAddress(request.getAddress()); + } + + if (request.getDistrict() != null) { + venue.setDistrict(request.getDistrict()); + } + + if (request.getCapacity() != null) { + venue.setCapacity(request.getCapacity()); + } + + if (request.getPricePerSlot() != null) { + venue.setPricePerSlot(request.getPricePerSlot()); + } + + if (request.getAdvancePercentage() != null) { + venue.setAdvancePercentage( + request.getAdvancePercentage() + ); + } + + if (request.getCategoryId() != null) { + + VenueCategory category = venueCategoryRepository + .findById(request.getCategoryId()) + .orElseThrow(() -> + new BusinessException(ErrorCode.VENUE_CATEGORY_NOT_FOUND)); + + venue.setCategory(category); + } + + Venue updatedVenue = venueRepository.save(venue); + + log.info( + "Venue updated. venueId={}, ownerId={}", + updatedVenue.getId(), + currentUser.getId() + ); + + return venueMapper.toResponse(updatedVenue); + } + + @Override + public void deleteVenue(Long venueId){ + + User currentUser = authenticatedUserService.getCurrentUser(); + Venue venue = venueRepository + .findByIdAndOwnerId(venueId, currentUser.getId()) + .orElseThrow(() -> + new BusinessException(ErrorCode.VENUE_NOT_FOUND)); + venueRepository.delete(venue); + + log.info( + "Venue deleted. venueId={}, ownerId={}", + venueId, + currentUser.getId() + ); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/controller/VerificationController.java b/server/src/main/java/com/bookmyvenue/server/verification/controller/VerificationController.java new file mode 100644 index 000000000..f16dd3b2e --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/controller/VerificationController.java @@ -0,0 +1,34 @@ +package com.bookmyvenue.server.verification.controller; + +import com.bookmyvenue.server.verification.dto.request.EmailRequest; +import com.bookmyvenue.server.verification.dto.request.VerifyEmailRequest; +import com.bookmyvenue.server.verification.service.VerificationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class VerificationController { + + private final VerificationService verificationService; + + @Operation(summary = "Verify email", description = "Verifies the user's email using the OTP sent to their email address.") + @ApiResponse(responseCode = "200", description = "Email verified successfully") + @PostMapping("/verify-email") + public void verifyEmail(@Valid @RequestBody VerifyEmailRequest request) + { + verificationService.verifyEmail(request.getEmail(), request.getOtp()); + } + + @Operation(summary = "Resend verification email", description = "Generates a new OTP and sends it to the user's registered email.") + @ApiResponse(responseCode = "200", description = "Verification email sent successfully") + @PostMapping("/resend-verification") + public void resendVerification(@Valid @RequestBody EmailRequest request) + { + verificationService.sendEmailVerificationOtp(request.getEmail()); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/dto/request/EmailRequest.java b/server/src/main/java/com/bookmyvenue/server/verification/dto/request/EmailRequest.java new file mode 100644 index 000000000..b230ecbf0 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/dto/request/EmailRequest.java @@ -0,0 +1,18 @@ +package com.bookmyvenue.server.verification.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class EmailRequest { + + @NotBlank(message = "Email is required") + @Email(message = "Invalid email format") + private String email; + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/dto/request/VerifyEmailRequest.java b/server/src/main/java/com/bookmyvenue/server/verification/dto/request/VerifyEmailRequest.java new file mode 100644 index 000000000..c71679719 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/dto/request/VerifyEmailRequest.java @@ -0,0 +1,23 @@ +package com.bookmyvenue.server.verification.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VerifyEmailRequest { + + @NotBlank(message = "Email is required") + @Email(message = "Invalid email format") + private String email; + + @NotBlank(message = "OTP is required") + @Pattern(regexp = "\\d{6}", message = "OTP must be a 6-digit number") + private String otp; + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/mail/client/BrevoClient.java b/server/src/main/java/com/bookmyvenue/server/verification/mail/client/BrevoClient.java new file mode 100644 index 000000000..c0c0b2c6d --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/mail/client/BrevoClient.java @@ -0,0 +1,56 @@ +package com.bookmyvenue.server.verification.mail.client; + + +import com.bookmyvenue.server.verification.mail.config.BrevoProperties; +import com.bookmyvenue.server.verification.mail.dto.BrevoEmailRequest; +import com.bookmyvenue.server.verification.mail.dto.BrevoResponse; +import com.bookmyvenue.server.verification.mail.dto.Recipient; +import com.bookmyvenue.server.verification.mail.dto.Sender; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.List; + +@Component +@Slf4j +@RequiredArgsConstructor +public class BrevoClient { + + private static final String BREVO_URL = "https://api.brevo.com/v3/smtp/email"; + + private final RestClient restClient; + private final BrevoProperties brevoProperties; + + public void sendEmail(String to, String subject, String htmlContent) { + + BrevoEmailRequest request = BrevoEmailRequest.builder() + .sender(new Sender( + brevoProperties.getSenderName(), + brevoProperties.getSenderEmail())) + .to(List.of(new Recipient(to))) + .subject(subject) + .htmlContent(htmlContent) + .build(); + + try { + BrevoResponse response = restClient.post() + .uri(BREVO_URL) + .header("api-key", brevoProperties.getApiKey()) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(BrevoResponse.class); + + log.info("Brevo Response: {}", response); + + } catch (Exception e) { + log.error("Brevo email sending failed", e); + throw e; + } + + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/mail/config/BrevoProperties.java b/server/src/main/java/com/bookmyvenue/server/verification/mail/config/BrevoProperties.java new file mode 100644 index 000000000..b1c4d49e5 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/mail/config/BrevoProperties.java @@ -0,0 +1,19 @@ +package com.bookmyvenue.server.verification.mail.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.brevo") +public class BrevoProperties { + + private String apiKey; + + private String senderEmail; + + private String senderName; +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/mail/config/RestClientConfig.java b/server/src/main/java/com/bookmyvenue/server/verification/mail/config/RestClientConfig.java new file mode 100644 index 000000000..db6c210bb --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/mail/config/RestClientConfig.java @@ -0,0 +1,15 @@ +package com.bookmyvenue.server.verification.mail.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +public class RestClientConfig { + + @Bean + public RestClient restClient() { + + return RestClient.builder().build(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/mail/dto/BrevoEmailRequest.java b/server/src/main/java/com/bookmyvenue/server/verification/mail/dto/BrevoEmailRequest.java new file mode 100644 index 000000000..2a14adeeb --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/mail/dto/BrevoEmailRequest.java @@ -0,0 +1,20 @@ +package com.bookmyvenue.server.verification.mail.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class BrevoEmailRequest { + + private Sender sender; + + private List to; + + private String subject; + + private String htmlContent; + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/mail/dto/BrevoResponse.java b/server/src/main/java/com/bookmyvenue/server/verification/mail/dto/BrevoResponse.java new file mode 100644 index 000000000..159027b4c --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/mail/dto/BrevoResponse.java @@ -0,0 +1,4 @@ +package com.bookmyvenue.server.verification.mail.dto; + +public class BrevoResponse { +} diff --git a/server/src/main/java/com/bookmyvenue/server/verification/mail/dto/Recipient.java b/server/src/main/java/com/bookmyvenue/server/verification/mail/dto/Recipient.java new file mode 100644 index 000000000..1ec2df2ff --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/mail/dto/Recipient.java @@ -0,0 +1,12 @@ +package com.bookmyvenue.server.verification.mail.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class Recipient { + + private String email; + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/mail/dto/Sender.java b/server/src/main/java/com/bookmyvenue/server/verification/mail/dto/Sender.java new file mode 100644 index 000000000..005797391 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/mail/dto/Sender.java @@ -0,0 +1,13 @@ +package com.bookmyvenue.server.verification.mail.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class Sender { + + private String name; + private String email; + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/mail/service/MailService.java b/server/src/main/java/com/bookmyvenue/server/verification/mail/service/MailService.java new file mode 100644 index 000000000..68f566149 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/mail/service/MailService.java @@ -0,0 +1,11 @@ +package com.bookmyvenue.server.verification.mail.service; + +public interface MailService { + + + void sendVerificationOtp(String email, String otp); + + + void sendPasswordResetOtp(String email, String otp); + +} diff --git a/server/src/main/java/com/bookmyvenue/server/verification/mail/service/MailServiceImpl.java b/server/src/main/java/com/bookmyvenue/server/verification/mail/service/MailServiceImpl.java new file mode 100644 index 000000000..06ac33f74 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/mail/service/MailServiceImpl.java @@ -0,0 +1,44 @@ +package com.bookmyvenue.server.verification.mail.service; + +import com.bookmyvenue.server.verification.mail.client.BrevoClient; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MailServiceImpl implements MailService { + + private final BrevoClient brevoClient; + + @Override + public void sendVerificationOtp(String email, String otp) { + + String subject = "Verify your BookMyVenue account"; + + String html = """ +

Email Verification

+

Your verification code is:

+

%s

+

This code will expire in 5 minutes.

+ """.formatted(otp); + + brevoClient.sendEmail(email, subject, html); + } + + + @Override + public void sendPasswordResetOtp(String email, String otp) { + + String subject = "Reset your BookMyVenue password"; + + String html = """ +

Password Reset

+

Your password reset code is:

+

%s

+

This code will expire in 5 minutes.

+

If you did not request a password reset, you can safely ignore this email.

+ """.formatted(otp); + + brevoClient.sendEmail(email, subject, html); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/service/OtpRedisService.java b/server/src/main/java/com/bookmyvenue/server/verification/service/OtpRedisService.java new file mode 100644 index 000000000..e1aa41e43 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/service/OtpRedisService.java @@ -0,0 +1,14 @@ +package com.bookmyvenue.server.verification.service; + +public interface OtpRedisService { + + void saveOtp(String key, String otp); + + String getOtp(String key); + + void deleteOtp(String key); + + + + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/service/OtpRedisServiceImpl.java b/server/src/main/java/com/bookmyvenue/server/verification/service/OtpRedisServiceImpl.java new file mode 100644 index 000000000..3317d06db --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/service/OtpRedisServiceImpl.java @@ -0,0 +1,34 @@ +package com.bookmyvenue.server.verification.service; + +import com.bookmyvenue.server.verification.util.RedisKeys; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class OtpRedisServiceImpl implements OtpRedisService { + + private static final Duration OTP_EXPIRY = Duration.ofMinutes(5); + private final StringRedisTemplate redisTemplate; + + @Override + public void saveOtp(String key, String otp) { + redisTemplate.opsForValue() + .set(key, otp, OTP_EXPIRY); + } + + @Override + public String getOtp(String key) { + return redisTemplate.opsForValue().get(key); + } + + @Override + public void deleteOtp(String key) { + redisTemplate.delete(key); + } + + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/service/VerificationService.java b/server/src/main/java/com/bookmyvenue/server/verification/service/VerificationService.java new file mode 100644 index 000000000..c2e82de7e --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/service/VerificationService.java @@ -0,0 +1,10 @@ +package com.bookmyvenue.server.verification.service; + +public interface VerificationService { + + void sendEmailVerificationOtp(String email); + + void verifyEmail(String email, String otp); + + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/service/VerificationServiceImpl.java b/server/src/main/java/com/bookmyvenue/server/verification/service/VerificationServiceImpl.java new file mode 100644 index 000000000..e7098c2d7 --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/service/VerificationServiceImpl.java @@ -0,0 +1,75 @@ +package com.bookmyvenue.server.verification.service; + + +import com.bookmyvenue.server.common.exception.BusinessException; +import com.bookmyvenue.server.common.exception.ErrorCode; +import com.bookmyvenue.server.user.entity.User; +import com.bookmyvenue.server.user.repository.UserRepository; +import com.bookmyvenue.server.verification.mail.service.MailService; +import com.bookmyvenue.server.verification.util.OtpGenerator; +import com.bookmyvenue.server.verification.util.RedisKeys; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class VerificationServiceImpl implements VerificationService { + + private final OtpGenerator otpGenerator; + private final OtpRedisService otpRedisService; + private final MailService mailService; + private final UserRepository userRepository; + + + + @Override + public void sendEmailVerificationOtp(String email) { + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> + new BusinessException(ErrorCode.USER_NOT_FOUND)); + + if (user.isEmailVerified()) { + throw new BusinessException(ErrorCode.EMAIL_ALREADY_VERIFIED); + } + String otp = otpGenerator.generateOtp(); + String key = RedisKeys.emailVerification(email); + otpRedisService.saveOtp(key, otp); + log.info("Sending verification email to {}", email); + mailService.sendVerificationOtp(email, otp); + log.info("Verification email sent successfully to {}", email); + } + + @Transactional + @Override + public void verifyEmail(String email, String otp) { + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> + new BusinessException(ErrorCode.USER_NOT_FOUND)); + + + if (user.isEmailVerified()) { + throw new BusinessException(ErrorCode.EMAIL_ALREADY_VERIFIED); + } + + String key = RedisKeys.emailVerification(email); + String savedOtp = otpRedisService.getOtp(key); + + if (savedOtp == null) { + throw new BusinessException(ErrorCode.OTP_EXPIRED); + } + + if (!savedOtp.equals(otp)) { + throw new BusinessException(ErrorCode.INVALID_OTP); + } + user.setEmailVerified(true); + userRepository.save(user); + otpRedisService.deleteOtp(key); + log.info("Email verified successfully: {}", email); + } + +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/util/OtpGenerator.java b/server/src/main/java/com/bookmyvenue/server/verification/util/OtpGenerator.java new file mode 100644 index 000000000..26b925b5c --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/util/OtpGenerator.java @@ -0,0 +1,27 @@ +package com.bookmyvenue.server.verification.util; + +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; + +/** + * Utility class for generating secure 6-digit OTPs. + * Used for email and phone verification. + */ +@Component +public class OtpGenerator { + + // Cryptographically secure random number generator + private static final SecureRandom RANDOM = new SecureRandom(); + + /** + * Generates a random 6-digit OTP. + * Leading zeros are preserved (e.g., 000123). + * + * @return 6-digit OTP as a String + */ + public String generateOtp() { + + return String.format("%06d", RANDOM.nextInt(1_000_000)); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/bookmyvenue/server/verification/util/RedisKeys.java b/server/src/main/java/com/bookmyvenue/server/verification/util/RedisKeys.java new file mode 100644 index 000000000..08e6cf6cf --- /dev/null +++ b/server/src/main/java/com/bookmyvenue/server/verification/util/RedisKeys.java @@ -0,0 +1,19 @@ +package com.bookmyvenue.server.verification.util; + +public final class RedisKeys { + + private RedisKeys() { + } + + public static String emailVerification(String email) { + return "verify:email:" + email; + } + + public static String verifiedEmail(String email) { + return "verified:email:" + email; + } + + public static String passwordReset(String email) { + return "reset:password:" + email; + } +} \ No newline at end of file diff --git a/server/src/main/resources/application.properties b/server/src/main/resources/application.properties new file mode 100644 index 000000000..9c9ba4f78 --- /dev/null +++ b/server/src/main/resources/application.properties @@ -0,0 +1,39 @@ +# =================================================================== +# CONFIGURATION +# =================================================================== + +spring.config.import=optional:file:server/.env[.properties] + +spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/${DB_NAME}} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME:${DB_USERNAME}} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:${DB_PASSWORD}} +spring.datasource.driver-class-name=org.postgresql.Driver + +# JPA / Hibernate Properties +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +# JWT +app.jwt.secret=${JWT_SECRET} +app.jwt.access-token-expiration=${JWT_ACCESS_TOKEN_EXPIRATION} +app.jwt.refresh-token-expiration=${JWT_REFRESH_TOKEN_EXPIRATION} + +# Flyway +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration + +# Razorpay +app.razorpay.key-id=${RAZORPAY_KEY_ID} +app.razorpay.key-secret=${RAZORPAY_KEY_SECRET} + + +# Redis +spring.data.redis.host=${SPRING_DATA_REDIS_HOST:localhost} +spring.data.redis.port=${SPRING_DATA_REDIS_PORT:6379} + +# Brevo +app.brevo.api-key=${BREVO_API_KEY} +app.brevo.sender-email=${BREVO_SENDER_EMAIL} +app.brevo.sender-name=${BREVO_SENDER_NAME} + diff --git a/server/src/main/resources/db/migration/V10__add_advance_percentage_to_venue.sql b/server/src/main/resources/db/migration/V10__add_advance_percentage_to_venue.sql new file mode 100644 index 000000000..5ebf10511 --- /dev/null +++ b/server/src/main/resources/db/migration/V10__add_advance_percentage_to_venue.sql @@ -0,0 +1,2 @@ +ALTER TABLE venue + ADD COLUMN advance_percentage NUMERIC(5,2) NOT NULL DEFAULT 20.00; \ No newline at end of file diff --git a/server/src/main/resources/db/migration/V1__init_schema.sql b/server/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 000000000..4cfb547b0 --- /dev/null +++ b/server/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,136 @@ +CREATE TABLE users ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + phone VARCHAR(20) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + + +CREATE TABLE venue_category ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + + +CREATE TABLE venue ( + id BIGSERIAL PRIMARY KEY, + + owner_id UUID NOT NULL, + category_id BIGINT NOT NULL, + + name VARCHAR(255) NOT NULL, + description TEXT, + address TEXT NOT NULL, + district VARCHAR(100), + capacity INTEGER NOT NULL, + + price_per_slot DECIMAL(10,2) NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_venue_owner + FOREIGN KEY (owner_id) + REFERENCES users(id), + + CONSTRAINT fk_venue_category + FOREIGN KEY (category_id) + REFERENCES venue_category(id) +); + + + +CREATE TABLE venue_image ( + id BIGSERIAL PRIMARY KEY, + + venue_id BIGINT NOT NULL, + + image_url VARCHAR(1000) NOT NULL, + + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_venue_image_venue + FOREIGN KEY (venue_id) + REFERENCES venue(id) +); + + +CREATE TABLE venue_availability_template ( + id BIGSERIAL PRIMARY KEY, + + venue_id BIGINT NOT NULL, + + day_of_week VARCHAR(20) NOT NULL, + + start_time TIME NOT NULL, + end_time TIME NOT NULL, + + active BOOLEAN NOT NULL DEFAULT TRUE, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_template_venue + FOREIGN KEY (venue_id) + REFERENCES venue(id) + ON DELETE CASCADE +); + + +CREATE TABLE booking ( + id BIGSERIAL PRIMARY KEY, + + user_id UUID NOT NULL, + venue_id BIGINT NOT NULL, + + booking_date DATE NOT NULL, + + start_time TIME NOT NULL, + end_time TIME NOT NULL, + + status VARCHAR(50) NOT NULL, + + total_amount DECIMAL(10,2) NOT NULL, + + expires_at TIMESTAMP, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_booking_user + FOREIGN KEY (user_id) + REFERENCES users(id), + + CONSTRAINT fk_booking_venue + FOREIGN KEY (venue_id) + REFERENCES venue(id) +); + + +CREATE TABLE payment ( + id BIGSERIAL PRIMARY KEY, + + booking_id BIGINT NOT NULL, + + amount DECIMAL(10,2) NOT NULL, + + status VARCHAR(50) NOT NULL, + + gateway_payment_id VARCHAR(255), + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_payment_booking + FOREIGN KEY (booking_id) + REFERENCES booking(id) +); \ No newline at end of file diff --git a/server/src/main/resources/db/migration/V2__add_status_to_venue.sql b/server/src/main/resources/db/migration/V2__add_status_to_venue.sql new file mode 100644 index 000000000..5327660b7 --- /dev/null +++ b/server/src/main/resources/db/migration/V2__add_status_to_venue.sql @@ -0,0 +1,2 @@ +ALTER TABLE venue + ADD COLUMN status VARCHAR(30) NOT NULL DEFAULT 'PENDING_APPROVAL'; \ No newline at end of file diff --git a/server/src/main/resources/db/migration/V3__edit_booking_table.sql b/server/src/main/resources/db/migration/V3__edit_booking_table.sql new file mode 100644 index 000000000..84e1f8dc2 --- /dev/null +++ b/server/src/main/resources/db/migration/V3__edit_booking_table.sql @@ -0,0 +1,20 @@ +ALTER TABLE booking +DROP COLUMN start_time; + +ALTER TABLE booking +DROP COLUMN end_time; + +ALTER TABLE booking + ADD COLUMN slot_template_id BIGINT NOT NULL; + +ALTER TABLE booking + ADD CONSTRAINT fk_booking_slot_template + FOREIGN KEY (slot_template_id) + REFERENCES venue_availability_template(id); + +ALTER TABLE booking + ADD CONSTRAINT uk_booking_slot + UNIQUE ( + booking_date, + slot_template_id + ); \ No newline at end of file diff --git a/server/src/main/resources/db/migration/V4__add_unique_index_for_booking.sql b/server/src/main/resources/db/migration/V4__add_unique_index_for_booking.sql new file mode 100644 index 000000000..8a71fc13f --- /dev/null +++ b/server/src/main/resources/db/migration/V4__add_unique_index_for_booking.sql @@ -0,0 +1,3 @@ +CREATE UNIQUE INDEX uk_booking_active_slot + ON booking(booking_date, slot_template_id) + WHERE status IN ('PENDING', 'CONFIRMED'); \ No newline at end of file diff --git a/server/src/main/resources/db/migration/V5__delete_unique_constraint_in_booking.sql b/server/src/main/resources/db/migration/V5__delete_unique_constraint_in_booking.sql new file mode 100644 index 000000000..71165ba0f --- /dev/null +++ b/server/src/main/resources/db/migration/V5__delete_unique_constraint_in_booking.sql @@ -0,0 +1,2 @@ +ALTER TABLE booking +DROP CONSTRAINT uk_booking_slot; \ No newline at end of file diff --git a/server/src/main/resources/db/migration/V6__enhance_payment_for_razorpay.sql b/server/src/main/resources/db/migration/V6__enhance_payment_for_razorpay.sql new file mode 100644 index 000000000..cf06de206 --- /dev/null +++ b/server/src/main/resources/db/migration/V6__enhance_payment_for_razorpay.sql @@ -0,0 +1,11 @@ +ALTER TABLE payment + ADD COLUMN razorpay_order_id VARCHAR(255); + +ALTER TABLE payment + ADD COLUMN razorpay_payment_id VARCHAR(255); + +ALTER TABLE payment + ADD COLUMN razorpay_signature TEXT; + +ALTER TABLE payment + ADD COLUMN paid_at TIMESTAMP; \ No newline at end of file diff --git a/server/src/main/resources/db/migration/V7__add_user_verification_fields.sql b/server/src/main/resources/db/migration/V7__add_user_verification_fields.sql new file mode 100644 index 000000000..a232d5f71 --- /dev/null +++ b/server/src/main/resources/db/migration/V7__add_user_verification_fields.sql @@ -0,0 +1,5 @@ +ALTER TABLE users + ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE users + ADD COLUMN phone_verified BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/server/src/main/resources/db/migration/V8__seed_venue_categories.sql b/server/src/main/resources/db/migration/V8__seed_venue_categories.sql new file mode 100644 index 000000000..9aec01431 --- /dev/null +++ b/server/src/main/resources/db/migration/V8__seed_venue_categories.sql @@ -0,0 +1,15 @@ +INSERT INTO venue_category (name, description) +VALUES + ('Wedding Hall', 'Indoor halls for weddings and receptions'), + ('Convention Center', 'Large venues for conferences and corporate events'), + ('Resort', 'Resorts suitable for destination weddings and events'), + ('Banquet Hall', 'Banquet halls for celebrations and gatherings'), + ('Party Hall', 'Small and medium party venues'), + ('Auditorium', 'Auditoriums for cultural and public events'), + ('Conference Hall', 'Business meetings and seminars'), + ('Sports Turf', 'Football, cricket and other sports grounds'), + ('Farm House', 'Farm houses for private events'), + ('Beach Venue', 'Beachside venues for weddings and parties'), + ('Rooftop Venue', 'Open rooftop event spaces'), + ('Community Hall', 'Community-owned multipurpose halls') + ON CONFLICT (name) DO NOTHING; \ No newline at end of file diff --git a/server/src/main/resources/db/migration/V9__add_payment_type.sql b/server/src/main/resources/db/migration/V9__add_payment_type.sql new file mode 100644 index 000000000..767ddb7d9 --- /dev/null +++ b/server/src/main/resources/db/migration/V9__add_payment_type.sql @@ -0,0 +1,3 @@ +ALTER TABLE payment + ADD COLUMN payment_type VARCHAR(20) + NOT NULL DEFAULT 'ADVANCE'; \ No newline at end of file diff --git a/server/src/test/java/com/bookmyvenue/server/BookingConcurrencyTest.java b/server/src/test/java/com/bookmyvenue/server/BookingConcurrencyTest.java new file mode 100644 index 000000000..e59153f43 --- /dev/null +++ b/server/src/test/java/com/bookmyvenue/server/BookingConcurrencyTest.java @@ -0,0 +1,139 @@ +package com.bookmyvenue.server; + +import com.bookmyvenue.server.booking.entity.Booking; +import com.bookmyvenue.server.booking.enums.BookingStatus; +import com.bookmyvenue.server.booking.repository.BookingRepository; +import com.bookmyvenue.server.slot.entity.SlotTemplate; +import com.bookmyvenue.server.slot.repository.SlotTemplateRepository; +import com.bookmyvenue.server.user.entity.User; +import com.bookmyvenue.server.user.repository.UserRepository; +import com.bookmyvenue.server.venue.entity.Venue; +import com.bookmyvenue.server.venue.repository.VenueRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class BookingConcurrencyTest { + + @Autowired + private BookingRepository bookingRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private VenueRepository venueRepository; + + @Autowired + private SlotTemplateRepository slotTemplateRepository; + + @Test + void shouldPreventDoubleBooking() throws Exception { + + Venue venue = + venueRepository.findById(1L) + .orElseThrow(); + + SlotTemplate slotTemplate = + slotTemplateRepository.findById(1L) + .orElseThrow(); + + User user = + userRepository.findAll() + .getFirst(); + + LocalDate bookingDate = + LocalDate.of(2026, 6, 23); + + int threadCount = 100; + + ExecutorService executorService = + Executors.newFixedThreadPool(threadCount); + + CountDownLatch startLatch = + new CountDownLatch(1); + + CountDownLatch finishLatch = + new CountDownLatch(threadCount); + + AtomicInteger successCount = + new AtomicInteger(); + + AtomicInteger failureCount = + new AtomicInteger(); + + for (int i = 0; i < threadCount; i++) { + + executorService.submit(() -> { + + try { + + startLatch.await(); + + Booking booking = Booking.builder() + .user(user) + .venue(venue) + .slotTemplate(slotTemplate) + .bookingDate(bookingDate) + .status(BookingStatus.PENDING) + .totalAmount(venue.getPricePerSlot()) + .expiresAt( + LocalDateTime.now() + .plusMinutes(10) + ) + .build(); + + bookingRepository.saveAndFlush( + booking + ); + + successCount.incrementAndGet(); + + } catch (Exception e) { + + failureCount.incrementAndGet(); + + } finally { + + finishLatch.countDown(); + + } + }); + } + + startLatch.countDown(); + + finishLatch.await(); + + executorService.shutdown(); + + System.out.println( + "Success Count = " + + successCount.get() + ); + + System.out.println( + "Failure Count = " + + failureCount.get() + ); + + assertEquals( + 1, + successCount.get() + ); + } +} \ No newline at end of file diff --git a/server/src/test/java/com/bookmyvenue/server/ServerApplicationTests.java b/server/src/test/java/com/bookmyvenue/server/ServerApplicationTests.java new file mode 100644 index 000000000..2aa945b37 --- /dev/null +++ b/server/src/test/java/com/bookmyvenue/server/ServerApplicationTests.java @@ -0,0 +1,13 @@ +package com.bookmyvenue.server; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ServerApplicationTests { + + @Test + void contextLoads() { + } + +}