diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..1afce0ea9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Environment variables +backend/.env + +# Uploads +backend/uploads/ diff --git a/README.md b/README.md index 68d8b36b8..719d66615 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,485 @@ -# BookMyVenue (by WeCode) +# 🏰 BookMyVenue -BookMyVenue is a 100% open-source initiative built by the **WeCode community**, designed to simplify the process of finding and booking venues in local areas. This is a platform built for the community, by the community. Whether you're looking for a cozy cafe for a quick meetup, a spacious auditorium for a community event, or a scenic outdoor space for a personal celebration, BookMyVenue aims to connect people with the perfect spaceβ€”without the commercial overhead. +A premium, luxury-themed venue discovery, booking, and marketplace management system. Built using a sleek glassmorphic visual design, this full-stack application connects renters looking for exquisite event spaces with venue hosts, all supervised by a multi-role administrative platform. -## 🚧 The Problem +--- -Organizing an eventβ€”big or smallβ€”often comes with the hassle of finding a suitable location. Traditionally, people struggle with: +[![Vite](https://img.shields.io/badge/Frontend-React%20%2B%20Vite-blue?style=for-the-badge&logo=vite)](https://vite.dev) +[![Express](https://img.shields.io/badge/Backend-Express.js-black?style=for-the-badge&logo=express)](https://expressjs.com) +[![PostgreSQL](https://img.shields.io/badge/Database-PostgreSQL-blue?style=for-the-badge&logo=postgresql)](https://www.postgresql.org) +[![TailwindCSS](https://img.shields.io/badge/Styling-Tailwind%20CSS-38B2AC?style=for-the-badge&logo=tailwindcss)](https://tailwindcss.com) +[![Framer Motion](https://img.shields.io/badge/Animations-Framer%20Motion-F01F7A?style=for-the-badge&logo=framer)](https://www.framer.com/motion/) +[![MapLibre GL](https://img.shields.io/badge/Maps-MapLibre%20GL-lightgrey?style=for-the-badge&logo=mapbox)](https://maplibre.org/) +[![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)](LICENSE) -- **Fragmented Information:** Venue details, availability, and pricing are scattered across various websites or require time-consuming phone calls. -- **Lack of Transparency:** Hidden costs, unclear amenities, and outdated photos make it hard to trust what you are booking. -- **Time Inefficiency:** Manually comparing options, negotiating, and finalizing bookings is a tedious and frustrating process for organizers. -- **Underutilized Spaces:** Owners of small or unique spaces (like local cafes, art studios, or boutique halls) often lack the marketing reach to showcase their venues to a wider audience. +--- -## πŸ’‘ The Solution +## πŸ“– Table of Contents +1. [About & Overview](#-about--overview) +2. [Demo](#-demo) +3. [Key Features](#-key-features) +4. [System Architecture](#-system-architecture) +5. [Tech Stack](#-tech-stack) +6. [Database Schema](#-database-schema) +7. [Folder Structure](#-folder-structure) +8. [Getting Started](#-getting-started) + - [Prerequisites](#prerequisites) + - [Database Setup & Seeding](#1-database-setup--seeding) + - [Backend Setup](#2-backend-setup) + - [Frontend Setup](#3-frontend-setup) +9. [API Reference](#-api-reference) +10. [Usage Examples](#-usage-examples) +11. [Roadmap](#-roadmap) +12. [Contributing](#-contributing) +13. [License](#-license) +14. [Contact](#-contact) -BookMyVenue acts as a completely free and open bridge between space owners and the community, offering a seamless booking experience for everyone: +--- -- **Centralized Marketplace:** A single platform to discover a wide variety of venues, ranging from conventional banquet halls and auditoriums to unconventional spaces like cafes, studios, and mall pop-up spaces. -- **Real-Time Availability & Pricing:** Transparent access to schedules and upfront pricing so you can make informed decisions quickly without the back-and-forth. -- **Detailed Listings:** High-quality photos, comprehensive amenity lists (e.g., Wi-Fi, parking, AV equipment), and verified user reviews. -- **Simplified Booking Process:** Easy, hassle-free online reservations in just a few clicks. -- **Empowering Local Communities:** Providing small venue owners, community centers, and local spaces an open platform to manage their underutilized spaces and share them with the public. +## 🏰 About & Overview -## πŸ—ΊοΈ Project Roadmap +**BookMyVenue** is a state-of-the-art marketplace for renting premium event locations, mansions, banquet halls, and creative spaces. Featuring distinct portal dashboards tailored to **Customers (Renters)**, **Venue Owners (Hosts)**, and **Platform Administrators**, the system ensures a unified, safe booking flow. By combining MapLibre GL geolocation markers with PostgreSQL GiST exclusion logic, BookMyVenue guarantees that venue searches and schedule bookings are completely conflict-free. -We are building BookMyVenue iteratively through **4 Phases**: -- **Phase 1: MVP (Current):** Anyone can contribute in *any stack*. If it's good, we merge it! -- **Phase 2 & 3: Feature & Modularization:** Implementing features module by module and refining the architecture. -- **Phase 4: Scalability:** Building a fully scalable, cloud-native solution for high traffic. +--- -## 🀝 How to Contribute +## πŸ“Έ Demo -This is a collective effort, and everyone in the WeCode community shares the benefits of what we build! Whether you're a beginner or a pro, we would love your help. -Please check out our full [Contribution Guidelines](CONTRIBUTING.md) to learn how to: -- Fork and clone the repository. -- Create your feature branch (`feat/`). -- Submit a Pull Request. + +*Placeholder: Visual walkthrough of the glassmorphic desktop interface dashboard.* -### πŸ“ Pull Request Format -To maintain a high standard of code, all Pull Requests must use our [standard template](.github/PULL_REQUEST_TEMPLATE.md). When you open a PR, you will be prompted to: -1. **Select the Phase Category** (e.g., Phase 1 MVP). -2. **Document your Tech Stack** (Frontend, Backend, Database). -3. **Complete the Review Checklist** (Self-review, community standards). -4. **Sign the AI Disclosure** (Confirming you have reviewed any AI-generated code). -5. **Attach Screenshots** (If your PR includes UI changes). +--- -**BookMyVenue belongs to all of us. Join WeCode today and let's build something amazing together!** +## ✨ Key Features + +### πŸ‘€ For Customers (Renters) +* **Venue Discovery**: Browse handpicked locations using live search filters matching guest capacity, location, keywords, and booking formats. +* **Interactive Geolocation Map**: Powered by MapLibre GL and OpenStreetMap, showing listings on dynamic maps. +* **Flexible Bookings**: Lock in reservations on an **hourly** basis (with operating hour buffers) or a **daily** (overnight) basis. +* **Renter Calendar**: View availability in real-time, compute totals instantly, and confirm upcoming blocks. +* **Sleek Checkout Flow**: Scannable mock UPI QR payment gateway for booking guarantees, using Rupees (β‚Ή) currency. +* **My Bookings Gateway**: Check check-in instructions, dynamic secure access door codes, active payment statuses, or cancel future visits (enforces a sliding cancellation refund scale: 100%, 50%, or 10% based on how far in advance cancellation occurs). + +### 🏑 For Venue Owners (Hosts) +* **Owner Dashboard**: Real-time stats on listing counts, rental reservation listings, active customer contact lines, and earnings. +* **Visual Reservation Timelines**: Track layered, chronologically ordered booking records in a visual dashboard schedule component. +* **Listing Submissions**: Fill details including size metrics, guest thresholds, parking conditions, custom regulations, custom event category tags, hourly operating windows, and a map location picker. +* **Offline Locks & Maintenance Mode**: Blocks dates or hours for private events or offline repairs directly from the calendar, avoiding double bookings. +* **Media Upload Manager**: Upload venue preview photos locally via the media manager (stored locally via Node Multer). + +### πŸ›‘οΈ For Platform Administrators +* **Admin Dashboard KPIs**: Monitor active customer accounts, host listings, total platform transaction volumes, platform commission (10%), and host payout allocations (90%). +* **Verification Workflow**: Review newly submitted venue listings in a pending status. Approve or decline listings (with specific rejection reasons shown to the host). +* **Global Booking Auditor**: Review and inspect payment statuses and date range records across the entire marketplace. +* **Access Control**: Live-search and update details for all registered accounts. + +### ⚑ Resiliency & Fallback Mode +* **Hybrid Data Fallback**: The React client includes a failover fallback. If the PostgreSQL/Express server is unreachable, the client will **gracefully fall back to local browser storage** using pre-configured mock venue records ([venuesData.ts](file:///d:/1/BookMyVenue%20test2/src/data/venuesData.ts)) to allow demo presentations and offline testing. + +--- + +## πŸ—οΈ System Architecture + +BookMyVenue runs on a decoupled client-server architecture. Client requests are securely authenticated using JWT Bearer tokens. + +```mermaid +graph TD + A[React Web Client
Vite / Tailwind / MapLibre] <-->|HTTP JSON REST + JWT| B[Express.js Gateway
server.js] + B -->|Upload Requests| C[Multer Middleware] + B -->|Auth & Role Verification| D[authMiddleware.js] + C -->|Store Images| E[uploads/ Directory] + B -->|Database Operations| F[node-postgres Client] + F <-->|Queries & Exclusion Checks| G[(PostgreSQL DB)] + G -.->|GiST Constraint EXCLUDE| H[Prevent Overlap at DB Level] +``` + +### πŸ”’ Double-Booking Protection Logic +The system enforces strict conflict guards during transaction attempts: +1. **Operating Hour Enforcement**: For hourly listings, reservations must fall strictly within the venue's set `opening_time` and `closing_time`. +2. **Buffer/Cleaning Gaps**: Hosts can configure a buffer (e.g. 2-hour cleaning gaps) for hourly venues. Bookings are automatically padded with this gap to prevent back-to-back overlaps. +3. **Database-Level Guard**: The PostgreSQL table utilizes `btree_gist` and a `tsrange` exclusion constraint to prevent overlapping active blocks at the database engine level, guaranteeing data integrity. + +--- + +## πŸ› οΈ Tech Stack + +| Layer | Technology | Purpose | +| :--- | :--- | :--- | +| **Frontend** | React (v18.3) | Reactive components and dashboard interfaces | +| **Routing** | React Router (v7.1) | Declarative single-page routing and role-based guards | +| **Styling** | Tailwind CSS (v3.4) + Radix UI | Sleek glassmorphic design variables and responsive styling | +| **Animations** | Framer Motion (v12.4) | Micro-interactions, hover effects, and slide-in transition animations | +| **Maps** | MapLibre GL (v5.24) | Geolocation searches and map picking using OpenStreetMap tiles | +| **Backend** | Node.js + Express.js | Core API router and business validation layer | +| **Database** | PostgreSQL (v8.11 client) | Relational storage utilizing GIST exclusion indexes | +| **Auth** | JSON Web Tokens (JWT) + bcryptjs | Token-based security and password hashing | +| **Uploads** | Multer | local disk-based image storage | + +--- + +## πŸ—„οΈ Database Schema + +The database relies on three core tables. Run the definitions below or execute the automated setup script described in the [Getting Started](#-getting-started) section: + +```sql +-- Enable btree_gist extension for GiST exclusion constraints on scalar types +CREATE EXTENSION IF NOT EXISTS btree_gist; + +-- 1. Create Users Table +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'venue_owner', 'admin')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2. Create Venues Table +CREATE TABLE venues ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + location VARCHAR(255) NOT NULL, + full_address TEXT NOT NULL, + latitude NUMERIC(9, 6), + longitude NUMERIC(9, 6), + capacity INTEGER NOT NULL CHECK (capacity > 0), + square_feet INTEGER NOT NULL CHECK (square_feet > 0), + price_per_night NUMERIC(10, 2) NOT NULL CHECK (price_per_night >= 0), + host_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + host_type VARCHAR(100) DEFAULT 'Superhost', + rating NUMERIC(3, 2) DEFAULT 5.0 CHECK (rating >= 0 AND rating <= 5), + is_top_rated BOOLEAN DEFAULT FALSE, + date_range VARCHAR(100) DEFAULT 'Available', + parking TEXT, + catering TEXT, + images TEXT[] DEFAULT '{}', + amenities TEXT[] DEFAULT '{}', + rules TEXT[] DEFAULT '{}', + event_types TEXT[] DEFAULT '{}', + status VARCHAR(50) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'declined')), + rejection_reason TEXT, + booking_type VARCHAR(50) DEFAULT 'days' CHECK (booking_type IN ('days', 'hours')), + cleaning_gap INTEGER DEFAULT 0 CHECK (cleaning_gap >= 0), + opening_time VARCHAR(5) DEFAULT '08:00', + closing_time VARCHAR(5) DEFAULT '22:00', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Indexing for lookup speed optimization +CREATE INDEX idx_venues_status ON venues(status); +CREATE INDEX idx_venues_host_id ON venues(host_id); + +-- 3. Create Bookings Table +CREATE TABLE bookings ( + id VARCHAR(50) PRIMARY KEY, + venue_id INTEGER REFERENCES venues(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- Null for host offline locks + start_date TIMESTAMP NOT NULL, + end_date TIMESTAMP NOT NULL, + blocked_end_date TIMESTAMP NOT NULL, -- end_date + cleaning_gap (or end_date if daily) + cleaning_gap INTEGER DEFAULT 0 CHECK (cleaning_gap >= 0), + guests INTEGER DEFAULT 0 CHECK (guests >= 0), + total_price NUMERIC(10, 2) DEFAULT 0.00 CHECK (total_price >= 0), + status VARCHAR(50) DEFAULT 'upcoming' CHECK (status IN ('upcoming', 'cancelled', 'offline')), + payment_status VARCHAR(50) DEFAULT 'paid' CHECK (payment_status IN ('paid', 'refunded', 'offline')), + booking_date DATE DEFAULT CURRENT_DATE, + check_in_instructions TEXT, + renter_name VARCHAR(255), + renter_phone VARCHAR(50), + renter_email VARCHAR(255), + booking_type VARCHAR(50) DEFAULT 'days' CHECK (booking_type IN ('days', 'hours')), + refund_amount NUMERIC(10, 2) DEFAULT 0.00 CHECK (refund_amount >= 0), + refund_percentage INTEGER DEFAULT 0 CHECK (refund_percentage >= 0 AND refund_percentage <= 100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_booking_dates CHECK (end_date >= start_date), + CONSTRAINT chk_blocked_end CHECK (blocked_end_date >= end_date), + CONSTRAINT no_overlapping_bookings EXCLUDE USING gist ( + venue_id WITH =, + tsrange(start_date, blocked_end_date, '[)') WITH && + ) WHERE (status != 'cancelled') +); + +CREATE INDEX idx_bookings_venue_dates ON bookings(venue_id, start_date, end_date) WHERE status != 'cancelled'; +CREATE INDEX idx_bookings_user_id ON bookings(user_id); +``` + +--- + +## πŸ“‚ Folder Structure + +``` +β”œβ”€β”€ backend/ +β”‚ β”œβ”€β”€ controllers/ # Express handler logic (auth, venues, bookings, admin) +β”‚ β”œβ”€β”€ middleware/ # JWT authorization and validation middleware +β”‚ β”œβ”€β”€ routes/ # Express API endpoints +β”‚ β”œβ”€β”€ uploads/ # Locally uploaded files (gitignored) +β”‚ β”œβ”€β”€ db.js # pg Connection Pool initialization +β”‚ β”œβ”€β”€ reset-db.js # Database rebuild and seed utility +β”‚ β”œβ”€β”€ schema.sql # Database schema tables and constraints +β”‚ β”œβ”€β”€ server.js # Node backend entry point +β”‚ β”œβ”€β”€ .env.example # Template for environment variables +β”‚ └── package.json # Backend node packages and scripts +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ assets/ # Media and logo assets +β”‚ β”œβ”€β”€ components/ +β”‚ β”‚ β”œβ”€β”€ map/ # MapLibre wrapper components (LocationPicker, VenueMap, etc.) +β”‚ β”‚ β”œβ”€β”€ ui/ # Radix UI and visual design atoms (badge, button, cards) +β”‚ β”‚ └── Navbar.tsx # Universal application navigation header +β”‚ β”œβ”€β”€ data/ +β”‚ β”‚ └── venuesData.ts # Seed data fallback config +β”‚ β”œβ”€β”€ page/ # React router screen pages +β”‚ β”œβ”€β”€ index.css # Stylesheets with Tailwind and design tokens +β”‚ β”œβ”€β”€ App.tsx # React router structure and authentication route wrappers +β”‚ └── main.tsx # Frontend bootstrap file +β”œβ”€β”€ components.json # Shadcn/ui CLI configuration +β”œβ”€β”€ index.html # Main entry HTML document +β”œβ”€β”€ package.json # Root workspace packages and build configurations +β”œβ”€β”€ tailwind.config.js # Custom design spacing configurations +β”œβ”€β”€ vite.config.ts # Vite application packaging setup +└── tsconfig.json # Base typescript configurations +``` + +--- + +## πŸš€ Getting Started + +### Prerequisites +* **Node.js** (v18.x or newer) +* **PostgreSQL** database instance (v12+ recommended for GiST exclusion constraints) +* **NPM** (packaged with Node) + +--- + +### 1. Database Setup & Seeding + +1. Open your PostgreSQL query tool and create a new database: + ```sql + CREATE DATABASE bookmyvenue; + ``` +2. Navigate into the backend subdirectory: + ```bash + cd backend + ``` +3. Copy `.env.example` to `.env`: + ```bash + cp .env.example .env + ``` +4. Edit the new `.env` file with your PostgreSQL password and username: + ```env + PORT=5000 + DATABASE_URL=postgresql://your_postgres_username:your_postgres_password@localhost:5432/bookmyvenue + JWT_SECRET=your_super_secret_key_here + NODE_ENV=development + ``` +5. Install backend dependencies: + ```bash + npm install + ``` +6. Run the reset and seeding script. This drops any conflicting tables, compiles the SQL schemas, and sets up test users: + ```bash + node reset-db.js + ``` + +#### πŸ”‘ Seeded Test Accounts +The seeding script generates three preconfigured accounts representing each system role: + +| Role | Username / Email | Password | +| :--- | :--- | :--- | +| **Administrator** | `admin@gmail.com` | `test123` | +| **Venue Host** | `owner@gmail.com` | `test123` | +| **Standard User (Renter)** | `user@gmail.com` | `test123` | + +--- + +### 2. Running Backend Locally + +Run the development server in watch mode: +```bash +npm run dev +``` +The server will boot and listen at `http://localhost:5000`. You should see the following console confirmation: +``` +BookMyVenue backend server listening on port 5000 +API URL: http://localhost:5000/api +``` + +--- + +### 3. Running Frontend Locally + +1. Open a new terminal session in the **root project directory**. +2. Install frontend dependencies: + ```bash + npm install + ``` +3. Boot up the Vite developer build server: + ```bash + npm run dev + ``` +4. Open your browser and navigate to `http://localhost:5173`. + +> [!NOTE] +> The frontend is configured to call `http://localhost:5000/api` directly. If the backend is not running, the application will display fallback mock venue cards so you can test user flows offline. + +--- + +## πŸ”Œ API Reference + +All requests must be prefixed with `/api`. Authenticated requests require the Header `Authorization: Bearer `. + +
+πŸ” Click to Expand API Endpoints Reference + +### Authentication (`/api/auth`) +* `POST /signup` + * **Description**: Create a new account. + * **Payload**: `{ "name": "...", "email": "...", "password": "...", "role": "user" \| "venue_owner" }` + * **Auth Required**: None +* `POST /login` + * **Description**: Authenticate credentials and get authorization token. + * **Payload**: `{ "email": "...", "password": "..." }` + * **Auth Required**: None + +### Venue Operations (`/api/venues`) +* `GET /` + * **Description**: Fetch all approved venues. + * **Auth Required**: None +* `GET /my-venues` + * **Description**: Fetch venues registered under the authenticated host account. + * **Auth Required**: Yes (`venue_owner`) +* `GET /:id` + * **Description**: Retrieve deep specifications for a specific venue listing. + * **Auth Required**: None +* `GET /:id/bookings` + * **Description**: Retrieve active bookings associated with a specific venue. + * **Auth Required**: None +* `GET /:id/availability` + * **Description**: Retrieve booked schedules (or timeline slots if hourly) for a venue. Optional query `?date=YYYY-MM-DD`. + * **Auth Required**: None +* `POST /` + * **Description**: Submit a new venue for administrator validation. + * **Auth Required**: Yes (`venue_owner`) +* `PUT /:id` + * **Description**: Update an owned venue's details (resets verification status to `pending`). + * **Auth Required**: Yes (`venue_owner`) +* `DELETE /:id` + * **Description**: Remove a venue registration. + * **Auth Required**: Yes (`venue_owner`) + +### Booking Operations (`/api/bookings`) +* `GET /` + * **Description**: Fetch user reservations (renters see their bookings; hosts see their venues' reservations). + * **Auth Required**: Yes +* `GET /:id` + * **Description**: Get details for a specific booking transaction. + * **Auth Required**: Yes (Renter, Venue Host, or Admin) +* `POST /` + * **Description**: Create a new online reservation (validates overlaps and horizon limits). + * **Auth Required**: Yes +* `POST /lock` + * **Description**: Schedule an offline maintenance block or private reservation. + * **Auth Required**: Yes (`venue_owner`) +* `PUT /:id/cancel` + * **Description**: Mark a booking status as `cancelled` and issue payment refund logs. + * **Auth Required**: Yes + +### Media Upload (`/api/upload`) +* `POST /` + * **Description**: Upload a single image file. Returns a public file access URL. + * **Payload**: Multipart form data with key `image`. + * **Auth Required**: None + +### Admin Auditing (`/api/admin`) +* `GET /stats` + * **Description**: Fetch platform KPI variables (transaction volumes, payouts, counts). + * **Auth Required**: Yes (`admin`) +* `GET /venues` + * **Description**: List all database venue listings regardless of validation state. + * **Auth Required**: Yes (`admin`) +* `PUT /venues/:id/status` + * **Description**: Approve or reject a listing request. + * **Payload**: `{ "status": "approved" \| "declined", "rejectionReason": "..." }` + * **Auth Required**: Yes (`admin`) +* `GET /bookings` + * **Description**: Review all bookings recorded in the system. + * **Auth Required**: Yes (`admin`) +* `GET /users` + * **Description**: Retrieve a registry of all system users. + * **Auth Required**: Yes (`admin`) + +
+ +--- + +## πŸ’‘ Usage Examples + +### 1. User Sign In +To fetch a bearer token for a user: +```bash +curl -X POST http://localhost:5000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email": "user@gmail.com", "password": "test123"}' +``` + +### 2. Fetch Venues +Retrieve the catalog of approved event locations: +```bash +curl -X GET http://localhost:5000/api/venues +``` + +### 3. Create a Booking +Book a venue by passing dates, total price, guest count, and renter contact details (requires authentication token): +```bash +curl -X POST http://localhost:5000/api/bookings \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "venueId": 1, + "startDate": "2026-07-15T14:00:00", + "endDate": "2026-07-16T11:00:00", + "guests": 2, + "totalPrice": 250.00, + "renterName": "John Doe", + "renterPhone": "+1234567890", + "renterEmail": "user@gmail.com" + }' +``` + +--- + +## πŸ—ΊοΈ Roadmap + +- [x] Multi-role dashboard structures (User, Host, Admin) +- [x] Day-based (daily check-in/out) and Hour-based booking schedules +- [x] Cleaning buffers and customizable operating hours +- [x] MapLibre GL interactive maps and location selectors +- [x] Database-level double-booking protection using PostgreSQL GIST constraints +- [x] Interactive booking checkout with scannable UPI payments (guarantee mockup) and Indian Rupee (β‚Ή) currency support +- [x] Flexible cancellation policy and automatic sliding-scale refund tracking +- [ ] Real-time Socket.io chat messaging between renters and venue owners +- [ ] Direct checkout payments via Stripe integration +- [ ] Venue rating feedback loops and user reviews + +--- + +## 🀝 Contributing + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +--- + +## πŸ“„ License + +This project is licensed under the [MIT License](LICENSE). + +--- + +## βœ‰οΈ Contact + +* **Project Repository**: [BookMyVenue](https://github.com/karthikajay04/BookMyVenue) +* **Demo Enquiries**: admin@gmail.com diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 000000000..bd8181438 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,4 @@ +PORT=5000 +DATABASE_URL=postgresql://username:password@localhost:5432/database_name +JWT_SECRET=your_jwt_secret_here +NODE_ENV=development diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js new file mode 100644 index 000000000..2eb09431f --- /dev/null +++ b/backend/controllers/adminController.js @@ -0,0 +1,210 @@ +import { query } from '../db.js'; + + +/** + * @desc Get aggregate platform metrics (user/host/venue counts, platform earnings, volume) + * @route GET /api/admin/stats + * @access Private (Admin role only) + */ +export const getDashboardStats = async (req, res) => { + try { + // user counts + const usersCountRes = await query("SELECT COUNT(*)::int AS count FROM users WHERE role = 'user'"); + const hostsCountRes = await query("SELECT COUNT(*)::int AS count FROM users WHERE role = 'venue_owner'"); + + // venue counts + const venuesCountRes = await query("SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE status = 'pending')::int AS pending, COUNT(*) FILTER (WHERE status = 'approved')::int AS approved FROM venues"); + + // financialtransactions + const financialRes = await query(` + SELECT COALESCE(SUM(total_price), 0)::int AS "totalVolume" + FROM bookings + WHERE payment_status = 'paid' AND status != 'cancelled' + `); + + const totalVolume = financialRes.rows[0].totalVolume; + const platformFeePercentage = 10; + const platformEarnings = Math.round(totalVolume * (platformFeePercentage / 100)); + const hostEarnings = totalVolume - platformEarnings; + + res.json({ + success: true, + stats: { + totalUsers: usersCountRes.rows[0].count, + totalHosts: hostsCountRes.rows[0].count, + totalVenues: venuesCountRes.rows[0].total, + pendingVenues: venuesCountRes.rows[0].pending, + approvedVenues: venuesCountRes.rows[0].approved, + totalVolume, + platformFeePercentage, + platformEarnings, + hostEarnings + } + }); + } catch (error) { + console.error('Error fetching dashboard stats:', error); + res.status(500).json({ message: 'Error fetching admin dashboard stats' }); + } +}; + +/** + * @desc Get all venues across the platform for approval review + * @route GET /api/admin/venues + * @access Private (Admin role only) + */ +export const getAllVenues = async (req, res) => { + try { + const result = await query(` + SELECT v.*, u.name AS "hostName", u.email AS host_email + FROM venues v + LEFT JOIN users u ON v.host_id = u.id + ORDER BY v.id DESC + `); + + // Map database rows using same camelCase logic + const mappedVenues = result.rows.map(row => ({ + id: row.id.toString(), + title: row.title, + description: row.description, + location: row.location, + fullAddress: row.full_address, + latitude: row.latitude !== null && row.latitude !== undefined ? Number(row.latitude) : null, + longitude: row.longitude !== null && row.longitude !== undefined ? Number(row.longitude) : null, + capacity: Number(row.capacity), + squareFeet: Number(row.square_feet), + pricePerNight: Number(row.price_per_night), + hostEmail: row.host_email, + hostName: row.hostName || 'Unknown Host', + hostType: row.host_type, + rating: Number(row.rating), + isTopRated: row.is_top_rated, + dateRange: row.date_range, + parking: row.parking, + catering: row.catering, + images: row.images, + amenities: row.amenities, + rules: row.rules, + eventTypes: row.event_types, + status: row.status || 'pending', + rejectionReason: row.rejection_reason || '', + createdAt: row.created_at + })); + + res.json(mappedVenues); + } catch (error) { + console.error('Error fetching all venues:', error); + res.status(500).json({ message: 'Error fetching venues list' }); + } +}; + +/** + * @desc Approve or decline a pending venue listing + * @route PUT /api/admin/venues/:id/status + * @access Private (Admin role only) + */ +export const updateVenueStatus = async (req, res) => { + const { id } = req.params; + const { status, rejectionReason } = req.body; // 'approved' or 'declined' + + if (!status || !['approved', 'declined', 'pending'].includes(status)) { + return res.status(400).json({ message: 'Invalid status value. Must be approved, declined, or pending.' }); + } + + try { + const checkRes = await query('SELECT * FROM venues WHERE id = $1', [id]); + if (checkRes.rows.length === 0) { + return res.status(404).json({ message: 'Venue not found' }); + } + + let result; + if (status === 'declined') { + result = await query( + 'UPDATE venues SET status = $1, rejection_reason = $2 WHERE id = $3 RETURNING *', + [status, rejectionReason || '', id] + ); + } else { + result = await query( + 'UPDATE venues SET status = $1, rejection_reason = NULL WHERE id = $2 RETURNING *', + [status, id] + ); + } + + res.json({ + success: true, + message: `Venue status successfully updated to ${status}`, + venue: result.rows[0] + }); + } catch (error) { + console.error('Error updating venue status:', error); + res.status(500).json({ message: 'Error updating venue status' }); + } +}; + +/** + * @desc Get list of all bookings across the platform + * @route GET /api/admin/bookings + * @access Private (Admin role only) + */ +export const getAllBookings = async (req, res) => { + try { + const result = await query(` + SELECT + b.id, + b.venue_id AS "venueId", + v.title AS "venueTitle", + v.location AS "venueLocation", + v.full_address AS "fullAddress", + v.latitude, + v.longitude, + v.images[1] AS "venueImage", + b.start_date AS "startDate", + b.end_date AS "endDate", + b.guests, + b.total_price AS "totalPrice", + b.status, + b.booking_date AS "bookingDate", + b.payment_status AS "paymentStatus", + b.created_at AS "createdAt", + COALESCE(b.renter_name, u.name) AS "renterName", + COALESCE(b.renter_email, u.email) AS "renterEmail", + b.renter_phone AS "renterPhone", + h.name AS "hostName", + h.email AS "hostMail" + FROM bookings b + JOIN venues v ON b.venue_id = v.id + JOIN users h ON v.host_id = h.id + LEFT JOIN users u ON b.user_id = u.id + ORDER BY b.created_at DESC + `); + + const mappedBookings = result.rows.map(row => ({ + ...row, + latitude: row.latitude !== null && row.latitude !== undefined ? Number(row.latitude) : null, + longitude: row.longitude !== null && row.longitude !== undefined ? Number(row.longitude) : null, + })); + + res.json(mappedBookings); + } catch (error) { + console.error('Error fetching all bookings:', error); + res.status(500).json({ message: 'Error fetching bookings' }); + } +}; + +/** + * @desc Get list of all registered platform users and hosts + * @route GET /api/admin/users + * @access Private (Admin role only) + */ +export const getAllUsers = async (req, res) => { + try { + const result = await query(` + SELECT id, name, email, role, created_at AS "createdAt" + FROM users + ORDER BY role ASC, id ASC + `); + res.json(result.rows); + } catch (error) { + console.error('Error fetching all users:', error); + res.status(500).json({ message: 'Error fetching users list' }); + } +}; diff --git a/backend/controllers/authController.js b/backend/controllers/authController.js new file mode 100644 index 000000000..2c42d3dab --- /dev/null +++ b/backend/controllers/authController.js @@ -0,0 +1,122 @@ +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { query } from '../db.js'; + +/** + * Generates a signed JSON Web Token (JWT) containing user identifiers. + * The token has a standard expiration duration of 7 days. + * @param {number|string} id - The user database identifier + * @param {string} email - The user email address + * @param {string} role - The user authority role ('user', 'venue_owner', 'admin') + * @returns {string} Signed JWT token string + */ +const generateToken = (id, email, role) => { + return jwt.sign( + { id, email, role }, + process.env.JWT_SECRET, + { expiresIn: '7d' } + ); +}; + +/** + * @desc Register a new user profile with hashed credentials + * @route POST /api/auth/signup + * @access Public (Enforces lowercase emails, basic length limits, and restricts admin self-creation) + */ +export const registerUser = async (req, res) => { + const { name, email, password, role } = req.body; + + try { + // 1. Basic validation + if (!name || !email || !password) { + return res.status(400).json({ message: 'Please provide name, email, and password' }); + } + + if (password.length < 6) { + return res.status(400).json({ message: 'Password must be at least 6 characters long' }); + } + + // 2. Check if user already exists + const emailCheckResult = await query('SELECT * FROM users WHERE email = $1', [email.toLowerCase().trim()]); + if (emailCheckResult.rows.length > 0) { + return res.status(400).json({ message: 'A user with this email address already exists' }); + } + + // 3. Secure password hashing + const salt = await bcrypt.genSalt(15); + const hashedPassword = await bcrypt.hash(password, salt); + + // Validate and restrict role to prevent unauthorized admin creations + const userRole = (role === 'venue_owner' || role === 'user') ? role : 'user'; + + // 4. Insert new user into database + const insertResult = await query( + 'INSERT INTO users (name, email, password, role) VALUES ($1, $2, $3, $4) RETURNING id, name, email, role', + [name.trim(), email.toLowerCase().trim(), hashedPassword, userRole] + ); + + const newUser = insertResult.rows[0]; + + // 5. Generate and send JWT response + const token = generateToken(newUser.id, newUser.email, newUser.role); + + res.status(201).json({ + success: true, + user: { + name: newUser.name, + email: newUser.email, + role: newUser.role + }, + token + }); + } catch (error) { + console.error('Error during registration:', error); + res.status(500).json({ message: 'Server error during user registration. Please try again.' }); + } +}; + +/** + * @desc Authenticate user credentials and generate active session JWT token + * @route POST /api/auth/login + * @access Public (Validates email and bcrypt password match) + */ +export const loginUser = async (req, res) => { + const { email, password } = req.body; + + try { + // 1. Validation + if (!email || !password) { + return res.status(400).json({ message: 'Please provide email and password' }); + } + + // 2. Search for user by email + const userResult = await query('SELECT * FROM users WHERE email = $1', [email.toLowerCase().trim()]); + if (userResult.rows.length === 0) { + return res.status(401).json({ message: 'Invalid email or password' }); + } + + const user = userResult.rows[0]; + + // 3. Verify bcrypt-hashed password + const isPasswordCorrect = await bcrypt.compare(password, user.password); + if (!isPasswordCorrect) { + return res.status(401).json({ message: 'Invalid email or password' }); + } + + // 4. Generate JWT + const token = generateToken(user.id, user.email, user.role); + + res.json({ + success: true, + user: { + name: user.name, + email: user.email, + role: user.role + }, + token + }); + } catch (error) { + console.error('Error during login:', error); + res.status(500).json({ message: 'Server error during login. Please try again.' }); + } +}; diff --git a/backend/controllers/bookingController.js b/backend/controllers/bookingController.js new file mode 100644 index 000000000..ebe5275a7 --- /dev/null +++ b/backend/controllers/bookingController.js @@ -0,0 +1,727 @@ +import { query } from '../db.js'; + +/** + * @desc Get all bookings related to the authenticated user + * @route GET /api/bookings + * @access Private (User sees their own bookings; Venue Owner sees bookings for their venues) + */ +export const getBookings = async (req, res) => { + const { id, role } = req.user; + + try { + let result; + if (role === 'venue_owner') { + // Host sees bookings for their own venues + result = await query(` + SELECT + b.id, + b.venue_id AS "venueId", + v.title AS "venueTitle", + v.location AS "venueLocation", + v.full_address AS "fullAddress", + v.latitude, + v.longitude, + v.images[1] AS "venueImage", + b.start_date AS "startDate", + b.end_date AS "endDate", + b.guests, + b.total_price AS "totalPrice", + b.status, + b.booking_date AS "bookingDate", + b.payment_status AS "paymentStatus", + COALESCE(b.renter_name, u.name) AS "renterName", + COALESCE(b.renter_email, u.email) AS "renterEmail", + b.renter_phone AS "renterPhone", + h.name AS "hostName", + h.email AS "hostMail", + b.check_in_instructions AS "checkInInstructions", + v.booking_type AS "venueBookingType", + v.cleaning_gap AS "venueCleaningGap", + v.opening_time AS "venueOpeningTime", + v.closing_time AS "venueClosingTime", + b.booking_type AS "bookingType", + b.refund_amount AS "refundAmount", + b.refund_percentage AS "refundPercentage" + FROM bookings b + JOIN venues v ON b.venue_id = v.id + JOIN users h ON v.host_id = h.id + LEFT JOIN users u ON b.user_id = u.id + WHERE h.id = $1 + ORDER BY b.created_at DESC + `, [id]); + } else { + // Regular user sees bookings they have made + result = await query(` + SELECT + b.id, + b.venue_id AS "venueId", + v.title AS "venueTitle", + v.location AS "venueLocation", + v.full_address AS "fullAddress", + v.latitude, + v.longitude, + v.images[1] AS "venueImage", + b.start_date AS "startDate", + b.end_date AS "endDate", + b.guests, + b.total_price AS "totalPrice", + b.status, + b.booking_date AS "bookingDate", + b.payment_status AS "paymentStatus", + h.name AS "hostName", + h.email AS "hostMail", + b.check_in_instructions AS "checkInInstructions", + COALESCE(b.renter_name, u.name) AS "renterName", + b.renter_phone AS "renterPhone", + COALESCE(b.renter_email, u.email) AS "renterEmail", + v.booking_type AS "venueBookingType", + v.cleaning_gap AS "venueCleaningGap", + v.opening_time AS "venueOpeningTime", + v.closing_time AS "venueClosingTime", + b.booking_type AS "bookingType", + b.refund_amount AS "refundAmount", + b.refund_percentage AS "refundPercentage" + FROM bookings b + JOIN venues v ON b.venue_id = v.id + JOIN users h ON v.host_id = h.id + LEFT JOIN users u ON b.user_id = u.id + WHERE b.user_id = $1 + ORDER BY b.created_at DESC + `, [id]); + } + + const mappedBookings = result.rows.map(row => ({ + ...row, + latitude: row.latitude !== null && row.latitude !== undefined ? Number(row.latitude) : null, + longitude: row.longitude !== null && row.longitude !== undefined ? Number(row.longitude) : null, + })); + + res.json(mappedBookings); + } catch (error) { + console.error('Error fetching bookings:', error); + res.status(500).json({ message: 'Error fetching bookings' }); + } +}; + +/** + * @desc Create a new booking inquiry + * @route POST /api/bookings + * @access Private (User role only; enforces 30-day limits & operating hour constraints) + */ +export const createBooking = async (req, res) => { + const { venueId, startDate, endDate, guests, totalPrice, renterName, renterPhone, renterEmail } = req.body; + const user_id = req.user.id; + + try { + if (!venueId || !startDate || !endDate || !guests || !totalPrice || !renterName || !renterPhone || !renterEmail) { + return res.status(400).json({ message: 'Please provide all required booking fields (including contact name, phone, and email)' }); + } + + // Check if venue exists + const venueRes = await query('SELECT * FROM venues WHERE id = $1', [venueId]); + if (venueRes.rows.length === 0) { + return res.status(404).json({ message: 'Venue not found' }); + } + const venue = venueRes.rows[0]; + + // Validate 30-day booking limit for web bookings + const today = new Date(); + today.setHours(0, 0, 0, 0); + const start = new Date(startDate); + const end = new Date(endDate); + + if (venue.booking_type !== 'hours') { + start.setHours(0, 0, 0, 0); + end.setHours(12, 0, 0, 0); + } + + const maxAllowed = new Date(today); + maxAllowed.setDate(today.getDate() + 30); + maxAllowed.setHours(23, 59, 59, 999); + + if (start < today) { + return res.status(400).json({ message: 'Booking time cannot be in the past.' }); + } + if (end <= start) { + return res.status(400).json({ message: 'End time must be after start time.' }); + } + if (start > maxAllowed || end > maxAllowed) { + const formattedLimit = maxAllowed.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + return res.status(400).json({ + message: `Web bookings are only allowed for dates within 30 days from today (up to ${formattedLimit}). Please contact the host directly to make an offline booking.` + }); + } + + // Operating hours check for hours-based booking + if (venue.booking_type === 'hours') { + const getMinutesOfDay = (d) => d.getHours() * 60 + d.getMinutes(); + const parseTimeStr = (tStr) => { + if (!tStr) return 0; + const [h, m] = tStr.split(':').map(Number); + return h * 60 + m; + }; + + const startMinutes = getMinutesOfDay(start); + const endMinutes = getMinutesOfDay(end); + const openMinutes = parseTimeStr(venue.opening_time || '08:00'); + const closeMinutes = parseTimeStr(venue.closing_time || '22:00'); + + if (startMinutes < openMinutes || endMinutes > closeMinutes) { + return res.status(400).json({ + message: `Booking must be within operating hours: ${venue.opening_time || '08:00'} - ${venue.closing_time || '22:00'}.` + }); + } + } + + // Schedule overlap conflict validation + const existingBookings = await query(` + SELECT start_date, end_date, blocked_end_date FROM bookings + WHERE venue_id = $1 AND status != 'cancelled' + `, [venueId]); + + const cleaningGap = Number(venue.cleaning_gap || 0); + let blockedEndDate; + if (venue.booking_type === 'hours') { + blockedEndDate = new Date(end.getTime() + cleaningGap * 60 * 60 * 1000); + } else { + blockedEndDate = new Date(end.getTime() + 12 * 60 * 60 * 1000 + cleaningGap * 24 * 60 * 60 * 1000); + } + + const hasOverlap = existingBookings.rows.some(b => { + const bStart = new Date(b.start_date); + const bEnd = new Date(b.end_date); + const bBlockedEnd = new Date(b.blocked_end_date); + + if (venue.booking_type === 'hours') { + const gapHours = Number(venue.cleaning_gap || 0); + const limitNewEnd = new Date(end.getTime() + gapHours * 60 * 60 * 1000); + const limitExistingEnd = new Date(bEnd.getTime() + gapHours * 60 * 60 * 1000); + + return start < limitExistingEnd && bStart < limitNewEnd; + } else { + return start < bBlockedEnd && bStart < blockedEndDate; + } + }); + + if (hasOverlap) { + return res.status(400).json({ + message: venue.booking_type === 'hours' + ? 'The selected time range conflicts with an existing booking or its cleaning gap block.' + : 'The selected dates conflict with an existing booking.' + }); + } + + // Generate BKG-XXXX ID + const randomNum = Math.floor(1000 + Math.random() * 9000); + const bookingId = `BKG-${randomNum}`; + + const checkInCode = Math.floor(1000 + Math.random() * 9000); + const checkInInstructions = venue.booking_type === 'hours' + ? `Secure entry code: #${checkInCode}. Welcome to ${venue.title}! Please check in and out according to your reserved hours.` + : `Secure entry code: #${checkInCode}. Welcome to ${venue.title}! Check-in starts at 12:00 AM (midnight) on your start date, and check-out is by 12:00 PM (noon) on your end date.`; + + try { + await query('BEGIN'); + + const result = await query(` + INSERT INTO bookings ( + id, venue_id, user_id, start_date, end_date, blocked_end_date, cleaning_gap, guests, total_price, status, payment_status, check_in_instructions, renter_name, renter_phone, renter_email, booking_type + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING * + `, [ + bookingId, venueId, user_id, start, end, blockedEndDate, cleaningGap, guests, totalPrice, 'upcoming', 'paid', checkInInstructions, renterName, renterPhone, renterEmail, venue.booking_type || 'days' + ]); + + await query('COMMIT'); + + res.status(201).json({ + success: true, + booking: result.rows[0] + }); + } catch (dbError) { + await query('ROLLBACK'); + if (dbError.code === '23P01') { + return res.status(409).json({ message: 'This venue has just been booked by another customer.' }); + } + throw dbError; + } + } catch (error) { + console.error('Error creating booking:', error); + res.status(500).json({ message: 'Error creating booking' }); + } +}; + +/** + * @desc Cancel an existing booking and calculate refund based on time-to-event rules + * @route PUT /api/bookings/:id/cancel + * @access Private (User or Venue Owner) + */ +export const cancelBooking = async (req, res) => { + const { id } = req.params; + const { id: userId, role } = req.user; + + try { + // Check if booking exists + const bookingRes = await query('SELECT * FROM bookings WHERE id = $1', [id]); + if (bookingRes.rows.length === 0) { + return res.status(404).json({ message: 'Booking not found' }); + } + const booking = bookingRes.rows[0]; + + // Verify user owns the booking or the venue being booked + if (role === 'venue_owner') { + const venueRes = await query('SELECT host_id FROM venues WHERE id = $1', [booking.venue_id]); + if (venueRes.rows.length === 0 || venueRes.rows[0].host_id !== userId) { + return res.status(403).json({ message: 'Not authorized to cancel bookings for this venue' }); + } + } else if (booking.user_id !== userId) { + return res.status(403).json({ message: 'Not authorized to cancel this booking' }); + } + + if (booking.status === 'cancelled') { + return res.status(400).json({ message: 'Booking is already cancelled' }); + } + + const today = new Date(); + const startDate = new Date(booking.start_date); + + if (startDate < today) { + return res.status(400).json({ message: 'Cannot cancel a booking that has already started' }); + } + + // Calculate remaining time until start_date + const diffTime = startDate.getTime() - today.getTime(); + const daysRemaining = diffTime / (1000 * 60 * 60 * 24); + + let refundPercentage = 0; + let refundAmount = 0.00; + let paymentStatus = booking.payment_status; + + if (booking.status === 'offline' || booking.payment_status === 'offline') { + refundPercentage = 0; + refundAmount = 0.00; + paymentStatus = 'offline'; + } else { + if (booking.booking_type === 'hours') { + const hoursRemaining = diffTime / (1000 * 60 * 60); + if (hoursRemaining >= 36) { + refundPercentage = 100; + refundAmount = Number(booking.total_price); + paymentStatus = 'refunded'; + } else if (hoursRemaining >= 24) { + refundPercentage = 50; + refundAmount = Number(booking.total_price) * 0.5; + paymentStatus = 'refunded'; + } else if (hoursRemaining >= 6) { + refundPercentage = 10; + refundAmount = Number(booking.total_price) * 0.10; + paymentStatus = 'refunded'; + } else { + refundPercentage = 0; + refundAmount = 0.00; + paymentStatus = 'paid'; + } + } else { + if (daysRemaining >= 10) { + refundPercentage = 100; + refundAmount = Number(booking.total_price); + paymentStatus = 'refunded'; + } else if (daysRemaining >= 3) { + refundPercentage = 50; + refundAmount = Number(booking.total_price) * 0.5; + paymentStatus = 'refunded'; + } else { + refundPercentage = 0; + refundAmount = 0.00; + paymentStatus = 'paid'; + } + } + } + + // Update status to cancelled and refund payment + const result = await query(` + UPDATE bookings + SET + status = 'cancelled', + payment_status = $2, + refund_amount = $3, + refund_percentage = $4 + WHERE id = $1 + RETURNING * + `, [id, paymentStatus, refundAmount, refundPercentage]); + + res.json({ + success: true, + booking: result.rows[0], + refundAmount, + refundPercentage + }); + } catch (error) { + console.error('Error cancelling booking:', error); + res.status(500).json({ message: 'Error cancelling booking' }); + } +}; + +/** + * @desc Lock a venue for offline bookings, private events, or maintenance + * @route POST /api/bookings/lock + * @access Private (Venue Owner only) + */ +export const lockVenue = async (req, res) => { + const { venueId, startDate, endDate, notes, totalPrice, guests, renterName, renterPhone, renterEmail } = req.body; + const host_id = req.user.id; + + try { + if (!venueId || !startDate || !endDate || !renterName || !renterPhone || !renterEmail) { + return res.status(400).json({ message: 'Please provide venueId, startDate, endDate, renterName, renterPhone, and renterEmail' }); + } + + // Check if venue exists and is owned by this host + const venueRes = await query('SELECT * FROM venues WHERE id = $1', [venueId]); + if (venueRes.rows.length === 0) { + return res.status(404).json({ message: 'Venue not found' }); + } + const venue = venueRes.rows[0]; + if (venue.host_id !== host_id) { + return res.status(403).json({ message: 'Not authorized to lock this venue' }); + } + + // Validate locking dates are not in the past + const today = new Date(); + today.setHours(0, 0, 0, 0); + const start = new Date(startDate); + const end = new Date(endDate); + + if (venue.booking_type !== 'hours') { + start.setHours(0, 0, 0, 0); + end.setHours(12, 0, 0, 0); + } + + if (start < today) { + return res.status(400).json({ message: 'Lock start date cannot be in the past.' }); + } + if (end <= start) { + return res.status(400).json({ message: 'Unlock date must be after lock start date.' }); + } + + // Operating hours check for hours-based booking + if (venue.booking_type === 'hours') { + const getMinutesOfDay = (d) => d.getHours() * 60 + d.getMinutes(); + const parseTimeStr = (tStr) => { + if (!tStr) return 0; + const [h, m] = tStr.split(':').map(Number); + return h * 60 + m; + }; + + const startMinutes = getMinutesOfDay(start); + const endMinutes = getMinutesOfDay(end); + const openMinutes = parseTimeStr(venue.opening_time || '08:00'); + const closeMinutes = parseTimeStr(venue.closing_time || '22:00'); + + if (startMinutes < openMinutes || endMinutes > closeMinutes) { + return res.status(400).json({ + message: `Booking must be within operating hours: ${venue.opening_time || '08:00'} - ${venue.closing_time || '22:00'}.` + }); + } + } + + // Schedule overlap conflict validation + const existingBookings = await query(` + SELECT start_date, end_date, blocked_end_date FROM bookings + WHERE venue_id = $1 AND status != 'cancelled' + `, [venueId]); + + const cleaningGap = Number(venue.cleaning_gap || 0); + let blockedEndDate; + if (venue.booking_type === 'hours') { + blockedEndDate = new Date(end.getTime() + cleaningGap * 60 * 60 * 1000); + } else { + blockedEndDate = new Date(end.getTime() + 12 * 60 * 60 * 1000 + cleaningGap * 24 * 60 * 60 * 1000); + } + + const hasOverlap = existingBookings.rows.some(b => { + const bStart = new Date(b.start_date); + const bEnd = new Date(b.end_date); + const bBlockedEnd = new Date(b.blocked_end_date); + + if (venue.booking_type === 'hours') { + const gapHours = Number(venue.cleaning_gap || 0); + const limitNewEnd = new Date(end.getTime() + gapHours * 60 * 60 * 1000); + const limitExistingEnd = new Date(bEnd.getTime() + gapHours * 60 * 60 * 1000); + + return start < limitExistingEnd && bStart < limitNewEnd; + } else { + return start < bBlockedEnd && bStart < blockedEndDate; + } + }); + + if (hasOverlap) { + return res.status(400).json({ + message: venue.booking_type === 'hours' + ? 'The selected time range conflicts with an existing booking or its cleaning gap block.' + : 'The selected dates conflict with an existing booking.' + }); + } + + // Generate OFF-XXXX ID + const randomNum = Math.floor(1000 + Math.random() * 9000); + const bookingId = `OFF-${randomNum}`; + + const checkInInstructions = notes || 'Venue locked for offline event or maintenance.'; + + try { + await query('BEGIN'); + + const result = await query(` + INSERT INTO bookings ( + id, venue_id, user_id, start_date, end_date, blocked_end_date, cleaning_gap, guests, total_price, status, payment_status, check_in_instructions, renter_name, renter_phone, renter_email, booking_type + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING * + `, [ + bookingId, venueId, null, start, end, blockedEndDate, cleaningGap, guests || 0, totalPrice || 0, 'offline', 'offline', checkInInstructions, renterName, renterPhone, renterEmail, venue.booking_type || 'days' + ]); + + await query('COMMIT'); + + res.status(201).json({ + success: true, + booking: result.rows[0] + }); + } catch (dbError) { + await query('ROLLBACK'); + if (dbError.code === '23P01') { + return res.status(409).json({ message: 'This venue has just been booked by another customer.' }); + } + throw dbError; + } + } catch (error) { + console.error('Error locking venue:', error); + res.status(500).json({ message: 'Error locking venue' }); + } +}; + +/** + * @desc Get non-cancelled bookings of a specific venue for calendar scheduling indicators + * @route GET /api/venues/:id/bookings + * @access Public + */ +export const getVenueBookings = async (req, res) => { + const { id } = req.params; + try { + const result = await query(` + SELECT + b.id, + b.start_date AS "startDate", + b.end_date AS "endDate", + b.status, + b.booking_type AS "bookingType" + FROM bookings b + WHERE b.venue_id = $1 AND b.status != 'cancelled' + ORDER BY b.start_date ASC + `, [id]); + res.json(result.rows); + } catch (error) { + console.error('Error fetching venue bookings:', error); + res.status(500).json({ message: 'Error fetching venue bookings' }); + } +}; + +/** + * @desc Fetch venue availability timelines (hours-based slots or daily blocked dates) + * @route GET /api/venues/:id/availability + * @access Public + */ +export const getVenueAvailability = async (req, res) => { + const { id } = req.params; + try { + // 1. Fetch venue details to see if it's days-based or hours-based + const venueRes = await query('SELECT id, booking_type, cleaning_gap, opening_time, closing_time FROM venues WHERE id = $1', [id]); + if (venueRes.rows.length === 0) { + return res.status(404).json({ message: 'Venue not found' }); + } + const venue = venueRes.rows[0]; + const isHours = venue.booking_type === 'hours'; + + if (isHours) { + // Hours-based availability check + let { date } = req.query; + if (!date) { + const today = new Date(); + const pad = (num) => String(num).padStart(2, '0'); + date = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`; + } + + // Generate timeline slots for operating hours + const parseTimeStr = (tStr) => { + if (!tStr) return 0; + const [h, m] = tStr.split(':').map(Number); + return h * 60 + m; + }; + + const startMin = parseTimeStr(venue.opening_time || '08:00'); + const endMin = parseTimeStr(venue.closing_time || '22:00'); + const slots = []; + for (let min = startMin; min + 60 <= endMin; min += 60) { + const sh = Math.floor(min / 60); + const sm = min % 60; + const eh = Math.floor((min + 60) / 60); + const em = (min + 60) % 60; + const startStr = `${String(sh).padStart(2, '0')}:${String(sm).padStart(2, '0')}`; + const endStr = `${String(eh).padStart(2, '0')}:${String(em).padStart(2, '0')}`; + slots.push({ start: startStr, end: endStr }); + } + + // Fetch bookings that touch the requested date + const bookingsRes = await query(` + SELECT start_date, end_date, blocked_end_date + FROM bookings + WHERE venue_id = $1 + AND status != 'cancelled' + AND start_date < $2 + AND end_date > $3 + `, [id, `${date} 23:59:59`, `${date} 00:00:00`]); + + const combineDateAndHour = (dateStr, hourStr) => { + return new Date(`${dateStr}T${hourStr}:00`); + }; + + const slotsWithStatus = slots.map(slot => { + const slotStart = combineDateAndHour(date, slot.start); + const slotEnd = combineDateAndHour(date, slot.end); + let status = 'available'; + + for (const b of bookingsRes.rows) { + const bStart = new Date(b.start_date); + const bEnd = new Date(b.end_date); + const bBlockedEnd = new Date(b.blocked_end_date); + + if (slotStart < bEnd && bStart < slotEnd) { + status = 'booked'; + break; + } + if (slotStart < bBlockedEnd && bEnd <= slotStart) { + status = 'cleaning'; + break; + } + } + + return { ...slot, status }; + }); + + return res.json({ + bookingType: 'hours', + slots: slotsWithStatus + }); + + } else { + // Days-based availability check + // Query all bookings for this venue + const bookingsRes = await query(` + SELECT start_date, blocked_end_date, status + FROM bookings + WHERE venue_id = $1 AND status != 'cancelled' + `, [id]); + + const unavailableDates = []; + const getLocalDateStr = (d) => { + const pad = (num) => String(num).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + }; + + for (const b of bookingsRes.rows) { + const start = new Date(b.start_date); + const blockedEnd = new Date(b.blocked_end_date); + + // Loop from start date up to blocked_end_date - 1 day (checkout is allowed on the checkout day) + for (let d = new Date(start); d < blockedEnd; d.setDate(d.getDate() + 1)) { + unavailableDates.push(getLocalDateStr(d)); + } + } + + // Deduplicate the array + const uniqueUnavailableDates = [...new Set(unavailableDates)]; + + return res.json({ + bookingType: 'days', + unavailableDates: uniqueUnavailableDates + }); + } + + } catch (error) { + console.error('Error fetching venue availability:', error); + res.status(500).json({ message: 'Error fetching venue availability' }); + } +}; + +/** + * @desc Get detailed parameters for a single booking by ID + * @route GET /api/bookings/:id + * @access Private (Owner, Renter, or Admin only) + */ +export const getBookingById = async (req, res) => { + const { id } = req.params; + const { id: userId, role } = req.user; + + try { + const result = await query(` + SELECT + b.id, + b.venue_id AS "venueId", + v.title AS "venueTitle", + v.location AS "venueLocation", + v.full_address AS "fullAddress", + v.latitude, + v.longitude, + v.images[1] AS "venueImage", + b.start_date AS "startDate", + b.end_date AS "endDate", + b.guests, + b.total_price AS "totalPrice", + b.status, + b.booking_date AS "bookingDate", + b.payment_status AS "paymentStatus", + COALESCE(b.renter_name, u.name) AS "renterName", + COALESCE(b.renter_email, u.email) AS "renterEmail", + b.renter_phone AS "renterPhone", + h.name AS "hostName", + h.email AS "hostMail", + b.check_in_instructions AS "checkInInstructions", + v.booking_type AS "venueBookingType", + v.cleaning_gap AS "venueCleaningGap", + v.opening_time AS "venueOpeningTime", + v.closing_time AS "venueClosingTime", + b.booking_type AS "bookingType", + b.user_id AS "userId", + v.host_id AS "hostId", + b.refund_amount AS "refundAmount", + b.refund_percentage AS "refundPercentage" + FROM bookings b + JOIN venues v ON b.venue_id = v.id + JOIN users h ON v.host_id = h.id + LEFT JOIN users u ON b.user_id = u.id + WHERE b.id = $1 + `, [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ message: 'Booking not found' }); + } + + const booking = result.rows[0]; + + // Authorization check + if (role !== 'admin' && booking.userId !== userId && booking.hostId !== userId) { + return res.status(403).json({ message: 'Not authorized to view this booking' }); + } + + const mappedBooking = { + ...booking, + latitude: booking.latitude !== null && booking.latitude !== undefined ? Number(booking.latitude) : null, + longitude: booking.longitude !== null && booking.longitude !== undefined ? Number(booking.longitude) : null, + }; + + res.json(mappedBooking); + } catch (error) { + console.error('Error fetching booking details:', error); + res.status(500).json({ message: 'Error fetching booking details' }); + } +}; diff --git a/backend/controllers/venueController.js b/backend/controllers/venueController.js new file mode 100644 index 000000000..1c73acaf2 --- /dev/null +++ b/backend/controllers/venueController.js @@ -0,0 +1,320 @@ +import { query } from '../db.js'; + +/** + * Helper to map standard database snake_case columns to camelCase variables + * and cast numeric Postgres types correctly to prevent frontend math bugs. + * @param {object} row - The database row returned by pg client + * @returns {object|null} CamelCased and formatted venue object + */ +const mapVenueRow = (row) => { + if (!row) return null; + return { + id: row.id.toString(), // Normalize numeric ID to string to match frontend types + title: row.title, + description: row.description, + location: row.location, + fullAddress: row.full_address, + latitude: row.latitude !== null && row.latitude !== undefined ? Number(row.latitude) : null, + longitude: row.longitude !== null && row.longitude !== undefined ? Number(row.longitude) : null, + capacity: Number(row.capacity), + squareFeet: Number(row.square_feet), + pricePerNight: Number(row.price_per_night), + hostEmail: row.host_email, + hostType: row.host_type, + rating: Number(row.rating), + isTopRated: row.is_top_rated, + dateRange: row.date_range, + parking: row.parking, + catering: row.catering, + images: row.images, + amenities: row.amenities, + rules: row.rules, + eventTypes: row.event_types, + status: row.status || 'pending', + rejectionReason: row.rejection_reason || '', + bookingType: row.booking_type || 'days', + cleaningGap: Number(row.cleaning_gap || 0), + openingTime: row.opening_time || '08:00', + closingTime: row.closing_time || '22:00', + tags: [ + row.location, + `${row.capacity} Guests`, + row.is_top_rated ? 'Luxury' : 'Handpicked' + ] + }; +}; + +/** + * @desc Get all approved venues with optional location and capacity filters + * @route GET /api/venues + * @access Public + */ +export const getVenues = async (req, res) => { + try { + const { location, capacity } = req.query; + let queryText = ` + SELECT v.*, u.email AS host_email + FROM venues v + LEFT JOIN users u ON v.host_id = u.id + `; + const params = []; + + const conditions = ["v.status = 'approved'"]; + if (location && location !== 'All') { + params.push(location); + conditions.push(`v.location = $${params.length}`); + } + + if (capacity && capacity !== 'All') { + const capNum = parseInt(capacity, 10); + if (!isNaN(capNum)) { + if (capNum === 10) { + conditions.push('v.capacity <= 10'); + } else if (capNum === 100) { + conditions.push('v.capacity > 10 AND v.capacity <= 100'); + } else if (capNum === 1000) { + conditions.push('v.capacity >= 1000'); + } + } + } + + if (conditions.length > 0) { + queryText += ' WHERE ' + conditions.join(' AND '); + } + + // Sort by id or title by default + queryText += ' ORDER BY v.id ASC'; + + const result = await query(queryText, params); + res.json(result.rows.map(mapVenueRow)); + } catch (error) { + console.error('Error fetching venues:', error); + res.status(500).json({ message: 'Error fetching venues' }); + } +}; + +/** + * @desc Get detailed fields for a single venue by its ID + * @route GET /api/venues/:id + * @access Public + */ +export const getVenueById = async (req, res) => { + const { id } = req.params; + try { + const result = await query(` + SELECT v.*, u.email AS host_email + FROM venues v + LEFT JOIN users u ON v.host_id = u.id + WHERE v.id = $1 + `, [id]); + if (result.rows.length === 0) { + return res.status(404).json({ message: 'Venue not found' }); + } + res.json(mapVenueRow(result.rows[0])); + } catch (error) { + console.error('Error fetching venue details:', error); + res.status(500).json({ message: 'Error fetching venue details' }); + } +}; + +/** + * @desc Create a new venue listing and set its status to pending approval + * @route POST /api/venues + * @access Private (Venue Owner only) + */ +export const createVenue = async (req, res) => { + const { + title, description, location, full_address, capacity, square_feet, price_per_night, + host_type, rating, is_top_rated, date_range, parking, catering, + images, amenities, rules, event_types, booking_type, cleaning_gap, opening_time, closing_time, + latitude, longitude + } = req.body; + + const host_id = req.user.id; + const host_email = req.user.email; + + try { + if (!title || !description || !location || !full_address || !capacity || !square_feet || !price_per_night) { + return res.status(400).json({ message: 'Please provide all required fields' }); + } + + const result = await query(` + INSERT INTO venues ( + title, description, location, full_address, latitude, longitude, capacity, square_feet, price_per_night, + host_id, host_type, rating, is_top_rated, date_range, parking, catering, + images, amenities, rules, event_types, status, booking_type, cleaning_gap, opening_time, closing_time + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25) + RETURNING * + `, [ + title, description, location, full_address, + latitude !== undefined && latitude !== null ? Number(latitude) : null, + longitude !== undefined && longitude !== null ? Number(longitude) : null, + capacity, square_feet, price_per_night, + host_id, host_type || 'Superhost', rating || 5.0, is_top_rated || false, date_range || 'Available', parking || '', catering || '', + images || [], amenities || [], rules || [], event_types || [], 'pending', + booking_type || 'days', Number(cleaning_gap || 0), opening_time || '08:00', closing_time || '22:00' + ]); + + const newVenue = result.rows[0]; + newVenue.host_email = host_email; + + res.status(201).json({ + success: true, + venue: mapVenueRow(newVenue) + }); + } catch (error) { + console.error('Error creating venue:', error); + res.status(500).json({ message: 'Error creating venue' }); + } +}; + +/** + * @desc Get all venues owned by the currently authenticated host + * @route GET /api/venues/my-venues + * @access Private (Venue Owner only) + */ +export const getMyVenues = async (req, res) => { + const host_id = req.user.id; + try { + const result = await query(` + SELECT v.*, u.email AS host_email + FROM venues v + LEFT JOIN users u ON v.host_id = u.id + WHERE v.host_id = $1 + ORDER BY v.id ASC + `, [host_id]); + res.json(result.rows.map(mapVenueRow)); + } catch (error) { + console.error('Error fetching host venues:', error); + res.status(500).json({ message: 'Error fetching host venues' }); + } +}; + +/** + * @desc Update details for a venue owned by the authenticated host and reset its status to pending + * @route PUT /api/venues/:id + * @access Private (Venue Owner only) + */ +export const updateVenue = async (req, res) => { + const { id } = req.params; + const host_id = req.user.id; + const host_email = req.user.email; + + const title = req.body.title; + const description = req.body.description; + const location = req.body.location; + const full_address = req.body.fullAddress || req.body.full_address; + const capacity = req.body.capacity; + const square_feet = req.body.squareFeet || req.body.square_feet; + const price_per_night = req.body.pricePerNight || req.body.price_per_night; + const host_type = req.body.hostType || req.body.host_type; + const is_top_rated = req.body.isTopRated !== undefined ? req.body.isTopRated : req.body.is_top_rated; + const date_range = req.body.dateRange || req.body.date_range; + const parking = req.body.parking; + const catering = req.body.catering; + const images = req.body.images; + const amenities = req.body.amenities; + const rules = req.body.rules; + const event_types = req.body.eventTypes || req.body.event_types; + const booking_type = req.body.bookingType || req.body.booking_type; + const cleaning_gap = req.body.cleaningGap !== undefined ? req.body.cleaningGap : req.body.cleaning_gap; + const opening_time = req.body.openingTime || req.body.opening_time; + const closing_time = req.body.closingTime || req.body.closing_time; + const latitude = req.body.latitude; + const longitude = req.body.longitude; + + try { + // Check ownership + const checkRes = await query('SELECT * FROM venues WHERE id = $1', [id]); + if (checkRes.rows.length === 0) { + return res.status(404).json({ message: 'Venue not found' }); + } + if (checkRes.rows[0].host_id !== host_id) { + return res.status(403).json({ message: 'Not authorized to update this venue' }); + } + + if (!title || !description || !location || !full_address || !capacity || !square_feet || !price_per_night) { + return res.status(400).json({ message: 'Please provide all required fields' }); + } + + const result = await query(` + UPDATE venues SET + title = $1, + description = $2, + location = $3, + full_address = $4, + capacity = $5, + square_feet = $6, + price_per_night = $7, + host_type = $8, + is_top_rated = $9, + date_range = $10, + parking = $11, + catering = $12, + images = $13, + amenities = $14, + rules = $15, + event_types = $16, + status = 'pending', + rejection_reason = NULL, + booking_type = $17, + cleaning_gap = $18, + opening_time = $19, + closing_time = $20, + latitude = $21, + longitude = $22 + WHERE id = $23 AND host_id = $24 + RETURNING * + `, [ + title, description, location, full_address, Number(capacity), Number(square_feet), Number(price_per_night), + host_type || 'Superhost', is_top_rated || false, date_range || 'Available', parking || '', catering || '', + images || [], amenities || [], rules || [], event_types || [], + booking_type || 'days', Number(cleaning_gap || 0), opening_time || '08:00', closing_time || '22:00', + latitude !== undefined && latitude !== null ? Number(latitude) : null, + longitude !== undefined && longitude !== null ? Number(longitude) : null, + id, host_id + ]); + + const updatedVenue = result.rows[0]; + updatedVenue.host_email = host_email; + + res.json({ + success: true, + venue: mapVenueRow(updatedVenue) + }); + } catch (error) { + console.error('Error updating venue:', error); + res.status(500).json({ message: 'Error updating venue' }); + } +}; + +/** + * @desc Delete a venue listing from the platform database + * @route DELETE /api/venues/:id + * @access Private (Venue Owner only) + */ +export const deleteVenue = async (req, res) => { + const { id } = req.params; + const host_id = req.user.id; + + try { + // Check ownership + const checkRes = await query('SELECT * FROM venues WHERE id = $1', [id]); + if (checkRes.rows.length === 0) { + return res.status(404).json({ message: 'Venue not found' }); + } + if (checkRes.rows[0].host_id !== host_id) { + return res.status(403).json({ message: 'Not authorized to delete this venue' }); + } + + await query('DELETE FROM venues WHERE id = $1 AND host_id = $2', [id, host_id]); + res.json({ + success: true, + message: 'Venue deleted successfully' + }); + } catch (error) { + console.error('Error deleting venue:', error); + res.status(500).json({ message: 'Error deleting venue' }); + } +}; + diff --git a/backend/db.js b/backend/db.js new file mode 100644 index 000000000..1b33c548b --- /dev/null +++ b/backend/db.js @@ -0,0 +1,26 @@ +import pg from 'pg'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const { Pool } = pg; + + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false +}); + +pool.on('connect', () => { + console.log('PostgreSQL database connection pool established successfully.'); +}); + +pool.on('error', (err) => { + console.error('Unexpected error on idle database client:', err); + process.exit(-1); +}); + + +export const query = (text, params) => pool.query(text, params); + +export default pool; diff --git a/backend/middleware/authMiddleware.js b/backend/middleware/authMiddleware.js new file mode 100644 index 000000000..7c877f86d --- /dev/null +++ b/backend/middleware/authMiddleware.js @@ -0,0 +1,38 @@ +import jwt from 'jsonwebtoken'; + +/** + * Middleware to authenticate requests using JWT tokens. + * Extracts the token from the Authorization header (expected format: Bearer ). + * Validates the token and attaches the decoded payload (user info) to the request object. + */ +export const authenticateToken = (req, res, next) => { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; // Format: Bearer TOKEN + + if (!token) { + return res.status(401).json({ message: 'Authentication token missing' }); + } + + jwt.verify(token, process.env.JWT_SECRET, (err, user) => { + if (err) { + return res.status(403).json({ message: 'Invalid or expired token' }); + } + req.user = user; // user payload contains { id, email, role } + next(); + }); +}; + +/** + * Middleware factory to restrict route access based on user roles. + * Ensures the authenticated user possesses the required role before proceeding. + * @param {string} role - The required user role (e.g., 'admin', 'venue_owner', 'user') + */ +export const requireRole = (role) => { + return (req, res, next) => { + if (!req.user || req.user.role !== role) { + return res.status(403).json({ message: `Access denied. Requires ${role} role.` }); + } + next(); + }; +}; + diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 000000000..3027786dd --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1605 @@ +{ + "name": "bookmyvenue-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bookmyvenue-backend", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "multer": "^2.2.0", + "pg": "^8.11.5" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "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/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "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==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/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==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "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/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/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/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/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/nodemon/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/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "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==", + "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==", + "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==", + "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==", + "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/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.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==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 000000000..3946ad1c9 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,22 @@ +{ + "name": "bookmyvenue-backend", + "version": "1.0.0", + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "multer": "^2.2.0", + "pg": "^8.11.5" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } +} diff --git a/backend/reset-db.js b/backend/reset-db.js new file mode 100644 index 000000000..3cdcb2b5d --- /dev/null +++ b/backend/reset-db.js @@ -0,0 +1,48 @@ +import fs from 'fs'; +import bcrypt from 'bcryptjs'; +import pg from 'pg'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const { Pool } = pg; +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +async function main() { + try { + console.log('Reading schema.sql...'); + const schemaSql = fs.readFileSync('schema.sql', 'utf8'); + + console.log('Connecting to database and running schema.sql...'); + await pool.query(schemaSql); + console.log('Database tables created successfully!'); + + console.log('Seeding initial users...'); + const usersToSeed = [ + { name: 'Admin User', email: 'admin@gmail.com', password: 'test123', role: 'admin' }, + { name: 'Regular User', email: 'user@gmail.com', password: 'test123', role: 'user' }, + { name: 'Venue Owner', email: 'owner@gmail.com', password: 'test123', role: 'venue_owner' }, + ]; + + const salt = await bcrypt.genSalt(15); + + for (const u of usersToSeed) { + const hashedPassword = await bcrypt.hash(u.password, salt); + await pool.query( + 'INSERT INTO users (name, email, password, role) VALUES ($1, $2, $3, $4)', + [u.name, u.email.toLowerCase().trim(), hashedPassword, u.role] + ); + console.log(`Seeded user: ${u.email} (${u.role})`); + } + + console.log('Database reset and seed finished successfully!'); + } catch (err) { + console.error('Error resetting database:', err); + } finally { + await pool.end(); + } +} + +main(); diff --git a/backend/routes/admin.js b/backend/routes/admin.js new file mode 100644 index 000000000..e3703d229 --- /dev/null +++ b/backend/routes/admin.js @@ -0,0 +1,24 @@ +import express from 'express'; +import { + getDashboardStats, + getAllVenues, + updateVenueStatus, + getAllBookings, + getAllUsers +} from '../controllers/adminController.js'; +import { authenticateToken, requireRole } from '../middleware/authMiddleware.js'; + +const router = express.Router(); + +// Apply admin token validation and role verification globally to all endpoints below +router.use(authenticateToken); +router.use(requireRole('admin')); + +// Admin Dashboard Endpoints +router.get('/stats', getDashboardStats); // Fetch overall metrics and revenue splits +router.get('/venues', getAllVenues); // Get all listed venues (approved/pending/declined) +router.put('/venues/:id/status', updateVenueStatus); // Update status (approve/decline venue listing) +router.get('/bookings', getAllBookings); // View all customer bookings on the platform +router.get('/users', getAllUsers); // List all users and venue owners on the platform + +export default router; diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 000000000..e61116128 --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,10 @@ +import express from 'express'; +import { registerUser, loginUser } from '../controllers/authController.js'; + +const router = express.Router(); + +// Authentication Route mappings +router.post('/signup', registerUser); // Signup profile creation endpoint +router.post('/login', loginUser); // Login authentication credential validation + +export default router; diff --git a/backend/routes/bookings.js b/backend/routes/bookings.js new file mode 100644 index 000000000..f6ad5c116 --- /dev/null +++ b/backend/routes/bookings.js @@ -0,0 +1,16 @@ +import express from 'express'; +import { getBookings, createBooking, cancelBooking, lockVenue, getBookingById } from '../controllers/bookingController.js'; +import { authenticateToken, requireRole } from '../middleware/authMiddleware.js'; + +const router = express.Router(); + +// Apply global authentication block middleware for bookings +router.use(authenticateToken); + +router.get('/', getBookings); // Get user's active bookings list +router.get('/:id', getBookingById); // Get detailed invoice/receipt fields by booking ID +router.post('/', createBooking); // Submit new booking reservation +router.post('/lock', requireRole('venue_owner'), lockVenue); // Block dates off for maintenance/offline events +router.put('/:id/cancel', cancelBooking); // Trigger cancel action and calculate refund + +export default router; diff --git a/backend/routes/upload.js b/backend/routes/upload.js new file mode 100644 index 000000000..5536506e9 --- /dev/null +++ b/backend/routes/upload.js @@ -0,0 +1,51 @@ +import express from 'express'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; + +const router = express.Router(); + +// Ensure the local uploads directory exists on disk initialization +const uploadDir = 'uploads'; +if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir); +} + +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + cb(null, uniqueSuffix + path.extname(file.originalname)); + } +}); + +const upload = multer({ + storage: storage, + limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit + fileFilter: (req, file, cb) => { + const filetypes = /jpeg|jpg|png|webp/; + const extname = filetypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = filetypes.test(file.mimetype); + if (mimetype && extname) { + return cb(null, true); + } + cb(new Error('Only images (jpeg, jpg, png, webp) are allowed!')); + } +}); + +router.post('/', upload.single('image'), (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ message: 'No file uploaded' }); + } + // Return the full URL of the uploaded image + const imageUrl = `${req.protocol}://${req.get('host')}/uploads/${req.file.filename}`; + res.json({ imageUrl }); + } catch (err) { + res.status(500).json({ message: err.message }); + } +}); + +export default router; diff --git a/backend/routes/venues.js b/backend/routes/venues.js new file mode 100644 index 000000000..c3c5f1f3c --- /dev/null +++ b/backend/routes/venues.js @@ -0,0 +1,23 @@ +import express from 'express'; +import { getVenues, getVenueById, createVenue, getMyVenues, updateVenue, deleteVenue } from '../controllers/venueController.js'; +import { getVenueBookings, getVenueAvailability } from '../controllers/bookingController.js'; +import { authenticateToken, requireRole } from '../middleware/authMiddleware.js'; + +const router = express.Router(); + +// Public list route +router.get('/', getVenues); // Get all approved venues (filtered/sorted) + +// Protected Host-Only (Venue Owner) Routes (MUST be declared before parameterized /:id routes) +router.get('/my-venues', authenticateToken, requireRole('venue_owner'), getMyVenues); // Get host's listed venues +router.post('/', authenticateToken, requireRole('venue_owner'), createVenue); // List new venue (pending approval) +router.put('/:id', authenticateToken, requireRole('venue_owner'), updateVenue); // Edit own venue details +router.delete('/:id', authenticateToken, requireRole('venue_owner'), deleteVenue); // Remove own venue listing + +// Public Parameterized Routes +router.get('/:id', getVenueById); // Get detailed view of single venue by ID +router.get('/:id/bookings', getVenueBookings); // Get booking ranges for calendar highlighting +router.get('/:id/availability', getVenueAvailability); // Fetch available hours slots or daily ranges + +export default router; + diff --git a/backend/schema.sql b/backend/schema.sql new file mode 100644 index 000000000..3de88337f --- /dev/null +++ b/backend/schema.sql @@ -0,0 +1,88 @@ +-- Drop tables if they exist to perform a clean setup +DROP TABLE IF EXISTS bookings CASCADE; +DROP TABLE IF EXISTS venues CASCADE; +DROP TABLE IF EXISTS users CASCADE; + +-- Enable btree_gist extension for GiST exclusion constraints on scalar types +CREATE EXTENSION IF NOT EXISTS btree_gist; + +-- 1. Create Users Table +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'venue_owner', 'admin')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2. Create Venues Table +CREATE TABLE venues ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + location VARCHAR(255) NOT NULL, + full_address TEXT NOT NULL, + latitude NUMERIC(9, 6), + longitude NUMERIC(9, 6), + capacity INTEGER NOT NULL CHECK (capacity > 0), + square_feet INTEGER NOT NULL CHECK (square_feet > 0), + price_per_night NUMERIC(10, 2) NOT NULL CHECK (price_per_night >= 0), + host_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + host_type VARCHAR(100) DEFAULT 'Superhost', + rating NUMERIC(3, 2) DEFAULT 5.0 CHECK (rating >= 0 AND rating <= 5), + is_top_rated BOOLEAN DEFAULT FALSE, + date_range VARCHAR(100) DEFAULT 'Available', + parking TEXT, + catering TEXT, + images TEXT[] DEFAULT '{}', + amenities TEXT[] DEFAULT '{}', + rules TEXT[] DEFAULT '{}', + event_types TEXT[] DEFAULT '{}', + status VARCHAR(50) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'declined')), + rejection_reason TEXT, + booking_type VARCHAR(50) DEFAULT 'days' CHECK (booking_type IN ('days', 'hours')), + cleaning_gap INTEGER DEFAULT 0 CHECK (cleaning_gap >= 0), + opening_time VARCHAR(5) DEFAULT '08:00', + closing_time VARCHAR(5) DEFAULT '22:00', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create index on venues.status for faster discovery +CREATE INDEX idx_venues_status ON venues(status); +-- Create index on venues.host_id for host dashboard queries +CREATE INDEX idx_venues_host_id ON venues(host_id); + +-- 3. Create Bookings Table +CREATE TABLE bookings ( + id VARCHAR(50) PRIMARY KEY, + venue_id INTEGER REFERENCES venues(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- Null for offline locks + start_date TIMESTAMP NOT NULL, + end_date TIMESTAMP NOT NULL, + blocked_end_date TIMESTAMP NOT NULL, -- end_date + cleaning_gap (or end_date if daily) + cleaning_gap INTEGER DEFAULT 0 CHECK (cleaning_gap >= 0), + guests INTEGER DEFAULT 0 CHECK (guests >= 0), + total_price NUMERIC(10, 2) DEFAULT 0.00 CHECK (total_price >= 0), + status VARCHAR(50) DEFAULT 'upcoming' CHECK (status IN ('upcoming', 'cancelled', 'offline')), + payment_status VARCHAR(50) DEFAULT 'paid' CHECK (payment_status IN ('paid', 'refunded', 'offline')), + booking_date DATE DEFAULT CURRENT_DATE, + check_in_instructions TEXT, + renter_name VARCHAR(255), + renter_phone VARCHAR(50), + renter_email VARCHAR(255), + booking_type VARCHAR(50) DEFAULT 'days' CHECK (booking_type IN ('days', 'hours')), + refund_amount NUMERIC(10, 2) DEFAULT 0.00 CHECK (refund_amount >= 0), + refund_percentage INTEGER DEFAULT 0 CHECK (refund_percentage >= 0 AND refund_percentage <= 100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_booking_dates CHECK (end_date >= start_date), + CONSTRAINT chk_blocked_end CHECK (blocked_end_date >= end_date), + CONSTRAINT no_overlapping_bookings EXCLUDE USING gist ( + venue_id WITH =, + tsrange(start_date, blocked_end_date, '[)') WITH && + ) WHERE (status != 'cancelled') +); + +-- Create indexes on bookings to speed up conflict checking and searches +CREATE INDEX idx_bookings_venue_dates ON bookings(venue_id, start_date, end_date) WHERE status != 'cancelled'; +CREATE INDEX idx_bookings_user_id ON bookings(user_id); diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 000000000..28d3ea275 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,59 @@ +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import authRoutes from './routes/auth.js'; +import venueRoutes from './routes/venues.js'; +import bookingRoutes from './routes/bookings.js'; +import adminRoutes from './routes/admin.js'; +import uploadRoutes from './routes/upload.js'; + +// Load environment variables from .env file +dotenv.config(); + +const app = express(); +const PORT = process.env.PORT || 5000; + +// Enable Cross-Origin Resource Sharing (CORS) for external frontend clients +app.use(cors({ + origin: '*', + methods: ['GET', 'POST', 'PUT', 'DELETE'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); + +// Global Middlewares +app.use(express.json()); // Parse incoming JSON request payloads +app.use('/uploads', express.static('uploads')); // Serve uploaded venue photos statically + +// API Route Mappings +app.use('/api/auth', authRoutes); // Sign up, login, token generations +app.use('/api/venues', venueRoutes); // Get venues, create, update, delete +app.use('/api/bookings', bookingRoutes); // Core bookings & availability +app.use('/api/admin', adminRoutes); // Admin metrics & status actions +app.use('/api/upload', uploadRoutes); // Image uploading to static storage + +// Root Health Check Route +app.get('/', (req, res) => { + res.json({ + status: 'online', + message: 'BookMyVenue API Gateway is running smoothly', + timestamp: new Date() + }); +}); + +// 404 Route Not Found Middleware +app.use((req, res, next) => { + res.status(404).json({ message: `Route ${req.originalUrl} not found` }); +}); + +// Global Centralized Error Handling Middleware +app.use((err, req, res, next) => { + console.error('Unhandled internal error:', err.stack); + res.status(500).json({ message: 'Internal server error occurred' }); +}); + +// Initialize Express Server Listening Port +app.listen(PORT, () => { + console.log(`BookMyVenue backend server listening on port ${PORT}`); + console.log(`API URL: http://localhost:${PORT}/api`); +}); + diff --git a/components.json b/components.json new file mode 100644 index 000000000..b9100379e --- /dev/null +++ b/components.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": { + "@mapcn": "https://mapcn.dev/r/{name}.json" + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 000000000..ef614d25c --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 000000000..1ebd717cc --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + BookMyVenue - Book or Host Premium Venues + + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..577cedbf2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3121 @@ +{ + "name": "bookmyvenue-test2", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bookmyvenue-test2", + "version": "0.0.0", + "dependencies": { + "@radix-ui/react-slot": "^1.2.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^12.40.0", + "lucide-react": "^0.344.0", + "maplibre-gl": "^5.24.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.15.1", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.18", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.5.3", + "vite": "^5.4.2" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "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", + "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/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", + "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.2" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz", + "integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", + "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", + "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/pbf": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.0.tgz", + "integrity": "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.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/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.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/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.29", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", + "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/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/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.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", + "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/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "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/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/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "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/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "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/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/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "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/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.344.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.344.0.tgz", + "integrity": "sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/maplibre-gl": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/motion-dom": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, + "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/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.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/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/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.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", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.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/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/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" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..28181348e --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "bookmyvenue-test2", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.2.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^12.40.0", + "lucide-react": "^0.344.0", + "maplibre-gl": "^5.24.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.15.1", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.18", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.5.3", + "vite": "^5.4.2" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 000000000..2e7af2b7f --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 000000000..6893eb132 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 000000000..e9522193d --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 000000000..4ccc294c1 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,75 @@ +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import Home from './page/Home'; +import Venues from './page/Venues'; +import Contact from './page/Contact'; +import Login from './page/Login'; +import Signup from './page/Signup'; +import VenueDetail from './page/VenueDetail'; +import BookVenue from './page/BookVenue'; +import AddVenue from './page/AddVenue'; +import Bookings from './page/UserBookings'; +import BookingDetail from './page/BookingDetail'; +import MyVenues from './page/MyVenues'; +import HostVenueDetail from './page/HostVenueDetail'; +import HostBookings from './page/HostBookings'; +import HostDashboard from './page/HostDashboard'; +import AdminDashboard from './page/AdminDashboard'; +import HostVenueBookings from './page/HostVenueBookings'; +import MapTest from './page/MapTest'; + +// Route guard to restrict access only to users with the 'admin' role. +// Redirects unauthorized users to the home page, and unauthenticated users to the login page. +function AdminRoute({ children }: { children: React.ReactNode }) { + const userStr = localStorage.getItem('user'); + if (!userStr) return ; + try { + const user = JSON.parse(userStr); + if (user.role !== 'admin') return ; + } catch { + return ; + } + return <>{children}; +} + +// Route wrapper to redirect logged-in Admins to the admin panel dashboard +// when they try to access normal user/host pages. +function NonAdminRoute({ children }: { children: React.ReactNode }) { + const userStr = localStorage.getItem('user'); + if (userStr) { + try { + const user = JSON.parse(userStr); + if (user.role === 'admin') return ; + } catch {} + } + return <>{children}; +} + +function App() { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Admin route */} + } /> + + + ); +} + +export default App; diff --git a/src/assets/video/vd1.mp4 b/src/assets/video/vd1.mp4 new file mode 100644 index 000000000..22395c620 Binary files /dev/null and b/src/assets/video/vd1.mp4 differ diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx new file mode 100644 index 000000000..b8fca1635 --- /dev/null +++ b/src/components/Navbar.tsx @@ -0,0 +1,260 @@ +import { useState, useEffect } from "react"; +import { LogIn, UserPlus, Menu, X, LogOut } from "lucide-react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; + +export default function Navbar() { + const [menuOpen, setMenuOpen] = useState(false); + const [user, setUser] = useState(null); + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + const storedUser = localStorage.getItem("user"); + if (storedUser) { + try { + setUser(JSON.parse(storedUser)); + } catch (e) { + setUser(null); + } + } else { + setUser(null); + } + }, [location]); + + const handleLogout = () => { + localStorage.removeItem("user"); + localStorage.removeItem("token"); + setUser(null); + setMenuOpen(false); + navigate("/"); + }; + + useEffect(() => { + if (menuOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [menuOpen]); + + const navLinks = !user + ? [ + { href: "/", label: "Home" }, + { href: "/venues", label: "Venues" }, + { href: "/contact", label: "Contact" }, + ] + : user.role === "admin" + ? [ + { href: "/admin", label: "Admin Panel" }, + ] + : user.role === "venue_owner" + ? [ + { href: "/", label: "Home" }, + { href: "/my-venues", label: "My Venues" }, + { href: "/bookings", label: "Bookings" }, + { href: "/contact", label: "Contact" }, + ] + : [ + { href: "/", label: "Home" }, + { href: "/venues", label: "Venues" }, + { href: "/mybooking", label: "Bookings" }, + { href: "/contact", label: "Contact" }, + ]; + + return ( + <> + {/* Navigation */} + + + {/* Mobile menu overlay */} +
setMenuOpen(false)} + > +
+
+ + {/* Mobile menu drawer */} +
+
+
+ {navLinks.map((link, i) => { + const isActive = location.pathname === link.href; + return ( + setMenuOpen(false)} + className={`text-2xl font-semibold py-4 border-b border-white/10 transition-all duration-500 ${isActive ? "text-[#c5a059]" : "text-white" + } ${menuOpen ? "translate-x-0 opacity-100" : "translate-x-8 opacity-0"}`} + style={{ + transitionDelay: menuOpen ? `${150 + i * 70}ms` : "0ms", + }} + > + {link.label} + + ); + })} +
+ +
+ {user ? ( + <> +
+
+ {user.name ? user.name.charAt(0) : "U"} +
+
+ + {user.name} + + {user.email} +
+
+ + + ) : ( + <> + setMenuOpen(false)} + className="flex items-center gap-2 text-sm font-medium text-white/80 sm:hidden" + > + + Sign Me Up! + + setMenuOpen(false)} + className="flex items-center gap-2 text-sm font-medium text-white/80 sm:hidden" + > + + Login + + + )} + setMenuOpen(false)} + className="mt-2 text-center bg-[#c5a059] hover:bg-[#ab8237] text-white text-sm font-semibold px-5 py-3 rounded-full transition-colors" + > + {user?.role === "admin" ? "Admin Panel" : user?.role === "venue_owner" ? "Dashboard" : "Book Your Venue"} + +
+
+
+ + ); +} diff --git a/src/components/VideoBg.tsx b/src/components/VideoBg.tsx new file mode 100644 index 000000000..942597f60 --- /dev/null +++ b/src/components/VideoBg.tsx @@ -0,0 +1,33 @@ +import { useEffect, useRef } from 'react'; + +type Props = { + src: string; + className?: string; +}; + +export default function VideoBg({ src }: Props) { + const videoRef = useRef(null); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + video.play().catch(() => {}); + }, [src]); + + return ( +
+
+ ); +} diff --git a/src/components/map/LocationPicker.tsx b/src/components/map/LocationPicker.tsx new file mode 100644 index 000000000..9bca8b7d7 --- /dev/null +++ b/src/components/map/LocationPicker.tsx @@ -0,0 +1,241 @@ +import React, { useState, useEffect, useRef } from "react"; +import MapLibreGL from "maplibre-gl"; +import { Map } from "@/components/ui/map"; +import { MapMarker } from "./MapMarker"; +import { MapControls } from "./MapControls"; +import { OSM_STYLE, DEFAULT_ZOOM, DEFAULT_CENTER } from "./mapConfig"; +import { Search, Loader2, MapPin } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface AddressInfo { + formattedAddress: string; + city: string; + state: string; + country: string; + postalCode: string; + latitude: number; + longitude: number; +} + +interface LocationPickerProps { + value: { latitude: number; longitude: number } | null; + onChange: (value: { latitude: number; longitude: number }) => void; + onAddressPicked?: (addressInfo: AddressInfo) => void; + className?: string; +} + +export const LocationPicker: React.FC = ({ + value, + onChange, + onAddressPicked, + className +}) => { + const mapRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(""); + const [isSearching, setIsSearching] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + + // Initialize display coordinates from value or default + const lat = value ? value.latitude : DEFAULT_CENTER[1]; + const lng = value ? value.longitude : DEFAULT_CENTER[0]; + + // Listen to map clicks to place marker + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const handleMapClick = (e: MapLibreGL.MapMouseEvent) => { + const coords = e.lngLat; + onChange({ latitude: coords.lat, longitude: coords.lng }); + reverseGeocode(coords.lat, coords.lng); + }; + + map.on("click", handleMapClick); + return () => { + map.off("click", handleMapClick); + }; + }, [onChange, mapRef.current]); + + // Sync viewport when value changes externally (e.g. after search) + useEffect(() => { + const map = mapRef.current; + if (map && value) { + map.jumpTo({ + center: [value.longitude, value.latitude], + zoom: map.getZoom() < 12 ? DEFAULT_ZOOM : map.getZoom() + }); + } + }, [value, mapRef.current]); + + const reverseGeocode = async (latitude: number, longitude: number) => { + try { + const res = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}`, + { + headers: { + "Accept-Language": "en", + "User-Agent": "BookMyVenue/1.0.0" + } + } + ); + if (!res.ok) throw new Error("Reverse geocoding failed"); + const data = await res.json(); + + if (data && onAddressPicked) { + const address = data.address || {}; + const formattedAddress = data.display_name || ""; + const city = address.city || address.town || address.village || address.suburb || address.county || ""; + const state = address.state || ""; + const country = address.country || ""; + const postalCode = address.postcode || ""; + + onAddressPicked({ + formattedAddress, + city, + state, + country, + postalCode, + latitude, + longitude + }); + } + } catch (err) { + console.error("Reverse geocoding error:", err); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + performSearch(); + } + }; + + const handleSearchClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + performSearch(); + }; + + const performSearch = async () => { + if (!searchQuery.trim()) return; + + setIsSearching(true); + setErrorMsg(null); + + try { + const res = await fetch( + `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent( + searchQuery + )}&limit=1`, + { + headers: { + "Accept-Language": "en", + "User-Agent": "BookMyVenue/1.0.0" + } + } + ); + if (!res.ok) throw new Error("Search request failed"); + const data = await res.json(); + + if (data && data.length > 0) { + const resultLat = parseFloat(data[0].lat); + const resultLng = parseFloat(data[0].lon); + + onChange({ latitude: resultLat, longitude: resultLng }); + + // Trigger reverse geocoding to retrieve structured address parts + await reverseGeocode(resultLat, resultLng); + + // Center map + if (mapRef.current) { + mapRef.current.flyTo({ + center: [resultLng, resultLat], + zoom: 15, + duration: 1200 + }); + } + } else { + setErrorMsg("Address not found. Please try a different search."); + } + } catch (err) { + console.error("Geocoding search error:", err); + setErrorMsg("Geocoding service unavailable."); + } finally { + setIsSearching(false); + } + }; + + const handleMarkerDragEnd = (coords: { lng: number; lat: number }) => { + onChange({ latitude: coords.lat, longitude: coords.lng }); + reverseGeocode(coords.lat, coords.lng); + }; + + return ( +
+ {/* Search Input Bar */} +
+
+ setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + className="w-full pl-10 pr-4 py-2 bg-black/40 hover:bg-black/60 focus:bg-black/80 border border-white/10 focus:border-[#c5a059]/40 rounded-xl text-sm text-white placeholder-white/30 focus:outline-none transition-all duration-200" + /> + +
+ +
+ + {errorMsg && ( +

+ {errorMsg} +

+ )} + + {/* Interactive Picker Map */} +
+ + + + + +
+ +
+ Click anywhere on the map or drag the marker to pin the exact venue location. + {value && ( + + {lat.toFixed(5)}, {lng.toFixed(5)} + + )} +
+
+ ); +}; diff --git a/src/components/map/MapControls.tsx b/src/components/map/MapControls.tsx new file mode 100644 index 000000000..a7e148d24 --- /dev/null +++ b/src/components/map/MapControls.tsx @@ -0,0 +1 @@ +export { MapControls } from "@/components/ui/map"; diff --git a/src/components/map/MapMarker.tsx b/src/components/map/MapMarker.tsx new file mode 100644 index 000000000..0e67735b5 --- /dev/null +++ b/src/components/map/MapMarker.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { MapMarker as BaseMapMarker, MarkerContent } from "@/components/ui/map"; +import { MapPin } from "lucide-react"; + +interface MapMarkerProps { + latitude: number; + longitude: number; + children?: React.ReactNode; + draggable?: boolean; + onDragEnd?: (coords: { lng: number; lat: number }) => void; + onClick?: (e: MouseEvent) => void; +} + +export const MapMarker: React.FC = ({ + latitude, + longitude, + children, + draggable = false, + onDragEnd, + onClick +}) => { + return ( + + + {children || ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/components/map/MapPopup.tsx b/src/components/map/MapPopup.tsx new file mode 100644 index 000000000..906fb76d2 --- /dev/null +++ b/src/components/map/MapPopup.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { MapPopup as BaseMapPopup } from "@/components/ui/map"; + +interface MapPopupProps { + latitude: number; + longitude: number; + onClose?: () => void; + children: React.ReactNode; + className?: string; + closeButton?: boolean; +} + +export const MapPopup: React.FC = ({ + latitude, + longitude, + onClose, + children, + className, + closeButton = true +}) => { + return ( + +
+ {children} +
+
+ ); +}; diff --git a/src/components/map/MiniVenueMap.tsx b/src/components/map/MiniVenueMap.tsx new file mode 100644 index 000000000..7f85350b1 --- /dev/null +++ b/src/components/map/MiniVenueMap.tsx @@ -0,0 +1,83 @@ +import React, { useState, useEffect } from "react"; +import { Map } from "@/components/ui/map"; +import { MapMarker } from "./MapMarker"; +import { OSM_STYLE, DEFAULT_ZOOM } from "./mapConfig"; +import { MapPin } from "lucide-react"; + +interface MiniVenueMapProps { + latitude?: number | null; + longitude?: number | null; + address?: string; + zoom?: number; +} + +export const MiniVenueMap: React.FC = ({ + latitude, + longitude, + address, + zoom = DEFAULT_ZOOM - 1 +}) => { + const [coords, setCoords] = useState<[number, number] | null>(null); + const [isGeocoding, setIsGeocoding] = useState(false); + + useEffect(() => { + if (latitude && longitude) { + setCoords([longitude, latitude]); + } else if (address) { + setIsGeocoding(true); + fetch( + `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent( + address + )}&limit=1`, + { + headers: { + "Accept-Language": "en", + "User-Agent": "BookMyVenue/1.0.0" + } + } + ) + .then((res) => res.json()) + .then((data) => { + if (data && data.length > 0) { + const lat = parseFloat(data[0].lat); + const lon = parseFloat(data[0].lon); + setCoords([lon, lat]); + } + }) + .catch((err) => console.error("MiniMap geocoding error:", err)) + .finally(() => setIsGeocoding(false)); + } + }, [latitude, longitude, address]); + + if (isGeocoding) { + return ( +
+
+
+ ); + } + + if (!coords) { + return ( +
+ +
+ ); + } + + const [lng, lat] = coords; + + return ( +
+ + + +
+ ); +}; diff --git a/src/components/map/VenueMap.tsx b/src/components/map/VenueMap.tsx new file mode 100644 index 000000000..993502dd8 --- /dev/null +++ b/src/components/map/VenueMap.tsx @@ -0,0 +1,152 @@ +import React, { useState, useEffect } from "react"; +import { Map } from "@/components/ui/map"; +import { MapMarker } from "./MapMarker"; +import { MapPopup } from "./MapPopup"; +import { MapControls } from "./MapControls"; +import { OSM_STYLE, DEFAULT_ZOOM } from "./mapConfig"; +import { ExternalLink, MapPin } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface VenueMapProps { + latitude?: number | null; + longitude?: number | null; + venueName: string; + address: string; + zoom?: number; +} + +export const VenueMap: React.FC = ({ + latitude, + longitude, + venueName, + address, + zoom = DEFAULT_ZOOM +}) => { + const [coords, setCoords] = useState<[number, number] | null>(null); + const [showPopup, setShowPopup] = useState(false); + const [isGeocoding, setIsGeocoding] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + + useEffect(() => { + if (latitude && longitude) { + setCoords([longitude, latitude]); + setErrorMsg(null); + } else if (address) { + // Dynamic geocoding fallback + setIsGeocoding(true); + setErrorMsg(null); + + const cleanAddress = address.trim(); + fetch( + `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent( + cleanAddress + )}&limit=1`, + { + headers: { + "Accept-Language": "en", + "User-Agent": "BookMyVenue/1.0.0" + } + } + ) + .then((res) => { + if (!res.ok) throw new Error("Network response was not ok"); + return res.json(); + }) + .then((data) => { + if (data && data.length > 0) { + const lat = parseFloat(data[0].lat); + const lon = parseFloat(data[0].lon); + setCoords([lon, lat]); + } else { + setErrorMsg("Could not verify location coordinates on the map."); + } + }) + .catch((err) => { + console.error("Geocoding error:", err); + setErrorMsg("Could not load map: offline or connection error."); + }) + .finally(() => { + setIsGeocoding(false); + }); + } + }, [latitude, longitude, address]); + + const handleOpenGoogleMaps = () => { + if (!coords) return; + const [lng, lat] = coords; + window.open(`https://www.google.com/maps/search/?api=1&query=${lat},${lng}`, "_blank"); + }; + + if (isGeocoding) { + return ( +
+
+ Locating venue on map... +
+ ); + } + + if (errorMsg || !coords) { + return ( +
+ + Map View Unavailable + {errorMsg || "Address is missing or invalid."} + {address && ( + + )} +
+ ); + } + + const [lng, lat] = coords; + + return ( +
+
+ + + + setShowPopup(true)} + /> + + {showPopup && ( + setShowPopup(false)} + > +
{venueName}
+

{address}

+
+ )} +
+
+ +
+ +
+
+ ); +}; diff --git a/src/components/map/index.ts b/src/components/map/index.ts new file mode 100644 index 000000000..2483d0157 --- /dev/null +++ b/src/components/map/index.ts @@ -0,0 +1,7 @@ +export * from "./mapConfig"; +export * from "./MapControls"; +export * from "./MapMarker"; +export * from "./MapPopup"; +export * from "./VenueMap"; +export * from "./MiniVenueMap"; +export * from "./LocationPicker"; diff --git a/src/components/map/mapConfig.ts b/src/components/map/mapConfig.ts new file mode 100644 index 000000000..43f40e467 --- /dev/null +++ b/src/components/map/mapConfig.ts @@ -0,0 +1,29 @@ +import MapLibreGL from "maplibre-gl"; + +export const DEFAULT_ZOOM = 13; +export const DEFAULT_CENTER: [number, number] = [75.3704, 11.8745]; // Kannur [lng, lat] + +export const TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; +export const ATTRIBUTION = "Β© OpenStreetMap contributors"; + +// Custom blank style configuration using OpenStreetMap raster tiles +export const OSM_STYLE: MapLibreGL.StyleSpecification = { + version: 8, + sources: { + osm: { + type: "raster", + tiles: [TILE_URL], + tileSize: 256, + attribution: ATTRIBUTION + } + }, + layers: [ + { + id: "osm-layer", + type: "raster", + source: "osm", + minzoom: 0, + maxzoom: 19 + } + ] +}; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 000000000..f000e3ef5 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 000000000..ac472bb0d --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + }, +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/components/ui/card-22.tsx b/src/components/ui/card-22.tsx new file mode 100644 index 000000000..98b8b7fc0 --- /dev/null +++ b/src/components/ui/card-22.tsx @@ -0,0 +1,195 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ChevronLeft, ChevronRight, Star, ArrowRight, Users } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; + +interface PlaceCardProps { + images: string[]; + tags: string[]; + rating: number; + title: string; + description: string; + capacity: number; + eventTypes: string[]; + className?: string; + onClick?: () => void; +} + +export const PlaceCard = ({ + images, + tags, + rating, + title, + description, + capacity, + eventTypes, + className, + onClick, +}: PlaceCardProps) => { + const [currentIndex, setCurrentIndex] = useState(0); + const [direction, setDirection] = useState(0); + + // Carousel image change handler + const changeImage = (newDirection: number) => { + setDirection(newDirection); + setCurrentIndex((prevIndex) => { + const nextIndex = prevIndex + newDirection; + if (nextIndex < 0) return images.length - 1; + if (nextIndex >= images.length) return 0; + return nextIndex; + }); + }; + + // Animation variants for the carousel + const carouselVariants = { + enter: (direction: number) => ({ + x: direction > 0 ? '100%' : '-100%', + opacity: 0, + }), + center: { + zIndex: 1, + x: 0, + opacity: 1, + }, + exit: (direction: number) => ({ + zIndex: 0, + x: direction < 0 ? '100%' : '-100%', + opacity: 0, + }), + }; + + // Animation variants for staggering content + const contentVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { + staggerChildren: 0.1, + }, + }, + }; + + const itemVariants = { + hidden: { opacity: 0, y: 15 }, + visible: { opacity: 1, y: 0 }, + }; + + return ( + + {/* Image Carousel Section */} +
+ + + + + {/* Carousel Navigation */} +
+ + +
+ + {/* Top Badges and Rating */} +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+
+ + {rating} + +
+ + {/* Pagination Dots */} +
+ {images.map((_, index) => ( +
+
+ + {/* Content Section */} + + +

{title}

+ +
+ + + + + {description} + + + {/* Specifications Section (Capacity & Event Types) */} + + {/* Capacity */} +
+ + Capacity: {capacity} Guests +
+ + {/* Event Types */} +
+ {eventTypes.map((type) => ( + + {type} + + ))} +
+
+ + + + + +
+
+ ); +}; diff --git a/src/components/ui/map.tsx b/src/components/ui/map.tsx new file mode 100644 index 000000000..f0e2dafc4 --- /dev/null +++ b/src/components/ui/map.tsx @@ -0,0 +1,2191 @@ +"use client"; + +import MapLibreGL, { type PopupOptions, type MarkerOptions } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { + createContext, + forwardRef, + useCallback, + useContext, + useEffect, + useId, + useImperativeHandle, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { X, Minus, Plus, Locate, Maximize, Loader2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const defaultStyles = { + dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", + light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", +}; + +// A tile-less, dependency-free style with a transparent background. Use it for +// data visualizations (choropleths, world arcs, dot maps) where you draw your +// own layers and don't need a street basemap. The easiest way to opt in is the +// `blank` prop: +// ... +// The transparent background lets the themed container show through. +const blankMapStyle: MapLibreGL.StyleSpecification = { + version: 8, + sources: {}, + layers: [ + { + id: "background", + type: "background", + paint: { "background-color": "rgba(0, 0, 0, 0)" }, + }, + ], +}; + +function mergeHoverPaint>( + paint: T, + hoverPaint: T | undefined, +): T { + if (!hoverPaint) return paint; + const merged: Record = { ...paint }; + for (const [key, hoverValue] of Object.entries(hoverPaint)) { + if (hoverValue === undefined) continue; + const baseValue = merged[key]; + merged[key] = + baseValue === undefined + ? hoverValue + : [ + "case", + ["boolean", ["feature-state", "hover"], false], + hoverValue, + baseValue, + ]; + } + return merged as T; +} + +type Theme = "light" | "dark"; + +// Check document class for theme (works with next-themes, etc.) +function getDocumentTheme(): Theme | null { + if (typeof document === "undefined") return null; + if (document.documentElement.classList.contains("dark")) return "dark"; + if (document.documentElement.classList.contains("light")) return "light"; + return null; +} + +// Get system preference +function getSystemTheme(): Theme { + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +function useResolvedTheme(themeProp?: "light" | "dark"): Theme { + const [detectedTheme, setDetectedTheme] = useState( + () => getDocumentTheme() ?? getSystemTheme(), + ); + + useEffect(() => { + if (themeProp) return; // Skip detection if theme is provided via prop + + // Watch for document class changes (e.g., next-themes toggling dark class) + const observer = new MutationObserver(() => { + const docTheme = getDocumentTheme(); + if (docTheme) { + setDetectedTheme(docTheme); + } + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + // Also watch for system preference changes + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleSystemChange = (e: MediaQueryListEvent) => { + // Only use system preference if no document class is set + if (!getDocumentTheme()) { + setDetectedTheme(e.matches ? "dark" : "light"); + } + }; + mediaQuery.addEventListener("change", handleSystemChange); + + return () => { + observer.disconnect(); + mediaQuery.removeEventListener("change", handleSystemChange); + }; + }, [themeProp]); + + return themeProp ?? detectedTheme; +} + +type MapContextValue = { + map: MapLibreGL.Map | null; + isLoaded: boolean; + resolvedTheme: Theme; +}; + +const MapContext = createContext(null); + +function useMap() { + const context = useContext(MapContext); + if (!context) { + throw new Error("useMap must be used within a Map component"); + } + return context; +} + +/** Map viewport state */ +type MapViewport = { + /** Center coordinates [longitude, latitude] */ + center: [number, number]; + /** Zoom level */ + zoom: number; + /** Bearing (rotation) in degrees */ + bearing: number; + /** Pitch (tilt) in degrees */ + pitch: number; +}; + +type MapStyleOption = string | MapLibreGL.StyleSpecification; + +type MapRef = MapLibreGL.Map; + +type MapProps = { + children?: ReactNode; + /** Additional CSS classes for the map container */ + className?: string; + /** + * Theme for the map. If not provided, automatically detects system preference. + * Pass your theme value here. + */ + theme?: Theme; + /** Custom map styles for light and dark themes. Overrides the default Carto styles. */ + styles?: { + light?: MapStyleOption; + dark?: MapStyleOption; + }; + /** + * Use a transparent, tile-less basemap instead of the default Carto street + * basemap β€” a blank canvas. Used alone it renders nothing; add your own + * layers on top (``, ``, markers, etc.). Ideal for data + * visualizations (choropleths, arcs, dot maps). + * Ignored when an explicit `styles` prop is provided. + */ + blank?: boolean; + /** Map projection type. Use `{ type: "globe" }` for 3D globe view. */ + projection?: MapLibreGL.ProjectionSpecification; + /** + * Controlled viewport. When provided with onViewportChange, + * the map becomes controlled and viewport is driven by this prop. + */ + viewport?: Partial; + /** + * Callback fired continuously as the viewport changes (pan, zoom, rotate, pitch). + * Can be used standalone to observe changes, or with `viewport` prop + * to enable controlled mode where the map viewport is driven by your state. + */ + onViewportChange?: (viewport: MapViewport) => void; + /** Show a loading indicator on the map */ + loading?: boolean; +} & Omit; + +function DefaultLoader() { + return ( +
+
+ + + +
+
+ ); +} + +function getViewport(map: MapLibreGL.Map): MapViewport { + const center = map.getCenter(); + return { + center: [center.lng, center.lat], + zoom: map.getZoom(), + bearing: map.getBearing(), + pitch: map.getPitch(), + }; +} + +const Map = forwardRef(function Map( + { + children, + className, + theme: themeProp, + styles, + blank = false, + projection, + viewport, + onViewportChange, + loading = false, + ...props + }, + ref, +) { + const containerRef = useRef(null); + const [mapInstance, setMapInstance] = useState(null); + const [isLoaded, setIsLoaded] = useState(false); + const [isStyleLoaded, setIsStyleLoaded] = useState(false); + const currentStyleRef = useRef(null); + const styleTimeoutRef = useRef | null>(null); + const internalUpdateRef = useRef(false); + const resolvedTheme = useResolvedTheme(themeProp); + + const isControlled = viewport !== undefined && onViewportChange !== undefined; + + const onViewportChangeRef = useRef(onViewportChange); + onViewportChangeRef.current = onViewportChange; + + const mapStyles = useMemo(() => { + // Explicit styles win. Otherwise `blank` opts into the transparent + // tile-less basemap; with neither, fall back to the Carto defaults. + if (styles) { + return { + dark: styles.dark ?? defaultStyles.dark, + light: styles.light ?? defaultStyles.light, + }; + } + if (blank) { + return { dark: blankMapStyle, light: blankMapStyle }; + } + return defaultStyles; + }, [styles, blank]); + + // Expose the map instance to the parent component + useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]); + + const clearStyleTimeout = useCallback(() => { + if (styleTimeoutRef.current) { + clearTimeout(styleTimeoutRef.current); + styleTimeoutRef.current = null; + } + }, []); + + // Initialize the map + useEffect(() => { + if (!containerRef.current) return; + + const initialStyle = + resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light; + currentStyleRef.current = initialStyle; + + const map = new MapLibreGL.Map({ + container: containerRef.current, + style: initialStyle, + renderWorldCopies: false, + attributionControl: { + compact: true, + }, + ...props, + ...viewport, + }); + + const styleDataHandler = () => { + clearStyleTimeout(); + // Delay to ensure style is fully processed before allowing layer operations + // This is a workaround to avoid race conditions with the style loading + // else we have to force update every layer on setStyle change + styleTimeoutRef.current = setTimeout(() => { + setIsStyleLoaded(true); + if (projection) { + map.setProjection(projection); + } + }, 100); + }; + const loadHandler = () => setIsLoaded(true); + + // Viewport change handler - skip if triggered by internal update + const handleMove = () => { + if (internalUpdateRef.current) return; + onViewportChangeRef.current?.(getViewport(map)); + }; + + map.on("load", loadHandler); + map.on("styledata", styleDataHandler); + map.on("move", handleMove); + setMapInstance(map); + + return () => { + clearStyleTimeout(); + map.off("load", loadHandler); + map.off("styledata", styleDataHandler); + map.off("move", handleMove); + map.remove(); + setIsLoaded(false); + setIsStyleLoaded(false); + setMapInstance(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Sync controlled viewport to map + useEffect(() => { + if (!mapInstance || !isControlled || !viewport) return; + if (mapInstance.isMoving()) return; + + const current = getViewport(mapInstance); + const next = { + center: viewport.center ?? current.center, + zoom: viewport.zoom ?? current.zoom, + bearing: viewport.bearing ?? current.bearing, + pitch: viewport.pitch ?? current.pitch, + }; + + if ( + next.center[0] === current.center[0] && + next.center[1] === current.center[1] && + next.zoom === current.zoom && + next.bearing === current.bearing && + next.pitch === current.pitch + ) { + return; + } + + internalUpdateRef.current = true; + mapInstance.jumpTo(next); + internalUpdateRef.current = false; + }, [mapInstance, isControlled, viewport]); + + // Handle style change + useEffect(() => { + if (!mapInstance || !resolvedTheme) return; + + const newStyle = + resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light; + + if (currentStyleRef.current === newStyle) return; + + clearStyleTimeout(); + currentStyleRef.current = newStyle; + setIsStyleLoaded(false); + + mapInstance.setStyle(newStyle, { diff: true }); + }, [mapInstance, resolvedTheme, mapStyles, clearStyleTimeout]); + + // Sync projection when the prop changes after mount. + useEffect(() => { + if (!mapInstance || !isStyleLoaded || !projection) return; + mapInstance.setProjection(projection); + }, [mapInstance, isStyleLoaded, projection]); + + const contextValue = useMemo( + () => ({ + map: mapInstance, + isLoaded: isLoaded && isStyleLoaded, + resolvedTheme, + }), + [mapInstance, isLoaded, isStyleLoaded, resolvedTheme], + ); + + return ( + +
+ {(!isLoaded || loading) && } + {/* SSR-safe: children render only when map is loaded on client */} + {mapInstance && children} +
+
+ ); +}); + +type MarkerContextValue = { + marker: MapLibreGL.Marker; + map: MapLibreGL.Map | null; +}; + +const MarkerContext = createContext(null); + +function useMarkerContext() { + const context = useContext(MarkerContext); + if (!context) { + throw new Error("Marker components must be used within MapMarker"); + } + return context; +} + +type MapMarkerProps = { + /** Longitude coordinate for marker position */ + longitude: number; + /** Latitude coordinate for marker position */ + latitude: number; + /** Marker subcomponents (MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel) */ + children: ReactNode; + /** Callback when marker is clicked */ + onClick?: (e: MouseEvent) => void; + /** Callback when mouse enters marker */ + onMouseEnter?: (e: MouseEvent) => void; + /** Callback when mouse leaves marker */ + onMouseLeave?: (e: MouseEvent) => void; + /** Callback when marker drag starts (requires draggable: true) */ + onDragStart?: (lngLat: { lng: number; lat: number }) => void; + /** Callback during marker drag (requires draggable: true) */ + onDrag?: (lngLat: { lng: number; lat: number }) => void; + /** Callback when marker drag ends (requires draggable: true) */ + onDragEnd?: (lngLat: { lng: number; lat: number }) => void; +} & Omit; + +function MapMarker({ + longitude, + latitude, + children, + onClick, + onMouseEnter, + onMouseLeave, + onDragStart, + onDrag, + onDragEnd, + draggable = false, + ...markerOptions +}: MapMarkerProps) { + const { map } = useMap(); + + const callbacksRef = useRef({ + onClick, + onMouseEnter, + onMouseLeave, + onDragStart, + onDrag, + onDragEnd, + }); + callbacksRef.current = { + onClick, + onMouseEnter, + onMouseLeave, + onDragStart, + onDrag, + onDragEnd, + }; + + const marker = useMemo(() => { + const markerInstance = new MapLibreGL.Marker({ + ...markerOptions, + element: document.createElement("div"), + draggable, + }).setLngLat([longitude, latitude]); + + const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e); + const handleMouseEnter = (e: MouseEvent) => + callbacksRef.current.onMouseEnter?.(e); + const handleMouseLeave = (e: MouseEvent) => + callbacksRef.current.onMouseLeave?.(e); + + markerInstance.getElement()?.addEventListener("click", handleClick); + markerInstance + .getElement() + ?.addEventListener("mouseenter", handleMouseEnter); + markerInstance + .getElement() + ?.addEventListener("mouseleave", handleMouseLeave); + + const handleDragStart = () => { + const lngLat = markerInstance.getLngLat(); + callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat }); + }; + const handleDrag = () => { + const lngLat = markerInstance.getLngLat(); + callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat }); + }; + const handleDragEnd = () => { + const lngLat = markerInstance.getLngLat(); + callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat }); + }; + + markerInstance.on("dragstart", handleDragStart); + markerInstance.on("drag", handleDrag); + markerInstance.on("dragend", handleDragEnd); + + return markerInstance; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!map) return; + + marker.addTo(map); + + return () => { + marker.remove(); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + const { offset, rotation, rotationAlignment, pitchAlignment } = markerOptions; + + useEffect(() => { + const current = marker.getLngLat(); + if (current.lng !== longitude || current.lat !== latitude) { + marker.setLngLat([longitude, latitude]); + } + + if (marker.isDraggable() !== draggable) { + marker.setDraggable(draggable); + } + + const currentOffset = marker.getOffset(); + const newOffset = offset ?? [0, 0]; + const [newOffsetX, newOffsetY] = Array.isArray(newOffset) + ? newOffset + : [newOffset.x, newOffset.y]; + if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) { + marker.setOffset(newOffset); + } + + if (marker.getRotation() !== (rotation ?? 0)) { + marker.setRotation(rotation ?? 0); + } + if (marker.getRotationAlignment() !== (rotationAlignment ?? "auto")) { + marker.setRotationAlignment(rotationAlignment ?? "auto"); + } + if (marker.getPitchAlignment() !== (pitchAlignment ?? "auto")) { + marker.setPitchAlignment(pitchAlignment ?? "auto"); + } + }, [ + marker, + longitude, + latitude, + draggable, + offset, + rotation, + rotationAlignment, + pitchAlignment, + ]); + + return ( + + {children} + + ); +} + +type MarkerContentProps = { + /** Custom marker content. Defaults to a blue dot if not provided */ + children?: ReactNode; + /** Additional CSS classes for the marker container */ + className?: string; +}; + +function MarkerContent({ children, className }: MarkerContentProps) { + const { marker } = useMarkerContext(); + + return createPortal( +
+ {children || } +
, + marker.getElement(), + ); +} + +function DefaultMarkerIcon() { + return ( +
+ ); +} + +function PopupCloseButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +type MarkerPopupProps = { + /** Popup content */ + children: ReactNode; + /** Additional CSS classes for the popup container */ + className?: string; + /** Show a close button in the popup (default: false) */ + closeButton?: boolean; +} & Omit; + +function MarkerPopup({ + children, + className, + closeButton = false, + ...popupOptions +}: MarkerPopupProps) { + const { marker, map } = useMarkerContext(); + const container = useMemo(() => document.createElement("div"), []); + const { offset, maxWidth } = popupOptions; + + const popup = useMemo(() => { + const popupInstance = new MapLibreGL.Popup({ + offset: 16, + ...popupOptions, + closeButton: false, + }) + .setMaxWidth("none") + .setDOMContent(container); + + return popupInstance; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!map) return; + + popup.setDOMContent(container); + marker.setPopup(popup); + + return () => { + marker.setPopup(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + // Sync popup options when they change. + useEffect(() => { + popup.setOffset(offset ?? 16); + if (maxWidth) { + popup.setMaxWidth(maxWidth); + } + }, [popup, offset, maxWidth]); + + const handleClose = () => popup.remove(); + + return createPortal( +
+ {closeButton && } + {children} +
, + container, + ); +} + +type MarkerTooltipProps = { + /** Tooltip content */ + children: ReactNode; + /** Additional CSS classes for the tooltip container */ + className?: string; +} & Omit; + +function MarkerTooltip({ + children, + className, + ...popupOptions +}: MarkerTooltipProps) { + const { marker, map } = useMarkerContext(); + const container = useMemo(() => document.createElement("div"), []); + const { offset, maxWidth } = popupOptions; + + const tooltip = useMemo(() => { + const tooltipInstance = new MapLibreGL.Popup({ + offset: 16, + ...popupOptions, + closeOnClick: true, + closeButton: false, + }).setMaxWidth("none"); + + return tooltipInstance; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!map) return; + + tooltip.setDOMContent(container); + + const handleMouseEnter = () => { + tooltip.setLngLat(marker.getLngLat()).addTo(map); + }; + const handleMouseLeave = () => tooltip.remove(); + + marker.getElement()?.addEventListener("mouseenter", handleMouseEnter); + marker.getElement()?.addEventListener("mouseleave", handleMouseLeave); + + return () => { + marker.getElement()?.removeEventListener("mouseenter", handleMouseEnter); + marker.getElement()?.removeEventListener("mouseleave", handleMouseLeave); + tooltip.remove(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + // Sync tooltip options when they change. + useEffect(() => { + tooltip.setOffset(offset ?? 16); + if (maxWidth) { + tooltip.setMaxWidth(maxWidth); + } + }, [tooltip, offset, maxWidth]); + + return createPortal( +
+ {children} +
, + container, + ); +} + +type MarkerLabelProps = { + /** Label text content */ + children: ReactNode; + /** Additional CSS classes for the label */ + className?: string; + /** Position of the label relative to the marker (default: "top") */ + position?: "top" | "bottom"; +}; + +function MarkerLabel({ + children, + className, + position = "top", +}: MarkerLabelProps) { + const positionClasses = { + top: "bottom-full mb-1", + bottom: "top-full mt-1", + }; + + return ( +
+ {children} +
+ ); +} + +type MapControlsProps = { + /** Position of the controls on the map (default: "bottom-right") */ + position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; + /** Show zoom in/out buttons (default: true) */ + showZoom?: boolean; + /** Show compass button to reset bearing (default: false) */ + showCompass?: boolean; + /** Show locate button to find user's location (default: false) */ + showLocate?: boolean; + /** Show fullscreen toggle button (default: false) */ + showFullscreen?: boolean; + /** Additional CSS classes for the controls container */ + className?: string; + /** Callback with user coordinates when located */ + onLocate?: (coords: { longitude: number; latitude: number }) => void; +}; + +const positionClasses = { + "top-left": "top-2 left-2", + "top-right": "top-2 right-2", + "bottom-left": "bottom-2 left-2", + "bottom-right": "bottom-10 right-2", +}; + +function ControlGroup({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function ControlButton({ + onClick, + label, + children, + disabled = false, +}: { + onClick: () => void; + label: string; + children: React.ReactNode; + disabled?: boolean; +}) { + return ( + + ); +} + +function MapControls({ + position = "bottom-right", + showZoom = true, + showCompass = false, + showLocate = false, + showFullscreen = false, + className, + onLocate, +}: MapControlsProps) { + const { map } = useMap(); + const [waitingForLocation, setWaitingForLocation] = useState(false); + + const handleZoomIn = useCallback(() => { + map?.zoomTo(map.getZoom() + 1, { duration: 300 }); + }, [map]); + + const handleZoomOut = useCallback(() => { + map?.zoomTo(map.getZoom() - 1, { duration: 300 }); + }, [map]); + + const handleResetBearing = useCallback(() => { + map?.resetNorthPitch({ duration: 300 }); + }, [map]); + + const handleLocate = useCallback(() => { + setWaitingForLocation(true); + if ("geolocation" in navigator) { + navigator.geolocation.getCurrentPosition( + (pos) => { + const coords = { + longitude: pos.coords.longitude, + latitude: pos.coords.latitude, + }; + map?.flyTo({ + center: [coords.longitude, coords.latitude], + zoom: 14, + duration: 1500, + }); + onLocate?.(coords); + setWaitingForLocation(false); + }, + (error) => { + console.error("Error getting location:", error); + setWaitingForLocation(false); + }, + ); + } + }, [map, onLocate]); + + const handleFullscreen = useCallback(() => { + const container = map?.getContainer(); + if (!container) return; + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + container.requestFullscreen(); + } + }, [map]); + + return ( +
+ {showZoom && ( + + + + + + + + + )} + {showCompass && ( + + + + )} + {showLocate && ( + + + {waitingForLocation ? ( + + ) : ( + + )} + + + )} + {showFullscreen && ( + + + + + + )} +
+ ); +} + +function CompassButton({ onClick }: { onClick: () => void }) { + const { map } = useMap(); + const compassRef = useRef(null); + + useEffect(() => { + if (!map || !compassRef.current) return; + + const compass = compassRef.current; + + const updateRotation = () => { + const bearing = map.getBearing(); + const pitch = map.getPitch(); + compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`; + }; + + map.on("rotate", updateRotation); + map.on("pitch", updateRotation); + updateRotation(); + + return () => { + map.off("rotate", updateRotation); + map.off("pitch", updateRotation); + }; + }, [map]); + + return ( + + + + + + + + + ); +} + +type MapPopupProps = { + /** Longitude coordinate for popup position */ + longitude: number; + /** Latitude coordinate for popup position */ + latitude: number; + /** Callback when popup is closed */ + onClose?: () => void; + /** Popup content */ + children: ReactNode; + /** Additional CSS classes for the popup container */ + className?: string; + /** Show a close button in the popup (default: false) */ + closeButton?: boolean; +} & Omit; + +function MapPopup({ + longitude, + latitude, + onClose, + children, + className, + closeButton = false, + ...popupOptions +}: MapPopupProps) { + const { map } = useMap(); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const container = useMemo(() => document.createElement("div"), []); + const { offset, maxWidth } = popupOptions; + + const popup = useMemo(() => { + const popupInstance = new MapLibreGL.Popup({ + offset: 16, + ...popupOptions, + closeButton: false, + }) + .setMaxWidth("none") + .setLngLat([longitude, latitude]); + + return popupInstance; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!map) return; + + const onCloseProp = () => onCloseRef.current?.(); + + popup.on("close", onCloseProp); + + popup.setDOMContent(container); + popup.addTo(map); + + return () => { + popup.off("close", onCloseProp); + if (popup.isOpen()) { + popup.remove(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + // Sync popup position and options when they change. + useEffect(() => { + const current = popup.getLngLat(); + if (!current || current.lng !== longitude || current.lat !== latitude) { + popup.setLngLat([longitude, latitude]); + } + popup.setOffset(offset ?? 16); + if (maxWidth) { + popup.setMaxWidth(maxWidth); + } + }, [popup, longitude, latitude, offset, maxWidth]); + + const handleClose = () => { + popup.remove(); + }; + + return createPortal( +
+ {closeButton && } + {children} +
, + container, + ); +} + +type MapRouteProps = { + /** Optional unique identifier for the route layer */ + id?: string; + /** Array of [longitude, latitude] coordinate pairs defining the route */ + coordinates: [number, number][]; + /** Line color as CSS color value (default: "#4285F4") */ + color?: string; + /** Line width in pixels (default: 3) */ + width?: number; + /** Line opacity from 0 to 1 (default: 0.8) */ + opacity?: number; + /** Dash pattern [dash length, gap length] for dashed lines */ + dashArray?: [number, number]; + /** Callback when the route line is clicked */ + onClick?: () => void; + /** Callback when mouse enters the route line */ + onMouseEnter?: () => void; + /** Callback when mouse leaves the route line */ + onMouseLeave?: () => void; + /** Whether the route is interactive - shows pointer cursor on hover (default: true) */ + interactive?: boolean; +}; + +function MapRoute({ + id: propId, + coordinates, + color = "#4285F4", + width = 3, + opacity = 0.8, + dashArray, + onClick, + onMouseEnter, + onMouseLeave, + interactive = true, +}: MapRouteProps) { + const { map, isLoaded } = useMap(); + const autoId = useId(); + const id = propId ?? autoId; + const sourceId = `route-source-${id}`; + const layerId = `route-layer-${id}`; + + // Add source and layer on mount + useEffect(() => { + if (!isLoaded || !map) return; + + map.addSource(sourceId, { + type: "geojson", + data: { + type: "Feature", + properties: {}, + geometry: { type: "LineString", coordinates: [] }, + }, + }); + + map.addLayer({ + id: layerId, + type: "line", + source: sourceId, + layout: { "line-join": "round", "line-cap": "round" }, + paint: { + "line-color": color, + "line-width": width, + "line-opacity": opacity, + ...(dashArray && { "line-dasharray": dashArray }), + }, + }); + + return () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + } catch { + // ignore + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, map]); + + // When coordinates change, update the source data + useEffect(() => { + if (!isLoaded || !map || coordinates.length < 2) return; + + const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; + if (source) { + source.setData({ + type: "Feature", + properties: {}, + geometry: { type: "LineString", coordinates }, + }); + } + }, [isLoaded, map, coordinates, sourceId]); + + useEffect(() => { + if (!isLoaded || !map || !map.getLayer(layerId)) return; + + map.setPaintProperty(layerId, "line-color", color); + map.setPaintProperty(layerId, "line-width", width); + map.setPaintProperty(layerId, "line-opacity", opacity); + map.setPaintProperty(layerId, "line-dasharray", dashArray); + }, [isLoaded, map, layerId, color, width, opacity, dashArray]); + + // Handle click and hover events + useEffect(() => { + if (!isLoaded || !map || !interactive) return; + + const handleClick = () => { + onClick?.(); + }; + const handleMouseEnter = () => { + map.getCanvas().style.cursor = "pointer"; + onMouseEnter?.(); + }; + const handleMouseLeave = () => { + map.getCanvas().style.cursor = ""; + onMouseLeave?.(); + }; + + map.on("click", layerId, handleClick); + map.on("mouseenter", layerId, handleMouseEnter); + map.on("mouseleave", layerId, handleMouseLeave); + + return () => { + map.off("click", layerId, handleClick); + map.off("mouseenter", layerId, handleMouseEnter); + map.off("mouseleave", layerId, handleMouseLeave); + }; + }, [ + isLoaded, + map, + layerId, + onClick, + onMouseEnter, + onMouseLeave, + interactive, + ]); + + return null; +} + +type MapGeoJSONData< + P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties, +> = + | GeoJSON.FeatureCollection + | GeoJSON.Feature + | GeoJSON.Geometry + | string; + +type MapFillPaint = NonNullable; +type MapLinePaint = NonNullable; + +/** A rendered feature with strongly-typed `properties`. */ +type MapGeoJSONFeature< + P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties, +> = Omit & { properties: P }; + +/** Event payload passed to MapGeoJSON interaction callbacks. */ +type MapGeoJSONEvent< + P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties, +> = { + /** The feature under the cursor, with its typed GeoJSON properties. */ + feature: MapGeoJSONFeature

; + /** Longitude of the cursor at the time of the event. */ + longitude: number; + /** Latitude of the cursor at the time of the event. */ + latitude: number; + /** The underlying MapLibre mouse event for advanced use cases. */ + originalEvent: MapLibreGL.MapLayerMouseEvent; +}; + +type MapGeoJSONProps< + P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties, +> = { + /** GeoJSON data (FeatureCollection, Feature, Geometry) or a URL to fetch it from. */ + data: MapGeoJSONData

; + /** Optional unique identifier prefix for the source/layers. Auto-generated if not provided. */ + id?: string; + /** + * Feature property to promote to the feature `id`. Required for hover + * feature-state (`fillHoverPaint`) and stable `onHover`/`onClick` payloads. + */ + promoteId?: string; + /** + * Paint for the polygon fill layer. Merged on top of a theme-aware monochrome + * surface tone (`fill-color`). Pass `false` to omit the fill layer entirely + * (e.g. outlines only). + */ + fillPaint?: MapFillPaint | false; + /** + * Paint for the outline layer. Merged on top of a theme-aware hairline + * default (`line-color` = page background, `line-width` = 0.5). Pass `false` + * to omit the outline layer. + */ + linePaint?: MapLinePaint | false; + /** + * Paint merged onto the fill layer for the feature under the cursor, applied + * as a `case` expression keyed on hover feature-state. Requires `promoteId`. + */ + fillHoverPaint?: MapFillPaint; + /** Callback when a feature is clicked. */ + onClick?: (e: MapGeoJSONEvent

) => void; + /** Callback fired when the hovered feature changes; `null` when the cursor leaves. */ + onHover?: (e: MapGeoJSONEvent

| null) => void; + /** Whether features respond to mouse events (default: false). */ + interactive?: boolean; + /** Optional MapLibre layer id to insert the layers before (z-order control). */ + beforeId?: string; +}; + +// Theme-aware monochrome defaults so MapGeoJSON reads +// clearly on the light/dark surface out of the box: a visible neutral-gray fill +// with page-background separators between shapes. Override either via +// `fillPaint` / `linePaint`. +const GEOJSON_DEFAULT_COLORS = { + light: { fill: "#d4d4d4", line: "#fafafa" }, + dark: { fill: "#404040", line: "#0a0a0a" }, +} satisfies Record; + +/** + * Renders arbitrary GeoJSON as fill + outline layers on the map. Composes like + * `MapRoute` / `MapArc` β€” drop it inside `` (typically with `blank`) for + * choropleths and region/data maps. For full control over expressions and + * multiple layers, manage layers directly via `useMap()` instead. + */ +function MapGeoJSON< + P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties, +>({ + data, + id: propId, + promoteId, + fillPaint, + linePaint, + fillHoverPaint, + onClick, + onHover, + interactive = false, + beforeId, +}: MapGeoJSONProps

) { + const { map, isLoaded, resolvedTheme } = useMap(); + const autoId = useId(); + const id = propId ?? autoId; + const sourceId = `geojson-source-${id}`; + const fillLayerId = `geojson-fill-${id}`; + const lineLayerId = `geojson-line-${id}`; + + const defaults = GEOJSON_DEFAULT_COLORS[resolvedTheme]; + + const showFill = fillPaint !== false; + const showLine = linePaint !== false; + + const mergedFillPaint = useMemo( + () => + mergeHoverPaint( + { "fill-color": defaults.fill, ...(fillPaint || {}) }, + fillHoverPaint, + ), + [defaults.fill, fillPaint, fillHoverPaint], + ); + const mergedLinePaint = useMemo( + () => ({ + "line-color": defaults.line, + "line-width": 0.5, + ...(linePaint || {}), + }), + [defaults.line, linePaint], + ); + const latestRef = useRef({ onClick, onHover }); + latestRef.current = { onClick, onHover }; + + // Add source on mount. + useEffect(() => { + if (!isLoaded || !map) return; + + map.addSource(sourceId, { + type: "geojson", + data, + ...(promoteId ? { promoteId } : {}), + }); + + return () => { + try { + if (map.getLayer(lineLayerId)) map.removeLayer(lineLayerId); + if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + } catch { + // style may be mid-reload + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, map]); + + // Sync data when it changes. + useEffect(() => { + if (!isLoaded || !map) return; + const source = map.getSource(sourceId) as + | MapLibreGL.GeoJSONSource + | undefined; + source?.setData(data as never); + }, [isLoaded, map, data, sourceId]); + + // Sync layers and paint when visibility or styling changes. + useEffect(() => { + if (!isLoaded || !map) return; + + const source = map.getSource(sourceId); + if (!source) return; + + if (showFill && !map.getLayer(fillLayerId)) { + map.addLayer( + { + id: fillLayerId, + type: "fill", + source: sourceId, + paint: mergedFillPaint, + }, + beforeId, + ); + } else if (!showFill && map.getLayer(fillLayerId)) { + map.removeLayer(fillLayerId); + } + + if (showLine && !map.getLayer(lineLayerId)) { + map.addLayer( + { + id: lineLayerId, + type: "line", + source: sourceId, + paint: mergedLinePaint, + }, + beforeId, + ); + } else if (!showLine && map.getLayer(lineLayerId)) { + map.removeLayer(lineLayerId); + } + + if (showFill && map.getLayer(fillLayerId)) { + for (const [key, value] of Object.entries(mergedFillPaint)) { + map.setPaintProperty( + fillLayerId, + key as keyof MapFillPaint, + value as never, + ); + } + } + if (showLine && map.getLayer(lineLayerId)) { + for (const [key, value] of Object.entries(mergedLinePaint)) { + map.setPaintProperty( + lineLayerId, + key as keyof MapLinePaint, + value as never, + ); + } + } + }, [ + isLoaded, + map, + sourceId, + fillLayerId, + lineLayerId, + showFill, + showLine, + mergedFillPaint, + mergedLinePaint, + beforeId, + ]); + + // Interaction handlers (bound to the fill layer). + useEffect(() => { + if (!isLoaded || !map || !interactive || !showFill) return; + + let hoveredId: string | number | null = null; + + const setHover = (next: string | number | null) => { + if (next === hoveredId) return; + const sourceExists = !!map.getSource(sourceId); + if (hoveredId != null && sourceExists) { + map.setFeatureState( + { source: sourceId, id: hoveredId }, + { hover: false }, + ); + } + hoveredId = next; + if (next != null && sourceExists) { + map.setFeatureState({ source: sourceId, id: next }, { hover: true }); + } + }; + + const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => { + const feature = e.features?.[0]; + if (!feature) return; + map.getCanvas().style.cursor = "pointer"; + + const featureId = feature.id; + if (featureId === hoveredId) return; + setHover(featureId ?? null); + latestRef.current.onHover?.({ + feature: feature as unknown as MapGeoJSONFeature

, + longitude: e.lngLat.lng, + latitude: e.lngLat.lat, + originalEvent: e, + }); + }; + + const handleMouseLeave = () => { + setHover(null); + map.getCanvas().style.cursor = ""; + latestRef.current.onHover?.(null); + }; + + const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => { + const feature = e.features?.[0]; + if (!feature) return; + latestRef.current.onClick?.({ + feature: feature as unknown as MapGeoJSONFeature

, + longitude: e.lngLat.lng, + latitude: e.lngLat.lat, + originalEvent: e, + }); + }; + + map.on("mousemove", fillLayerId, handleMouseMove); + map.on("mouseleave", fillLayerId, handleMouseLeave); + map.on("click", fillLayerId, handleClick); + + return () => { + map.off("mousemove", fillLayerId, handleMouseMove); + map.off("mouseleave", fillLayerId, handleMouseLeave); + map.off("click", fillLayerId, handleClick); + setHover(null); + map.getCanvas().style.cursor = ""; + }; + }, [isLoaded, map, fillLayerId, sourceId, interactive, showFill]); + + return null; +} + +/** A single arc to render inside . */ +type MapArcDatum = { + /** Unique identifier for this arc. Required for hover state tracking and event payloads. */ + id: string | number; + /** Start coordinate as [longitude, latitude]. */ + from: [number, number]; + /** End coordinate as [longitude, latitude]. */ + to: [number, number]; +}; + +/** Event payload passed to MapArc interaction callbacks. */ +type MapArcEvent = { + /** The arc datum that was hovered or clicked. */ + arc: T; + /** Longitude of the cursor at the time of the event. */ + longitude: number; + /** Latitude of the cursor at the time of the event. */ + latitude: number; + /** The underlying MapLibre mouse event for advanced use cases. */ + originalEvent: MapLibreGL.MapMouseEvent; +}; + +type MapArcLinePaint = NonNullable; +type MapArcLineLayout = NonNullable< + MapLibreGL.LineLayerSpecification["layout"] +>; + +type MapArcProps = { + /** Array of arcs to render. Each arc must have a unique `id`. */ + data: T[]; + /** Optional unique identifier prefix for the arc source/layers. Auto-generated if not provided. */ + id?: string; + /** + * How far each arc bows away from a straight line. `0` renders straight + * lines; higher values bend further. Negative values bend to the opposite + * side. Arcs are computed as a quadratic BΓ©zier in lng/lat space; the + * destination longitude is unwrapped relative to the origin so that arcs + * cross the antimeridian via the shorter great-circle direction. (default: 0.2) + */ + curvature?: number; + /** Number of samples used to render each curve. Higher = smoother. (default: 64) */ + samples?: number; + /** + * MapLibre paint properties for the arc layer. Merged on top of sensible + * defaults (`line-color: #4285F4`, `line-width: 2`, `line-opacity: 0.85`). + * Any value can be a MapLibre expression for per-feature styling, every + * field on each arc datum (besides `from`/`to`) is exposed via `["get", ...]`. + */ + paint?: MapArcLinePaint; + /** MapLibre layout properties for the arc layer. Defaults to rounded joins/caps. */ + layout?: MapArcLineLayout; + /** + * Paint properties applied to the arc currently under the cursor. Each key + * is merged into `paint` as a `case` expression keyed on per-feature hover + * state, so only the hovered arc changes appearance. + */ + hoverPaint?: MapArcLinePaint; + /** Callback when an arc is clicked. */ + onClick?: (e: MapArcEvent) => void; + /** + * Callback fired when the hovered arc changes. Receives the cursor's + * lng/lat at the moment of entry, and `null` when the cursor leaves the + * last hovered arc. + */ + onHover?: (e: MapArcEvent | null) => void; + /** Whether arcs respond to mouse events (default: true). */ + interactive?: boolean; + /** Optional MapLibre layer id to insert the arc layers before (z-order control). */ + beforeId?: string; +}; + +const DEFAULT_ARC_CURVATURE = 0.2; +const DEFAULT_ARC_SAMPLES = 64; +const ARC_HIT_MIN_WIDTH = 12; +const ARC_HIT_PADDING = 6; + +const DEFAULT_ARC_PAINT: MapArcLinePaint = { + "line-color": "#4285F4", + "line-width": 2, + "line-opacity": 0.85, +}; + +const DEFAULT_ARC_LAYOUT: MapArcLineLayout = { + "line-join": "round", + "line-cap": "round", +}; + +function buildArcCoordinates( + from: [number, number], + to: [number, number], + curvature: number, + samples: number, +): [number, number][] { + const [x0, y0] = from; + const [xTo, y2] = to; + // Unwrap the destination longitude so |dx| <= 180. This makes arcs that + // straddle the antimeridian (e.g. Tokyo -> San Francisco) bow the short way + // across the Pacific instead of the long way around the globe. Resulting + // longitudes may fall outside [-180, 180]; MapLibre renders them correctly + // on the globe projection, and on mercator when world copies are enabled. + const rawDx = xTo - x0; + const x2 = rawDx > 180 ? xTo - 360 : rawDx < -180 ? xTo + 360 : xTo; + const dx = x2 - x0; + const dy = y2 - y0; + const distance = Math.hypot(dx, dy); + + if (distance === 0 || curvature === 0) return [from, [x2, y2]]; + + const mx = (x0 + x2) / 2; + const my = (y0 + y2) / 2; + const nx = -dy / distance; + const ny = dx / distance; + const offset = distance * curvature; + const cx = mx + nx * offset; + const cy = my + ny * offset; + + const points: [number, number][] = []; + const segments = Math.max(2, Math.floor(samples)); + for (let i = 0; i <= segments; i += 1) { + const t = i / segments; + const inv = 1 - t; + const x = inv * inv * x0 + 2 * inv * t * cx + t * t * x2; + const y = inv * inv * y0 + 2 * inv * t * cy + t * t * y2; + points.push([x, y]); + } + return points; +} + +function MapArc({ + data, + id: propId, + curvature = DEFAULT_ARC_CURVATURE, + samples = DEFAULT_ARC_SAMPLES, + paint, + layout, + hoverPaint, + onClick, + onHover, + interactive = true, + beforeId, +}: MapArcProps) { + const { map, isLoaded } = useMap(); + const autoId = useId(); + const id = propId ?? autoId; + const sourceId = `arc-source-${id}`; + const layerId = `arc-layer-${id}`; + const hitLayerId = `arc-hit-layer-${id}`; + + const mergedPaint = useMemo( + () => mergeHoverPaint({ ...DEFAULT_ARC_PAINT, ...paint }, hoverPaint), + [paint, hoverPaint], + ); + const mergedLayout = useMemo( + () => ({ ...DEFAULT_ARC_LAYOUT, ...layout }), + [layout], + ); + + const hitWidth = useMemo(() => { + const w = paint?.["line-width"] ?? DEFAULT_ARC_PAINT["line-width"]; + const base = typeof w === "number" ? w : ARC_HIT_MIN_WIDTH; + return Math.max(base + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH); + }, [paint]); + + const geoJSON = useMemo>( + () => ({ + type: "FeatureCollection", + features: data.map((arc) => { + const { from, to, ...properties } = arc; + return { + type: "Feature", + properties, + geometry: { + type: "LineString", + coordinates: buildArcCoordinates(from, to, curvature, samples), + }, + }; + }), + }), + [data, curvature, samples], + ); + + const latestRef = useRef({ data, onClick, onHover }); + latestRef.current = { data, onClick, onHover }; + + // Add source and layers on mount. + useEffect(() => { + if (!isLoaded || !map) return; + + map.addSource(sourceId, { + type: "geojson", + data: geoJSON, + promoteId: "id", + }); + + map.addLayer( + { + id: hitLayerId, + type: "line", + source: sourceId, + layout: DEFAULT_ARC_LAYOUT, + paint: { + "line-color": "rgba(0, 0, 0, 0)", + "line-width": hitWidth, + "line-opacity": 1, + }, + }, + beforeId, + ); + + map.addLayer( + { + id: layerId, + type: "line", + source: sourceId, + layout: mergedLayout, + paint: mergedPaint, + }, + beforeId, + ); + + return () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + if (map.getLayer(hitLayerId)) map.removeLayer(hitLayerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + } catch { + // ignore + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, map]); + + // Sync features when data / curvature / samples change. + useEffect(() => { + if (!isLoaded || !map) return; + const source = map.getSource(sourceId) as + | MapLibreGL.GeoJSONSource + | undefined; + source?.setData(geoJSON); + }, [isLoaded, map, geoJSON, sourceId]); + + // Sync paint/layout when they change. + useEffect(() => { + if (!isLoaded || !map || !map.getLayer(layerId)) return; + for (const [key, value] of Object.entries(mergedPaint)) { + map.setPaintProperty( + layerId, + key as keyof MapArcLinePaint, + value as never, + ); + } + for (const [key, value] of Object.entries(mergedLayout)) { + map.setLayoutProperty( + layerId, + key as keyof MapArcLineLayout, + value as never, + ); + } + if (map.getLayer(hitLayerId)) { + map.setPaintProperty(hitLayerId, "line-width", hitWidth); + } + }, [isLoaded, map, layerId, hitLayerId, mergedPaint, mergedLayout, hitWidth]); + + // Interaction handlers + useEffect(() => { + if (!isLoaded || !map || !interactive) return; + + let hoveredId: string | number | null = null; + + const setHover = (next: string | number | null) => { + if (next === hoveredId) return; + const sourceExists = !!map.getSource(sourceId); + if (hoveredId != null && sourceExists) { + map.setFeatureState( + { source: sourceId, id: hoveredId }, + { hover: false }, + ); + } + hoveredId = next; + if (next != null && sourceExists) { + map.setFeatureState({ source: sourceId, id: next }, { hover: true }); + } + }; + + const findArc = (featureId: string | number | undefined) => + featureId == null + ? undefined + : latestRef.current.data.find( + (arc) => String(arc.id) === String(featureId), + ); + + const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => { + const featureId = e.features?.[0]?.id as string | number | undefined; + if (featureId == null || featureId === hoveredId) return; + + setHover(featureId); + map.getCanvas().style.cursor = "pointer"; + + const arc = findArc(featureId); + if (arc) { + latestRef.current.onHover?.({ + arc: arc as T, + longitude: e.lngLat.lng, + latitude: e.lngLat.lat, + originalEvent: e, + }); + } + }; + + const handleMouseLeave = () => { + setHover(null); + map.getCanvas().style.cursor = ""; + latestRef.current.onHover?.(null); + }; + + const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => { + const arc = findArc(e.features?.[0]?.id as string | number | undefined); + if (!arc) return; + latestRef.current.onClick?.({ + arc: arc as T, + longitude: e.lngLat.lng, + latitude: e.lngLat.lat, + originalEvent: e, + }); + }; + + map.on("mousemove", hitLayerId, handleMouseMove); + map.on("mouseleave", hitLayerId, handleMouseLeave); + map.on("click", hitLayerId, handleClick); + + return () => { + map.off("mousemove", hitLayerId, handleMouseMove); + map.off("mouseleave", hitLayerId, handleMouseLeave); + map.off("click", hitLayerId, handleClick); + setHover(null); + map.getCanvas().style.cursor = ""; + }; + }, [isLoaded, map, hitLayerId, sourceId, interactive]); + + return null; +} + +type MapClusterLayerProps< + P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties, +> = { + /** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */ + data: string | GeoJSON.FeatureCollection; + /** Maximum zoom level to cluster points on (default: 14) */ + clusterMaxZoom?: number; + /** Radius of each cluster when clustering points in pixels (default: 50) */ + clusterRadius?: number; + /** Colors for cluster circles: [small, medium, large] based on point count (default: ["#22c55e", "#eab308", "#ef4444"]) */ + clusterColors?: [string, string, string]; + /** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */ + clusterThresholds?: [number, number]; + /** Color for unclustered individual points (default: "#3b82f6") */ + pointColor?: string; + /** Callback when an unclustered point is clicked */ + onPointClick?: ( + feature: GeoJSON.Feature, + coordinates: [number, number], + ) => void; + /** Callback when a cluster is clicked. If not provided, zooms into the cluster */ + onClusterClick?: ( + clusterId: number, + coordinates: [number, number], + pointCount: number, + ) => void; +}; + +const DEFAULT_CLUSTER_COLORS: [string, string, string] = [ + "#22c55e", + "#eab308", + "#ef4444", +]; +const DEFAULT_CLUSTER_THRESHOLDS: [number, number] = [100, 750]; + +function MapClusterLayer< + P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties, +>({ + data, + clusterMaxZoom = 14, + clusterRadius = 50, + clusterColors = DEFAULT_CLUSTER_COLORS, + clusterThresholds = DEFAULT_CLUSTER_THRESHOLDS, + pointColor = "#3b82f6", + onPointClick, + onClusterClick, +}: MapClusterLayerProps

) { + const { map, isLoaded } = useMap(); + const id = useId(); + const sourceId = `cluster-source-${id}`; + const clusterLayerId = `clusters-${id}`; + const clusterCountLayerId = `cluster-count-${id}`; + const unclusteredLayerId = `unclustered-point-${id}`; + + const stylePropsRef = useRef({ + clusterColors, + clusterThresholds, + pointColor, + }); + + // Add source and layers on mount + useEffect(() => { + if (!isLoaded || !map) return; + + // Add clustered GeoJSON source + map.addSource(sourceId, { + type: "geojson", + data, + cluster: true, + clusterMaxZoom, + clusterRadius, + }); + + // Add cluster circles layer + map.addLayer({ + id: clusterLayerId, + type: "circle", + source: sourceId, + filter: ["has", "point_count"], + paint: { + "circle-color": [ + "step", + ["get", "point_count"], + clusterColors[0], + clusterThresholds[0], + clusterColors[1], + clusterThresholds[1], + clusterColors[2], + ], + "circle-radius": [ + "step", + ["get", "point_count"], + 20, + clusterThresholds[0], + 30, + clusterThresholds[1], + 40, + ], + "circle-stroke-width": 1, + "circle-stroke-color": "#fff", + "circle-opacity": 0.85, + }, + }); + + // Add cluster count text layer + map.addLayer({ + id: clusterCountLayerId, + type: "symbol", + source: sourceId, + filter: ["has", "point_count"], + layout: { + "text-field": "{point_count_abbreviated}", + "text-font": ["Open Sans"], + "text-size": 12, + }, + paint: { + "text-color": "#fff", + }, + }); + + // Add unclustered point layer + map.addLayer({ + id: unclusteredLayerId, + type: "circle", + source: sourceId, + filter: ["!", ["has", "point_count"]], + paint: { + "circle-color": pointColor, + "circle-radius": 5, + "circle-stroke-width": 2, + "circle-stroke-color": "#fff", + }, + }); + + return () => { + try { + if (map.getLayer(clusterCountLayerId)) + map.removeLayer(clusterCountLayerId); + if (map.getLayer(unclusteredLayerId)) + map.removeLayer(unclusteredLayerId); + if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + } catch { + // ignore + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, map, sourceId]); + + // Update source data when data prop changes (only for non-URL data) + useEffect(() => { + if (!isLoaded || !map || typeof data === "string") return; + + const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; + if (source) { + source.setData(data); + } + }, [isLoaded, map, data, sourceId]); + + // Update layer styles when props change + useEffect(() => { + if (!isLoaded || !map) return; + + const prev = stylePropsRef.current; + const colorsChanged = + prev.clusterColors !== clusterColors || + prev.clusterThresholds !== clusterThresholds; + + // Update cluster layer colors and sizes + if (map.getLayer(clusterLayerId) && colorsChanged) { + map.setPaintProperty(clusterLayerId, "circle-color", [ + "step", + ["get", "point_count"], + clusterColors[0], + clusterThresholds[0], + clusterColors[1], + clusterThresholds[1], + clusterColors[2], + ]); + map.setPaintProperty(clusterLayerId, "circle-radius", [ + "step", + ["get", "point_count"], + 20, + clusterThresholds[0], + 30, + clusterThresholds[1], + 40, + ]); + } + + // Update unclustered point layer color + if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) { + map.setPaintProperty(unclusteredLayerId, "circle-color", pointColor); + } + + stylePropsRef.current = { clusterColors, clusterThresholds, pointColor }; + }, [ + isLoaded, + map, + clusterLayerId, + unclusteredLayerId, + clusterColors, + clusterThresholds, + pointColor, + ]); + + // Handle click events + useEffect(() => { + if (!isLoaded || !map) return; + + // Cluster click handler - zoom into cluster + const handleClusterClick = async ( + e: MapLibreGL.MapMouseEvent & { + features?: MapLibreGL.MapGeoJSONFeature[]; + }, + ) => { + const features = map.queryRenderedFeatures(e.point, { + layers: [clusterLayerId], + }); + if (!features.length) return; + + const feature = features[0]; + const clusterId = feature.properties?.cluster_id as number; + const pointCount = feature.properties?.point_count as number; + const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [ + number, + number, + ]; + + if (onClusterClick) { + onClusterClick(clusterId, coordinates, pointCount); + } else { + // Default behavior: zoom to cluster expansion zoom + const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; + const zoom = await source.getClusterExpansionZoom(clusterId); + map.easeTo({ + center: coordinates, + zoom, + }); + } + }; + + // Unclustered point click handler + const handlePointClick = ( + e: MapLibreGL.MapMouseEvent & { + features?: MapLibreGL.MapGeoJSONFeature[]; + }, + ) => { + if (!onPointClick || !e.features?.length) return; + + const feature = e.features[0]; + const coordinates = ( + feature.geometry as GeoJSON.Point + ).coordinates.slice() as [number, number]; + + // Handle world copies + while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { + coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; + } + + onPointClick( + feature as unknown as GeoJSON.Feature, + coordinates, + ); + }; + + // Cursor style handlers + const handleMouseEnterCluster = () => { + map.getCanvas().style.cursor = "pointer"; + }; + const handleMouseLeaveCluster = () => { + map.getCanvas().style.cursor = ""; + }; + const handleMouseEnterPoint = () => { + if (onPointClick) { + map.getCanvas().style.cursor = "pointer"; + } + }; + const handleMouseLeavePoint = () => { + map.getCanvas().style.cursor = ""; + }; + + map.on("click", clusterLayerId, handleClusterClick); + map.on("click", unclusteredLayerId, handlePointClick); + map.on("mouseenter", clusterLayerId, handleMouseEnterCluster); + map.on("mouseleave", clusterLayerId, handleMouseLeaveCluster); + map.on("mouseenter", unclusteredLayerId, handleMouseEnterPoint); + map.on("mouseleave", unclusteredLayerId, handleMouseLeavePoint); + + return () => { + map.off("click", clusterLayerId, handleClusterClick); + map.off("click", unclusteredLayerId, handlePointClick); + map.off("mouseenter", clusterLayerId, handleMouseEnterCluster); + map.off("mouseleave", clusterLayerId, handleMouseLeaveCluster); + map.off("mouseenter", unclusteredLayerId, handleMouseEnterPoint); + map.off("mouseleave", unclusteredLayerId, handleMouseLeavePoint); + }; + }, [ + isLoaded, + map, + clusterLayerId, + unclusteredLayerId, + sourceId, + onClusterClick, + onPointClick, + ]); + + return null; +} + +export { + Map, + useMap, + MapMarker, + MarkerContent, + MarkerPopup, + MarkerTooltip, + MarkerLabel, + MapPopup, + MapControls, + MapRoute, + MapArc, + MapGeoJSON, + MapClusterLayer, +}; + +export type { MapRef, MapViewport, MapArcDatum, MapArcEvent, MapGeoJSONEvent }; diff --git a/src/components/ui/visualize-booking.tsx b/src/components/ui/visualize-booking.tsx new file mode 100644 index 000000000..530d18ee2 --- /dev/null +++ b/src/components/ui/visualize-booking.tsx @@ -0,0 +1,458 @@ +import React, { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { cn } from '@/lib/utils'; + +// Standard 7-day week calendar columns header +const daysOfWeek = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; + +export type DayType = { + day: string; + classNames: string; + meetingInfo?: { + id: string; + date: string; + time: string; + title: string; + participants: string[]; + location: string; + }[]; +}; + +export interface CalendarBooking { + id: string; + venueId: string | number; + venueTitle: string; + venueLocation: string; + startDate: string; + endDate: string; + guests: number; + totalPrice: number; + status: string; + renterName: string | null; + renterEmail: string | null; + bookingType?: string; +} + +interface DayProps { + classNames: string; + day: DayType; + onHover: (day: string | null) => void; + onClick: (day: string) => void; + isSelected: boolean; + isToday: boolean; +} + +const Day: React.FC = ({ classNames, day, onHover, onClick, isSelected, isToday }) => { + const [isHovered, setIsHovered] = useState(false); + const isFiller = day.day[0] === '+' || day.day[0] === '-'; + + return ( + <> + { + if (!isFiller) { + setIsHovered(true); + onHover(day.day); + } + }} + onMouseLeave={() => { + if (!isFiller) { + setIsHovered(false); + onHover(null); + } + }} + onClick={() => { + if (day.day && !isFiller) { + onClick(day.day); + } + }} + id={`day-${day.day}`} + > + + {!isFiller && ( + {day.day} + )} + {isToday && ( + + )} + + {day.meetingInfo && ( + + {day.meetingInfo.length} + + )} + + + {day.meetingInfo && isHovered && ( +

+ + {day.meetingInfo.length} + +
+ )} + + + + ); +}; + +const CalendarGrid: React.FC<{ + days: DayType[]; + onHover: (day: string | null) => void; + onClick: (day: string) => void; + selectedDay: string | null; + currentDate: Date; +}> = ({ days, onHover, onClick, selectedDay, currentDate }) => { + const today = new Date(); + const todayYear = today.getFullYear(); + const todayMonth = today.getMonth(); + const todayDayStr = String(today.getDate()).padStart(2, '0'); + + return ( +
+ {days.map((day, index) => { + const isFiller = day.day[0] === '+' || day.day[0] === '-'; + const isToday = + !isFiller && + day.day === todayDayStr && + currentDate.getMonth() === todayMonth && + currentDate.getFullYear() === todayYear; + + return ( + + ); + })} +
+ ); +}; + +interface InteractiveCalendarProps extends React.HTMLAttributes { + bookings?: CalendarBooking[]; +} + +const InteractiveCalendar = React.forwardRef< + HTMLDivElement, + InteractiveCalendarProps +>((({ className, bookings, ...props }, ref) => { + const navigate = useNavigate(); + const [currentDate, setCurrentDate] = useState(() => new Date()); + const [hoveredDay, setHoveredDay] = useState(null); + const [selectedDay, setSelectedDay] = useState(null); + + const handlePrevMonth = () => { + setCurrentDate(prev => new Date(prev.getFullYear(), prev.getMonth() - 1, 1)); + setHoveredDay(null); + setSelectedDay(null); + }; + + const handleNextMonth = () => { + setCurrentDate(prev => new Date(prev.getFullYear(), prev.getMonth() + 1, 1)); + setHoveredDay(null); + setSelectedDay(null); + }; + + const handleDayHover = (day: string | null) => { + setHoveredDay(day); + }; + + const handleDayClick = (day: string) => { + setSelectedDay((prev) => (prev === day ? null : day)); + }; + + // Dynamically generate the days for the current month/year based on provided bookings + const dynamicDays = React.useMemo(() => { + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); // 0-indexed + + // First day of current month + const firstDay = new Date(year, month, 1); + const startDayOfWeek = firstDay.getDay(); // 0 = Sun, 6 = Sat + + // Days in current month + const totalDaysInMonth = new Date(year, month + 1, 0).getDate(); + + // Days in previous month (for filler) + const prevMonthDays = new Date(year, month, 0).getDate(); + + const days: DayType[] = []; + + // 1. Fill in previous month's days + for (let i = startDayOfWeek - 1; i >= 0; i--) { + const dayNum = prevMonthDays - i; + days.push({ + day: `-${dayNum}`, + classNames: 'bg-zinc-700/10 opacity-30 cursor-default', + }); + } + + // 2. Fill in current month's days + for (let i = 1; i <= totalDaysInMonth; i++) { + const dayStr = i < 10 ? `0${i}` : `${i}`; + // Construct date string for this day (YYYY-MM-DD) + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${dayStr}`; + + // Filter bookings that cover this date + const activeBookings = (bookings || []).filter(b => { + if (b.status === 'cancelled') return false; + + // Normalize strings to compare YYYY-MM-DD + const bStart = b.startDate.split('T')[0]; + const bEnd = b.endDate.split('T')[0]; + + return dateStr >= bStart && dateStr <= bEnd; + }); + + const formatTime = (dateStr: string) => { + return new Date(dateStr).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + }; + + const meetingInfo = activeBookings.length > 0 + ? activeBookings.map(b => { + const isHourly = b.bookingType === 'hours'; + const dateText = isHourly + ? new Date(b.startDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + : `${new Date(b.startDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} - ${new Date(b.endDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`; + + let timeText = b.status === 'offline' ? 'Offline Lock' : `Guests: ${b.guests}`; + if (isHourly) { + const startT = formatTime(b.startDate); + const endT = formatTime(b.endDate); + timeText = `${startT} - ${endT}`; + } + + const participantsList = b.status === 'offline' + ? ['Offline Block', b.renterName].filter(Boolean) + : [b.renterName || 'Registered User', b.renterEmail || ''].filter(Boolean); + + if (isHourly && b.status !== 'offline') { + participantsList.push(`${b.guests} Guests`); + } else if (isHourly && b.status === 'offline') { + participantsList.push('Offline Lock'); + } + + return { + id: String(b.id), + date: dateText, + time: timeText, + title: b.venueTitle, + participants: participantsList, + location: b.venueLocation, + }; + }) + : undefined; + + days.push({ + day: dayStr, + classNames: `bg-[#1e1e1e] ${meetingInfo ? 'cursor-pointer' : ''}`, + meetingInfo, + }); + } + + // 3. Fill in next month's days to complete a 6-row grid (42 cells total) + const totalGridCells = 42; + const nextMonthDaysCount = totalGridCells - days.length; + for (let i = 1; i <= nextMonthDaysCount; i++) { + const dayStr = i < 10 ? `0${i}` : `${i}`; + days.push({ + day: `+${dayStr}`, + classNames: 'bg-zinc-700/10 opacity-30 cursor-default', + }); + } + + return days; + }, [currentDate, bookings]); + + const sortedDays = React.useMemo(() => { + const activeDay = hoveredDay || selectedDay; + if (!activeDay) return dynamicDays; + return [...dynamicDays].sort((a, b) => { + if (a.day === activeDay) return -1; + if (b.day === activeDay) return 1; + return 0; + }); + }, [hoveredDay, selectedDay, dynamicDays]); + + return ( + + + + + {/* Header controls with month navigation */} +
+ + {currentDate.toLocaleString('default', { month: 'long' })}{' '} + {currentDate.getFullYear()} + + +
+ + +
+
+ +
+ {daysOfWeek.map((day) => ( +
+ {day} +
+ ))} +
+ +
+
+ + + +
+ + Bookings + +

+ See upcoming and past events booked through your event type + links. +

+
+ + + {sortedDays.filter((day) => day.meetingInfo).length === 0 ? ( +
+ + No active bookings scheduled for this month. + +
+ ) : ( + sortedDays + .filter((day) => day.meetingInfo) + .map((day) => ( + + {day.meetingInfo && + day.meetingInfo.map((meeting, mIndex) => ( + +
+ + {meeting.date} + + + {meeting.time} + +
+

+ {meeting.title} +

+

+ {meeting.participants.join(', ')} +

+ +
+ ))} +
+ )) + )} +
+
+
+
+
+
+ ); +})); +InteractiveCalendar.displayName = 'InteractiveCalendar'; + +export default InteractiveCalendar; + + diff --git a/src/data/venuesData.ts b/src/data/venuesData.ts new file mode 100644 index 000000000..1fb2def4f --- /dev/null +++ b/src/data/venuesData.ts @@ -0,0 +1,114 @@ +export interface Venue { + id: string; + images: string[]; + tags: string[]; + location: string; + capacity: number; + rating: number; + title: string; + dateRange: string; + hostType: string; + isTopRated?: boolean; + description: string; + pricePerNight: number; + fullAddress: string; + parking: string; + amenities: string[]; + squareFeet: number; + catering: string; + rules: string[]; + eventTypes: string[]; + bookingType?: 'days' | 'hours'; + cleaningGap?: number; + openingTime?: string; + closingTime?: string; + status?: 'pending' | 'approved' | 'declined'; + rejectionReason?: string; + latitude?: number | null; + longitude?: number | null; +} + +export const sampleVenues: Venue[] = [ + { + id: '1', + images: [ + 'https://images.unsplash.com/photo-1519167758481-83f550bb49b3?q=80&w=800&auto=format&fit=crop', + 'https://images.unsplash.com/photo-1464366400600-7168b8af9bc3?q=80&w=800&auto=format&fit=crop', + 'https://images.unsplash.com/photo-1511795409834-ef04bbd61622?q=80&w=800&auto=format&fit=crop', + ], + tags: ['Los Angeles', '100 Guests', 'Luxury'], + location: 'Los Angeles', + capacity: 100, + rating: 4.9, + title: 'The Glass Pavilion', + dateRange: 'Jun 12 - 18', + hostType: 'Superhost', + isTopRated: true, + description: 'An architectural masterpiece featuring 360-degree glass walls, high ceilings, and stunning garden views.', + pricePerNight: 450, + fullAddress: '10450 Wilshire Blvd, Los Angeles, CA 90024', + parking: 'Valet parking available for up to 80 cars, underground secure parking garage with 120 dedicated slots, and multiple active EV charging stations.', + amenities: ['High-speed Wi-Fi', 'Professional Sound System', 'Bridal Suite', 'Full AC & Heating', 'Ambient LED Lighting', 'Outdoor Garden Area', 'Stage & Podium'], + squareFeet: 8500, + catering: 'In-house gourmet catering available (fully custom menu arrangements), outside licensed & insured caterers allowed upon approval.', + rules: ['Music must transition to indoor after 11 PM', 'No confetti, glitter, or open flames permitted', 'Licensed and certified bartenders required for any alcohol service'], + eventTypes: ['Weddings', 'Corporate Galas', 'Cocktail Receptions', 'Art Exhibitions'], + }, + { + id: '2', + images: [ + 'https://images.unsplash.com/photo-1540555700478-4be289fbecef?q=80&w=800&auto=format&fit=crop', + 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?q=80&w=800&auto=format&fit=crop', + 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=800&auto=format&fit=crop', + ], + tags: ['Miami', '10 Guests', 'Ocean View'], + location: 'Miami', + capacity: 10, + rating: 4.8, + title: 'Sunset Bay Villa', + dateRange: 'Jul 20 - 25', + hostType: 'Exclusive Agency', + isTopRated: false, + description: 'A luxurious beachfront villa perfect for intimate weddings, corporate retreats, or elite gatherings.', + pricePerNight: 720, + fullAddress: '45 Ocean Drive, Key Biscayne, Miami, FL 33149', + parking: 'Private gated driveway fitting up to 15 vehicles, surrounding street parking available, and optional customized guest shuttle arrangements.', + amenities: ['Private Beach Access', 'Infinity Pool', 'State-of-the-Art Kitchen', 'Outdoor Barbecue & Bar', 'Smart Home Integration', 'High-speed Wi-Fi', 'Ocean View Terrace'], + squareFeet: 5200, + catering: 'Private gourmet chef experiences can be curated, outside catering permitted upon prior coordination and approval.', + rules: ['Quiet hours from 10 PM to 8 AM', 'Strictly no smoking inside the premises', 'Maximum occupancy for daytime social events is 30 guests'], + eventTypes: ['Intimate Weddings', 'Executive Retreats', 'VVIP Dinners', 'Private Celebrations'], + }, +]; + +export const getVenues = (): Venue[] => { + const stored = localStorage.getItem('bookmyvenue_data'); + if (!stored) { + localStorage.setItem('bookmyvenue_data', JSON.stringify(sampleVenues)); + return sampleVenues; + } + try { + return JSON.parse(stored); + } catch (e) { + return sampleVenues; + } +}; + +export const addVenue = (venue: Venue) => { + const current = getVenues(); + const updated = [...current, venue]; + localStorage.setItem('bookmyvenue_data', JSON.stringify(updated)); +}; + +export const updateVenueInLocalStorage = (id: string, updatedVenue: Partial) => { + const current = getVenues(); + const updated = current.map(v => v.id === id ? { ...v, ...updatedVenue } : v); + localStorage.setItem('bookmyvenue_data', JSON.stringify(updated)); +}; + +export const deleteVenueFromLocalStorage = (id: string) => { + const current = getVenues(); + const updated = current.filter(v => v.id !== id); + localStorage.setItem('bookmyvenue_data', JSON.stringify(updated)); +}; + diff --git a/src/index.css b/src/index.css new file mode 100644 index 000000000..10f6dd6a5 --- /dev/null +++ b/src/index.css @@ -0,0 +1,84 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + --radius: 0.5rem; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + } +} + +@layer base { + * { + border-color: hsl(var(--border)); + } +} + +html, body, #root { + height: 100%; + margin: 0; + font-family: 'Neue Haas Grotesk Display Pro 55 Roman', 'Neue Haas Grotesk Text Pro', 'Helvetica Neue', Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; +} + +/* Premium aesthetic: Hide native scrollbar indicators but preserve scrolling behavior */ +.custom-scrollbar::-webkit-scrollbar { + display: none; +} +.custom-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +/* Customize date input calendar picker indicator */ +input[type="date"]::-webkit-calendar-picker-indicator { + cursor: pointer; + filter: invert(70%) sepia(28%) saturate(1478%) hue-rotate(351deg) brightness(92%) contrast(85%); + transition: filter 0.2s ease, transform 0.2s ease; +} + +input[type="date"]::-webkit-calendar-picker-indicator:hover { + filter: invert(85%) sepia(25%) saturate(1500%) hue-rotate(351deg) brightness(105%) contrast(90%); + transform: scale(1.15); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 000000000..d084ccade --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 000000000..bef5202a3 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/page/AddVenue.tsx b/src/page/AddVenue.tsx new file mode 100644 index 000000000..282d6ea20 --- /dev/null +++ b/src/page/AddVenue.tsx @@ -0,0 +1,1033 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + ArrowLeft, MapPin, Check, ShieldAlert, + Calendar, Star, Sparkles, Building, Settings, + PlusCircle, Trash2, BadgeAlert +} from 'lucide-react'; +import Navbar from '../components/Navbar'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { LocationPicker } from '@/components/map'; +import { addVenue } from '../data/venuesData'; +import type { Venue } from '../data/venuesData'; + + +const PRESET_AMENITIES = [ + 'High-speed Wi-Fi', + 'Professional Sound System', + 'Bridal Suite', + 'Full AC & Heating', + 'Ambient LED Lighting', + 'Outdoor Garden Area', + 'Stage & Podium' +]; + +const PRESET_EVENT_TYPES = [ + 'Weddings', + 'Corporate Galas', + 'Cocktail Receptions', + 'Art Exhibitions' +]; + +const MOCK_IMAGES = [ + 'https://images.unsplash.com/photo-1519167758481-83f550bb49b3?q=80&w=800&auto=format&fit=crop', + 'https://images.unsplash.com/photo-1464366400600-7168b8af9bc3?q=80&w=800&auto=format&fit=crop', + 'https://images.unsplash.com/photo-1511795409834-ef04bbd61622?q=80&w=800&auto=format&fit=crop', +]; + +/** + * AddVenue Component + * Renders an interactive form for hosts to create and publish a new venue listing. + * Includes local state management for inline template preview and file upload handling. + */ +export default function AddVenue() { + const navigate = useNavigate(); + + // Form Input States + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [location, setLocation] = useState(''); // Town / city name + const [fullAddress, setFullAddress] = useState(''); // Full location + const [latitude, setLatitude] = useState(null); + const [longitude, setLongitude] = useState(null); + const [capacity, setCapacity] = useState(0); + const [squareFeet, setSquareFeet] = useState(0); + const [pricePerNight, setPricePerNight] = useState(0); + const [hostType, setHostType] = useState('Superhost'); + const [isTopRated, setIsTopRated] = useState(true); + const [bookingType, setBookingType] = useState<'days' | 'hours'>('days'); + const [cleaningGap, setCleaningGap] = useState(0); + const [openingTime, setOpeningTime] = useState('08:00'); + const [closingTime, setClosingTime] = useState('22:00'); + + // Lists/Arrays + const [imageUrls, setImageUrls] = useState([ + MOCK_IMAGES[0], + MOCK_IMAGES[1], + MOCK_IMAGES[2] + ]); + const [customImageUrl, setCustomImageUrl] = useState(''); + const [activeImageIdx, setActiveImageIdx] = useState(0); + + const [selectedAmenities, setSelectedAmenities] = useState(PRESET_AMENITIES); + const [customAmenity, setCustomAmenity] = useState(''); + + const [selectedEventTypes, setSelectedEventTypes] = useState(PRESET_EVENT_TYPES); + const [customEventType, setCustomEventType] = useState(''); + + const [catering, setCatering] = useState('In-house gourmet catering available (fully custom menu arrangements), outside licensed & insured caterers allowed upon approval.'); + const [parking, setParking] = useState('Valet parking available for up to 80 cars, underground secure parking garage with 120 dedicated slots, and multiple active EV charging stations.'); + + const [rules, setRules] = useState([ + 'Music must transition to indoor after 11 PM', + 'No confetti, glitter, or open flames permitted', + 'Licensed and certified bartenders required for any alcohol service' + ]); + const [newRule, setNewRule] = useState(''); + + // Validation state + const [errors, setErrors] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isUploading, setIsUploading] = useState(false); + + const availableEventTypes = Array.from(new Set([...PRESET_EVENT_TYPES, ...selectedEventTypes])); + const availableAmenities = Array.from(new Set([...PRESET_AMENITIES, ...selectedAmenities])); + + // Pricing calculations + const basePrice = pricePerNight || 0; + const serviceFee = Math.round(basePrice * 0.15); + const totalPrice = basePrice + serviceFee; + + // Add / remove images + const handleAddImage = () => { + if (!customImageUrl.trim()) return; + if (!imageUrls.includes(customImageUrl.trim())) { + setImageUrls([...imageUrls, customImageUrl.trim()]); + } + setCustomImageUrl(''); + setErrors(prev => ({ ...prev, images: '' })); + }; + + const handleRemoveImage = (index: number) => { + const updated = imageUrls.filter((_, idx) => idx !== index); + setImageUrls(updated); + if (activeImageIdx >= updated.length) { + setActiveImageIdx(Math.max(0, updated.length - 1)); + } + }; + + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const formData = new FormData(); + formData.append('image', file); + + setIsUploading(true); + try { + const response = await fetch('http://localhost:5000/api/upload', { + method: 'POST', + body: formData, + }); + + if (response.ok) { + const data = await response.json(); + if (data.imageUrl) { + setImageUrls([...imageUrls, data.imageUrl]); + setErrors(prev => ({ ...prev, images: '' })); + } + } else { + const errorData = await response.json(); + setErrors(prev => ({ ...prev, images: errorData.message || 'Failed to upload image.' })); + } + } catch (err) { + console.error('Error uploading image:', err); + setErrors(prev => ({ ...prev, images: 'Error uploading image.' })); + } finally { + setIsUploading(false); + } + }; + + // Amenities helpers + const toggleAmenity = (amenity: string) => { + if (selectedAmenities.includes(amenity)) { + setSelectedAmenities(selectedAmenities.filter(a => a !== amenity)); + } else { + setSelectedAmenities([...selectedAmenities, amenity]); + } + }; + + const handleAddCustomAmenity = () => { + if (!customAmenity.trim()) return; + if (!selectedAmenities.includes(customAmenity.trim())) { + setSelectedAmenities([...selectedAmenities, customAmenity.trim()]); + } + setCustomAmenity(''); + }; + + // Event types helpers + const toggleEventType = (type: string) => { + if (selectedEventTypes.includes(type)) { + setSelectedEventTypes(selectedEventTypes.filter(t => t !== type)); + } else { + setSelectedEventTypes([...selectedEventTypes, type]); + } + }; + + const handleAddCustomEventType = () => { + if (!customEventType.trim()) return; + if (!selectedEventTypes.includes(customEventType.trim())) { + setSelectedEventTypes([...selectedEventTypes, customEventType.trim()]); + } + setCustomEventType(''); + }; + + // House rules helpers + const handleAddRule = () => { + if (!newRule.trim()) return; + setRules([...rules, newRule.trim()]); + setNewRule(''); + }; + + const handleRemoveRule = (index: number) => { + setRules(rules.filter((_, idx) => idx !== index)); + }; + + // Validation + const validateForm = () => { + const tempErrors: Record = {}; + if (!title.trim()) tempErrors.title = 'Title is required.'; + if (!description.trim()) tempErrors.description = 'Description is required.'; + if (!fullAddress.trim()) tempErrors.fullAddress = 'Full Location / Street Address is required.'; + if (!location.trim()) tempErrors.location = 'Town Name is required.'; + if (capacity <= 0) tempErrors.capacity = 'Capacity must be greater than zero.'; + if (squareFeet <= 0) tempErrors.squareFeet = 'Space Area must be greater than zero.'; + if (pricePerNight <= 0) tempErrors.pricePerNight = 'Daily Rate must be greater than zero.'; + if (imageUrls.length < 3) tempErrors.images = 'A minimum of three images is required.'; + + setErrors(tempErrors); + return Object.keys(tempErrors).length === 0; + }; + + // Form submit handler + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validateForm()) return; + + setIsSubmitting(true); + const generatedId = Date.now().toString(); + + const tags = [ + location, + `${capacity} Guests`, + isTopRated ? 'Luxury' : 'Handpicked' + ]; + + const newVenue: Venue = { + id: generatedId, + images: imageUrls, + tags, + location, + capacity, + rating: 4.9, + title, + dateRange: 'Available', + hostType, + isTopRated, + description, + pricePerNight, + fullAddress, + latitude, + longitude, + parking, + amenities: selectedAmenities, + squareFeet, + catering, + rules, + eventTypes: selectedEventTypes, + bookingType, + cleaningGap, + openingTime, + closingTime + }; + + const token = localStorage.getItem('token'); + let savedId = generatedId; + + if (token) { + try { + const response = await fetch('http://localhost:5000/api/venues', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + title, + description, + location, + full_address: fullAddress, + latitude, + longitude, + capacity: Number(capacity), + square_feet: Number(squareFeet), + price_per_night: Number(pricePerNight), + host_type: hostType, + rating: 4.9, + is_top_rated: isTopRated, + date_range: 'Available', + parking, + catering, + images: imageUrls, + amenities: selectedAmenities, + rules, + event_types: selectedEventTypes, + booking_type: bookingType, + cleaning_gap: Number(cleaningGap), + opening_time: openingTime, + closing_time: closingTime + }) + }); + + if (response.ok) { + const data = await response.json(); + if (data.venue && data.venue.id) { + savedId = data.venue.id.toString(); + } + } else { + // Fallback to local storage + addVenue(newVenue); + } + } catch (err) { + console.error('Failed submitting venue listing to backend, falling back:', err); + addVenue(newVenue); + } + } else { + addVenue(newVenue); + } + + setIsSubmitting(false); + // Directly redirect to view venue right from there! + navigate(`/my-venues/${savedId}`); + }; + + + + return ( +
+ + + {/* Background Gradients */} +
+
+ + {/* Main Content */} +
+ + {/* Header Block */} +
+
+

+ List New{' '} + + Venue + +

+

+ Fill out the visual template below to list your event space. Edit details inline to see how it shapes up. +

+
+ +
+ +
+
+ + {/* Live Visual Showcase Image Grid */} +
+
+ {`${title +
+ Photo {activeImageIdx + 1} of {imageUrls.length} +
+
+ + {/* Thumbnail Selection list */} +
+ {imageUrls.map((img, idx) => ( + + ))} +
+
+ + {/* Dual Column Layout Form */} +
+ + {/* Left Column: Form Inputs styled inline */} +
+
+ + {/* Step 1: Overview & Specs */} +
+

+ Overview & Details +

+ + {/* Venue Name */} +
+ + { + setTitle(e.target.value); + setErrors(prev => ({ ...prev, title: '' })); + }} + className={cn( + "w-full px-4 py-2.5 bg-white/[0.02] border border-white/10 rounded-xl text-sm text-white placeholder-white/30 focus:outline-none focus:border-[#c5a059]/40 transition-all", + errors.title && "border-red-500/50 focus:border-red-500" + )} + /> + {errors.title && ( + {errors.title} + )} +
+ + {/* Description Overview */} +
+ +