diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..a6c3f273c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.claude +PlanAndProgressFiles +.mcp.json \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 000000000..1dcef2d9f --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,2 @@ +node_modules +.env \ No newline at end of file diff --git a/backend/config/config.js b/backend/config/config.js new file mode 100644 index 000000000..2001f441e --- /dev/null +++ b/backend/config/config.js @@ -0,0 +1,20 @@ +module.exports = { + mongodbConnectionUrl: process.env.MONGODB_CONNECTION_URL, + + // Google OAuth — the Web client ID from Google Cloud Console. + // Used to verify that an incoming Google ID token was issued for THIS app. + googleClientId: process.env.GOOGLE_CLIENT_ID, + + jwtSecret: process.env.JWT_SECRET, + // Token lifetime per role. Customers stay logged in longer for convenience; + // venue owners get a shorter session since their account manages listings. + jwtExpiresIn: { + customer: "7d", + venueOwner: "1d", + admin: "2h" + }, + + // Comma-separated list of frontend origins allowed by CORS. + // Defaults to the Vite dev server. + corsOrigin: process.env.CORS_ORIGIN || "http://localhost:5173", +}; diff --git a/backend/config/database.js b/backend/config/database.js new file mode 100644 index 000000000..03393fe1c --- /dev/null +++ b/backend/config/database.js @@ -0,0 +1,6 @@ +const mongoose = require("mongoose"); +const { mongodbConnectionUrl } = require("./config"); + +module.exports.connectDB = async () => { + await mongoose.connect(mongodbConnectionUrl); +} \ No newline at end of file diff --git a/backend/constants/user.js b/backend/constants/user.js new file mode 100644 index 000000000..1910626ab --- /dev/null +++ b/backend/constants/user.js @@ -0,0 +1,9 @@ +const USER_ROLES = Object.freeze({ + CUSTOMER: "customer", // customer books venues + VENUE_OWNER: "venueOwner", // lists and manages venues + ADMIN: "admin", // platform administration +}); + +const USER_ROLE_VALUES = Object.values(USER_ROLES); + +module.exports = { USER_ROLES, USER_ROLE_VALUES }; diff --git a/backend/constants/venue.js b/backend/constants/venue.js new file mode 100644 index 000000000..ebc098375 --- /dev/null +++ b/backend/constants/venue.js @@ -0,0 +1,65 @@ +// Venue domain constants — shared across model, controllers, and seed. +// Centralized here so there are no magic strings scattered through the codebase. + +// Listing lifecycle. Only APPROVED venues are publicly searchable +const VENUE_STATUSES = { + DRAFT: "DRAFT", // Venue owner just created the venue; fields can be filled anytime. Not visible to admin or public. + PENDING: "PENDING", // Venue owner submitted the venue; waiting for admin approval. Not yet public. + APPROVED: "APPROVED", // Admin approved the venue; visible to the public (if isActive is true). + REJECTED: "REJECTED", // REJECTED means admin declined (+rejectionReason); not terminal — venue owner can fix & resubmit (REJECTED → change back to "DRAFT"/"EDIT_DRAFT" → submit → PENDING) + EDIT_DRAFT: "EDIT_DRAFT", // EDIT_DRAFT is an in-progress edit copy of a live (APPROVED) venue (editOf field set in db); venue owner keeps editing until they submit it (EDIT_DRAFT → submit → CHANGES_PENDING). + CHANGES_PENDING: "CHANGES_PENDING", // CHANGES_PENDING is a submitted edit copy waiting for admin re-approval; on approve it merges into the original. +}; + +// Venue owner-initiated submit → review queue. +// DRAFT → PENDING (a new/own venue enters the queue) +// EDIT_DRAFT → CHANGES_PENDING (a drafted edit copy enters the queue) +const SUBMITTABLE_STATUSES = Object.freeze([ + VENUE_STATUSES.DRAFT, + VENUE_STATUSES.EDIT_DRAFT, +]); + +// Venue owner-initiated hard delete. Only never-submitted/in-progress copies are +// deletable: +// DRAFT → a new venue that was never submitted; safe to drop entirely +// EDIT_DRAFT → an edit copy of a live APPROVED venue; deleting it discards the +// in-progress edits without touching the original APPROVED doc +const DELETABLE_STATUSES = Object.freeze([ + VENUE_STATUSES.DRAFT, + VENUE_STATUSES.EDIT_DRAFT, +]); + +// Statuses an edit applies in place (the doc isn't live). Covers a new venue's +// own draft states AND a not-yet-submitted edit copy. APPROVED is absent — editing +// an APPROVED venue spawns a new EDIT_DRAFT copy instead. AND PENDING, CHANGES_PENDING are submitted versions +const IN_PLACE_EDIT_STATUSES = Object.freeze([ + VENUE_STATUSES.DRAFT, + VENUE_STATUSES.EDIT_DRAFT, +]); + +// Statuses awaiting an admin decision — the admin review queue. +// PENDING → a brand-new venue submitted for first approval +// CHANGES_PENDING → a submitted edit copy awaiting re-approval +const REVIEW_STATUSES = Object.freeze([ + VENUE_STATUSES.PENDING, + VENUE_STATUSES.CHANGES_PENDING, +]); + +// Admin decisions recorded in a venue's editHistory. Deliberately distinct from +// the APPROVED/REJECTED status strings so a history entry (the noun for the +// decision) isn't confused with the venue's lifecycle state. +const HISTORY_ACTIONS = Object.freeze({ + APPROVAL: "APPROVAL", + REJECTION: "REJECTION", +}); + +module.exports = { + VENUE_STATUSES, + VENUE_STATUS_VALUES: Object.values(VENUE_STATUSES), + SUBMITTABLE_STATUSES, + DELETABLE_STATUSES, + IN_PLACE_EDIT_STATUSES, + REVIEW_STATUSES, + HISTORY_ACTIONS, + HISTORY_ACTION_VALUES: Object.values(HISTORY_ACTIONS), +}; diff --git a/backend/controllers/admin/adminGetVenueById.js b/backend/controllers/admin/adminGetVenueById.js new file mode 100644 index 000000000..cd7b2ba3c --- /dev/null +++ b/backend/controllers/admin/adminGetVenueById.js @@ -0,0 +1,47 @@ +const mongoose = require("mongoose"); +const Venues = require("../../models/venue"); +const { REVIEW_STATUSES } = require("../../constants/venue"); +const { VENUE_POPULATE } = require("../venue/shared"); + +// Owner fields surfaced to the review page — enough to identify/contact the owner. +const OWNER_POPULATE = { path: "venueOwner", select: "name email" }; + +// Fields excluded from the review detail. version/editHistory are the admin audit +// trail (not shown on the review page yet); deletedAt/__v are internal. +const DETAIL_HIDDEN_FIELDS = "-version -editHistory -deletedAt -__v"; + +// GET /admin/venues/:id +// Returns a single venue awaiting review (PENDING or CHANGES_PENDING) for the +// admin review page. Non-review statuses are treated as not found — this endpoint +// only serves the approval queue. For a CHANGES_PENDING edit copy the response +// carries `editOf` (the original APPROVED venue's id) so the UI can link to the +// live listing. +async function adminGetVenueById(req, res) { + try { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid venue id" }); + } + + const venue = await Venues.findOne({ + _id: id, + status: { $in: REVIEW_STATUSES }, + deletedAt: null, + }) + .select(DETAIL_HIDDEN_FIELDS) + .populate(VENUE_POPULATE) + .populate(OWNER_POPULATE) + .lean(); + + if (!venue) { + return res.status(404).json({ message: "Venue not found" }); + } + + return res.status(200).json({ data: venue }); + } catch (err) { + return res.status(500).json({ error: err.message, message: "Failed to fetch venue" }); + } +} + +module.exports = adminGetVenueById; diff --git a/backend/controllers/admin/adminListVenues.js b/backend/controllers/admin/adminListVenues.js new file mode 100644 index 000000000..f2922c0eb --- /dev/null +++ b/backend/controllers/admin/adminListVenues.js @@ -0,0 +1,80 @@ +const Venues = require("../../models/venue"); +const { REVIEW_STATUSES } = require("../../constants/venue"); +const { + DEFAULT_PAGE, + DEFAULT_LIMIT, + CATEGORY_POPULATE, + parsePageParam, +} = require("../venue/shared"); + +// Owner fields surfaced to the admin queue — enough to identify/contact the owner. +const OWNER_POPULATE = { path: "venueOwner", select: "name email" }; + +// Lean projection for the review table. Full venue data comes from the detail +// endpoint; the queue only needs what the table renders. +const LIST_FIELDS = "name city district venueCategory venueOwner status createdAt updatedAt"; + +// The queue is always sorted by submission time (updatedAt); only the direction +// is client-controlled. Map the two allowed sortOrder values to Mongo sort ints. +const SORT_DIRECTIONS = { asc: 1, desc: -1 }; +const DEFAULT_SORT_ORDER = "desc"; + +// GET /admin/venues?status=&sortOrder= +// Paginated admin review queue. `status` is required and must be exactly one +// review status (the UI's two tabs each fetch one). PENDING = new venues awaiting +// first approval; CHANGES_PENDING = submitted edit copies awaiting re-approval. +// Results are sorted by submission time (updatedAt); sortOrder defaults to desc. +// countOnly "true" — returns { data: { total } } with no venue docs (used to +// populate both tab count badges without fetching rows). +async function adminListVenues(req, res) { + try { + const { status } = req.query; + if (!status) { + return res.status(400).json({ message: "status query parameter is required" }); + } + if (!REVIEW_STATUSES.includes(status)) { + return res.status(400).json({ + message: `status must be one of: ${REVIEW_STATUSES.join(", ")}`, + }); + } + + const filter = { status, deletedAt: null }; + + if (req.query.countOnly === "true") { + const total = await Venues.countDocuments(filter); + return res.status(200).json({ data: { total } }); + } + + const sortOrder = req.query.sortOrder || DEFAULT_SORT_ORDER; + if (!Object.hasOwn(SORT_DIRECTIONS, sortOrder)) { + return res.status(400).json({ + message: `sortOrder must be one of: ${Object.keys(SORT_DIRECTIONS).join(", ")}`, + }); + } + + const page = parsePageParam(req.query.page, DEFAULT_PAGE); + const limit = parsePageParam(req.query.limit, DEFAULT_LIMIT); + const skip = (page - 1) * limit; + + const [venues, total] = await Promise.all([ + Venues.find(filter) + .select(LIST_FIELDS) + .populate(CATEGORY_POPULATE) + .populate(OWNER_POPULATE) + .sort({ updatedAt: SORT_DIRECTIONS[sortOrder] }) + .skip(skip) + .limit(limit) + .lean(), + Venues.countDocuments(filter), + ]); + + return res.status(200).json({ + data: venues, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }); + } catch (err) { + return res.status(500).json({ error: err.message, message: "Failed to fetch venues" }); + } +} + +module.exports = adminListVenues; diff --git a/backend/controllers/amenity/adminListAmenities.js b/backend/controllers/amenity/adminListAmenities.js new file mode 100644 index 000000000..0f2d11a4e --- /dev/null +++ b/backend/controllers/amenity/adminListAmenities.js @@ -0,0 +1,22 @@ +const Amenities = require("../../models/amenity"); + +// GET /admin/amenities +// Full management list for the admin UI: includes inactive/soft-deleted-excluded +// rows (deletedAt: null) regardless of isActive, so admins can see and +// reactivate a retired amenity, not just the ones live on the public site. +async function adminListAmenities(req, res) { + try { + const amenities = await Amenities.find({ deletedAt: null }) + .select("name isActive") + .sort({ name: 1 }) + .lean(); + + return res.status(200).json({ data: amenities }); + } catch (err) { + return res + .status(500) + .json({ error: err.message, message: "Failed to fetch amenities" }); + } +} + +module.exports = adminListAmenities; \ No newline at end of file diff --git a/backend/controllers/amenity/createAmenity.js b/backend/controllers/amenity/createAmenity.js new file mode 100644 index 000000000..d653f3030 --- /dev/null +++ b/backend/controllers/amenity/createAmenity.js @@ -0,0 +1,37 @@ +const Amenities = require("../../models/amenity"); +const slugify = require("../../utils/slugify"); + +// POST /admin/amenities body: { name } +// identifier is derived from name and never exposed for manual editing — +// venues reference amenities by identifier, so keeping it machine-generated +// avoids typo'd/duplicate slugs. +async function createAmenity(req, res) { + try { + const { name } = req.body; + if (!name || !name.trim()) { + return res.status(400).json({ message: "name is required" }); + } + + const identifier = slugify(name); + if (!identifier) { + return res.status(400).json({ message: "name must contain at least one letter or number" }); + } + + const existing = await Amenities.findOne({ identifier }); + if (existing) { + return res.status(409).json({ message: "An amenity with this name already exists" }); + } + + const amenity = await Amenities.create({ identifier, name: name.trim() }); + return res.status(201).json({ data: amenity }); + } catch (err) { + if (err.code === 11000) { + return res.status(409).json({ message: "An amenity with this name already exists" }); + } + return res + .status(500) + .json({ error: err.message, message: "Failed to create amenity" }); + } +} + +module.exports = createAmenity; diff --git a/backend/controllers/amenity/deleteAmenity.js b/backend/controllers/amenity/deleteAmenity.js new file mode 100644 index 000000000..55d82f676 --- /dev/null +++ b/backend/controllers/amenity/deleteAmenity.js @@ -0,0 +1,28 @@ +const Amenities = require("../../models/amenity"); + +// DELETE /admin/amenities/:id +// Soft delete: marks deletedAt + isActive false rather than removing the doc, +// so venues that already reference this amenity's identifier don't break. +async function deleteAmenity(req, res) { + try { + const { id } = req.params; + + const amenity = await Amenities.findOneAndUpdate( + { _id: id, deletedAt: null }, + { deletedAt: new Date(), isActive: false }, + { new: true } + ); + + if (!amenity) { + return res.status(404).json({ message: "Amenity not found" }); + } + + return res.status(200).json({ data: amenity }); + } catch (err) { + return res + .status(500) + .json({ error: err.message, message: "Failed to delete amenity" }); + } +} + +module.exports = deleteAmenity; diff --git a/backend/controllers/amenity/listAmenities.js b/backend/controllers/amenity/listAmenities.js new file mode 100644 index 000000000..6aa842016 --- /dev/null +++ b/backend/controllers/amenity/listAmenities.js @@ -0,0 +1,21 @@ +const Amenities = require("../../models/amenity"); + +const PUBLIC_FIELDS = "identifier name"; + +// multiselect dropdown and for resolving amenity labels +async function listAmenities(req, res) { + try { + const amenities = await Amenities.find({ isActive: true, deletedAt: null }) + .select(PUBLIC_FIELDS) + .sort({ name: 1 }) + .lean(); + + return res.status(200).json({ data: amenities }); + } catch (err) { + return res + .status(500) + .json({ error: err.message, message: "Failed to fetch amenities" }); + } +} + +module.exports = listAmenities; diff --git a/backend/controllers/amenity/updateAmenity.js b/backend/controllers/amenity/updateAmenity.js new file mode 100644 index 000000000..5a38d82c0 --- /dev/null +++ b/backend/controllers/amenity/updateAmenity.js @@ -0,0 +1,47 @@ +const Amenities = require("../../models/amenity"); + +// PATCH /admin/amenities/:id body: { name?, isActive? } +// identifier is intentionally immutable after creation (venues store the +// identifier, not the id/name, so changing it would silently break them). +async function updateAmenity(req, res) { + try { + const { id } = req.params; + const { name, isActive } = req.body; + + const update = {}; + if (name !== undefined) { + if (!name.trim()) { + return res.status(400).json({ message: "name cannot be empty" }); + } + update.name = name.trim(); + } + if (isActive !== undefined) { + if (typeof isActive !== "boolean") { + return res.status(400).json({ message: "isActive must be a boolean" }); + } + update.isActive = isActive; + } + + if (Object.keys(update).length === 0) { + return res.status(400).json({ message: "Nothing to update" }); + } + + const amenity = await Amenities.findOneAndUpdate( + { _id: id, deletedAt: null }, + update, + { new: true, runValidators: true } + ); + + if (!amenity) { + return res.status(404).json({ message: "Amenity not found" }); + } + + return res.status(200).json({ data: amenity }); + } catch (err) { + return res + .status(500) + .json({ error: err.message, message: "Failed to update amenity" }); + } +} + +module.exports = updateAmenity; \ No newline at end of file diff --git a/backend/controllers/auth/getMe.js b/backend/controllers/auth/getMe.js new file mode 100644 index 000000000..abeb7dc62 --- /dev/null +++ b/backend/controllers/auth/getMe.js @@ -0,0 +1,15 @@ +const { toPublicUser } = require("./shared"); + +// GET /auth/me — returns the currently authenticated user. +// authenticate has already verified the token and attached req.user. +async function getMe(req, res) { + try { + return res.status(200).json({ data: { user: toPublicUser(req.user) } }); + } catch (err) { + return res + .status(500) + .json({ error: err.message, message: "Failed to fetch user" }); + } +} + +module.exports = getMe; diff --git a/backend/controllers/auth/googleLogin.js b/backend/controllers/auth/googleLogin.js new file mode 100644 index 000000000..6e7dcabac --- /dev/null +++ b/backend/controllers/auth/googleLogin.js @@ -0,0 +1,68 @@ +const Users = require("../../models/user"); +const { USER_ROLES } = require("../../constants/user"); +const { signAuthToken } = require("../../utils/jwt"); +const { verifyGoogleIdToken, toPublicUser } = require("./shared"); + +// POST /auth/googleLogin +// Body: { idToken } — the Google ID token from the frontend. +// Google login is for customers only; venue owners use the email/password +// flow. Verifies the token, creates the customer if new (or enforces that an +// existing account is a customer), and returns our own app JWT. +async function googleLogin(req, res) { + try { + const { idToken } = req.body || {}; + if (!idToken) { + return res.status(400).json({ message: "idToken is required" }); + } + + // 1. Verify the token really came from Google and is meant for us(this application). + let profile; + try { + profile = await verifyGoogleIdToken(idToken); + } catch (err) { + return res + .status(401) + .json({ error: err.message, message: "Invalid Google token" }); + } + + // 2. Email is the primary identity key — find the existing user by it. + let user = await Users.findOne({ email: profile.email }); + + if (user) { + // Google login only ever produces customers. A non-customer account + // (e.g. a venue owner) must use its own login flow — refuse here. + if (user.role !== USER_ROLES.CUSTOMER) { + return res.status(409).json({ + message: + "This email is already registered for a different account type. Use a different email to log in as a customer.", + }); + } + // Keep Google-sourced fields fresh and link googleId if missing. + user.googleId = profile.googleId; + user.name = user.name || profile.name; // first priority is for the existing name (Maybe user sets their name themeselves from the user profile edit page) + user.picture = user.picture || profile.picture; // first priority is for the existing picture + await user.save(); + } else { + user = await Users.create({ + googleId: profile.googleId, + email: profile.email, + name: profile.name, + picture: profile.picture, + role: USER_ROLES.CUSTOMER, + }); + } + + // 3. Issue our own app JWT for subsequent authenticated requests. + const token = signAuthToken(user); + + return res + .status(200) + .json({ data: { token, user: toPublicUser(user) } }); + } catch (err) { + return res + .status(500) + .json({ error: err.message, message: "Login failed" }); + } +} + +module.exports = googleLogin; diff --git a/backend/controllers/auth/login.js b/backend/controllers/auth/login.js new file mode 100644 index 000000000..c1e9d1b52 --- /dev/null +++ b/backend/controllers/auth/login.js @@ -0,0 +1,44 @@ +const bcrypt = require("bcrypt"); +const Users = require("../../models/user"); +const { signAuthToken } = require("../../utils/jwt"); +const { toPublicUser } = require("./shared"); +const { USER_ROLES } = require("../../constants/user"); + +// Roles permitted to log in via email/password. Customers log in with Google +// only, so they are intentionally excluded here. Admin support comes later but +// the role is already accepted. +const LOGIN_ROLES = [USER_ROLES.VENUE_OWNER, USER_ROLES.ADMIN]; + +// Shared response for every failure mode (no such email, wrong password, +// disallowed role, Google-only account). Using one message avoids leaking +// which emails exist or how a given account was created. +const INVALID_CREDENTIALS = "Invalid email or password."; + +// POST /auth/login — email/password login for venue owners and admins. +module.exports.login = async (req, res) => { + try { + const { email, password } = req.body; + + const user = await Users.findOne({ email, role: { $in: LOGIN_ROLES } }); + + // No account, a Google-only account (no password set), or a role that + // isn't allowed to log in here — all answered identically. + if (!user || !user.password) { + return res.status(401).json({ message: INVALID_CREDENTIALS }); + } + + const passwordMatches = await bcrypt.compare(password, user.password); + if (!passwordMatches) { + return res.status(401).json({ message: INVALID_CREDENTIALS }); + } + + const token = signAuthToken(user); + + return res.status(200).json({ + message: "Logged in successfully", + data: { token, user: toPublicUser(user) }, + }); + } catch (error) { + return res.status(500).json({ message: "Login failed", error: error.message }); + } +}; diff --git a/backend/controllers/auth/shared.js b/backend/controllers/auth/shared.js new file mode 100644 index 000000000..31078c0e4 --- /dev/null +++ b/backend/controllers/auth/shared.js @@ -0,0 +1,34 @@ +const { OAuth2Client } = require("google-auth-library"); +const { googleClientId } = require("../../config/config"); + +// One shared verifier client for the whole app. +const googleClient = new OAuth2Client({ clientId: googleClientId }); + +// Verifies a Google ID token and returns the trusted profile claims. +// Throws if the token is invalid or not issued for our client id. +async function verifyGoogleIdToken(idToken) { + const loginTicket = await googleClient.verifyIdToken({ + idToken, + audience: googleClientId, + }); + const payload = loginTicket.getPayload(); + return { + googleId: payload.sub, + email: payload.email, + name: payload.name || "", + picture: payload.picture || "", + }; +} + +// Shape a User document for safe sending to the client (omit internal fields). +function toPublicUser(user) { + return { + id: user._id.toString(), + email: user.email, + name: user.name, + picture: user.picture, + role: user.role, + }; +} + +module.exports = { verifyGoogleIdToken, toPublicUser }; diff --git a/backend/controllers/auth/venueOwnerSignUp.js b/backend/controllers/auth/venueOwnerSignUp.js new file mode 100644 index 000000000..6bb5ac0d8 --- /dev/null +++ b/backend/controllers/auth/venueOwnerSignUp.js @@ -0,0 +1,46 @@ +const bcrypt = require("bcrypt"); +const Users = require("../../models/user"); +const { signAuthToken } = require("../../utils/jwt"); +const { toPublicUser } = require("./shared"); +const { USER_ROLES } = require("../../constants/user"); + +module.exports.venueOwnerSignUp = async (req, res) => { + try { + const { + name, + email, + password, + } = req.body; + + const passwordHash = await bcrypt.hash(password, 10); + + const user = new Users({ + name, + email, + password: passwordHash, + role: USER_ROLES.VENUE_OWNER, + }); + await user.save(); + + let loginToken; + try { + loginToken = signAuthToken(user); + } catch (error) { + return res.status(201).json({ + message: "Your account was created, but we couldn't log you in automatically. Please log in with your email and password.", + }); + } + + res.status(201).json({ + message: "Venue owner registration completed and logged in successfully", + data: { token: loginToken, user: toPublicUser(user) }, + }); + } catch (error) { + if (error.code === 11000) { + return res.status(409).json({ + message: "An account with this email address already exists. Please use another email address", + }); + } + return res.status(500).json({ message: "Sign up failed", error: error.message }); + } +} \ No newline at end of file diff --git a/backend/controllers/category/adminListCategories.js b/backend/controllers/category/adminListCategories.js new file mode 100644 index 000000000..0ca1f89c7 --- /dev/null +++ b/backend/controllers/category/adminListCategories.js @@ -0,0 +1,22 @@ +const Categories = require("../../models/category"); + +// GET /admin/categories +// Full management list for the admin UI: includes inactive rows (deletedAt: +// null) so admins can see and reactivate a retired category, not just the +// ones live on the public site. +async function adminListCategories(req, res) { + try { + const categories = await Categories.find({ deletedAt: null }) + .select("name isActive") + .sort({ name: 1 }) + .lean(); + + return res.status(200).json({ data: categories }); + } catch (err) { + return res + .status(500) + .json({ error: err.message, message: "Failed to fetch categories" }); + } +} + +module.exports = adminListCategories; \ No newline at end of file diff --git a/backend/controllers/category/createCategory.js b/backend/controllers/category/createCategory.js new file mode 100644 index 000000000..252c30fdc --- /dev/null +++ b/backend/controllers/category/createCategory.js @@ -0,0 +1,37 @@ +const Categories = require("../../models/category"); +const slugify = require("../../utils/slugify"); + +// POST /admin/categories body: { name } +// identifier is derived from name and never exposed for manual editing — +// venues reference categories by identifier, so keeping it machine-generated +// avoids typo'd/duplicate slugs. +async function createCategory(req, res) { + try { + const { name } = req.body; + if (!name || !name.trim()) { + return res.status(400).json({ message: "name is required" }); + } + + const identifier = slugify(name); + if (!identifier) { + return res.status(400).json({ message: "name must contain at least one letter or number" }); + } + + const existing = await Categories.findOne({ identifier }); + if (existing) { + return res.status(409).json({ message: "A category with this name already exists" }); + } + + const category = await Categories.create({ identifier, name: name.trim() }); + return res.status(201).json({ data: category }); + } catch (err) { + if (err.code === 11000) { + return res.status(409).json({ message: "A category with this name already exists" }); + } + return res + .status(500) + .json({ error: err.message, message: "Failed to create category" }); + } +} + +module.exports = createCategory; diff --git a/backend/controllers/category/deleteCategory.js b/backend/controllers/category/deleteCategory.js new file mode 100644 index 000000000..7398fe887 --- /dev/null +++ b/backend/controllers/category/deleteCategory.js @@ -0,0 +1,28 @@ +const Categories = require("../../models/category"); + +// DELETE /admin/categories/:id +// Soft delete: marks deletedAt + isActive false rather than removing the doc, +// so venues that already reference this category's identifier don't break. +async function deleteCategory(req, res) { + try { + const { id } = req.params; + + const category = await Categories.findOneAndUpdate( + { _id: id, deletedAt: null }, + { deletedAt: new Date(), isActive: false }, + { new: true } + ); + + if (!category) { + return res.status(404).json({ message: "Category not found" }); + } + + return res.status(200).json({ data: category }); + } catch (err) { + return res + .status(500) + .json({ error: err.message, message: "Failed to delete category" }); + } +} + +module.exports = deleteCategory; diff --git a/backend/controllers/category/listCategories.js b/backend/controllers/category/listCategories.js new file mode 100644 index 000000000..64f67fecb --- /dev/null +++ b/backend/controllers/category/listCategories.js @@ -0,0 +1,21 @@ +const Categories = require("../../models/category"); + +const PUBLIC_FIELDS = "identifier name"; + +// filter dropdown and for resolving category labels +async function listCategories(req, res) { + try { + const categories = await Categories.find({ isActive: true, deletedAt: null }) + .select(PUBLIC_FIELDS) + .sort({ name: 1 }) + .lean(); + + return res.status(200).json({ data: categories }); + } catch (err) { + return res + .status(500) + .json({ error: err.message, message: "Failed to fetch categories" }); + } +} + +module.exports = listCategories; diff --git a/backend/controllers/category/updateCategory.js b/backend/controllers/category/updateCategory.js new file mode 100644 index 000000000..6e9955191 --- /dev/null +++ b/backend/controllers/category/updateCategory.js @@ -0,0 +1,47 @@ +const Categories = require("../../models/category"); + +// PATCH /admin/categories/:id body: { name?, isActive? } +// identifier is intentionally immutable after creation (venues store the +// identifier, not the id/name, so changing it would silently break them). +async function updateCategory(req, res) { + try { + const { id } = req.params; + const { name, isActive } = req.body; + + const update = {}; + if (name !== undefined) { + if (!name.trim()) { + return res.status(400).json({ message: "name cannot be empty" }); + } + update.name = name.trim(); + } + if (isActive !== undefined) { + if (typeof isActive !== "boolean") { + return res.status(400).json({ message: "isActive must be a boolean" }); + } + update.isActive = isActive; + } + + if (Object.keys(update).length === 0) { + return res.status(400).json({ message: "Nothing to update" }); + } + + const category = await Categories.findOneAndUpdate( + { _id: id, deletedAt: null }, + update, + { new: true, runValidators: true } + ); + + if (!category) { + return res.status(404).json({ message: "Category not found" }); + } + + return res.status(200).json({ data: category }); + } catch (err) { + return res + .status(500) + .json({ error: err.message, message: "Failed to update category" }); + } +} + +module.exports = updateCategory; \ No newline at end of file diff --git a/backend/controllers/venue/getVenueById.js b/backend/controllers/venue/getVenueById.js new file mode 100644 index 000000000..10eb757a2 --- /dev/null +++ b/backend/controllers/venue/getVenueById.js @@ -0,0 +1,34 @@ +const mongoose = require("mongoose"); +const Venues = require("../../models/venue"); +const { PUBLIC_VENUE_FILTER, PUBLIC_FIELDS, VENUE_POPULATE } = require("./shared"); + +// GET /venues/:id — public detail for a single APPROVED venue. +async function getVenueById(req, res) { + try { + const { id } = req.params; + + // Invalid ObjectId → treat as not found. + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ error: "Invalid venue id", message: "Venue not found" }); + } + + const venue = await Venues.findOne({ _id: id, ...PUBLIC_VENUE_FILTER }) + .select(PUBLIC_FIELDS) + .populate(VENUE_POPULATE) + .lean(); + + // 404 whether it doesn't exist or isn't public — avoids leaking + // the existence of pending/rejected/deleted listings. + if (!venue) { + return res.status(404).json({ message: "Venue not found" }); + } + + return res.status(200).json({ data: venue }); + } catch (err) { + return res + .status(500) + .json({ error: err.message, message: "Failed to fetch venue" }); + } +} + +module.exports = getVenueById; diff --git a/backend/controllers/venue/listVenues.js b/backend/controllers/venue/listVenues.js new file mode 100644 index 000000000..0383b3659 --- /dev/null +++ b/backend/controllers/venue/listVenues.js @@ -0,0 +1,57 @@ +const Venues = require("../../models/venue"); +const { + DEFAULT_PAGE, + DEFAULT_LIMIT, + PUBLIC_FIELDS, + CATEGORY_POPULATE, + parsePageParam, + buildVenueFilter, +} = require("./shared"); + +// GET /venues — public, paginated, filterable list of APPROVED venues. +// +// Query params: +// page integer ≥1 (default 1) +// limit integer ≥1 (default 20) +// district string case-insensitive exact match +// category string venue category identifier (e.g. "resort") +// minPrice number basePrice ≥ minPrice +// maxPrice number basePrice ≤ maxPrice +async function listVenues(req, res) { + try { + const page = parsePageParam(req.query.page, DEFAULT_PAGE); + const limit = parsePageParam(req.query.limit, DEFAULT_LIMIT); + const skip = (page - 1) * limit; + + const { district, category, minPrice, maxPrice } = req.query; + + const filter = await buildVenueFilter({ district, category, minPrice, maxPrice }); + + const [venues, total] = await Promise.all([ + Venues.find(filter) + .select(PUBLIC_FIELDS) + .populate(CATEGORY_POPULATE) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .lean(), + Venues.countDocuments(filter), + ]); + + return res.status(200).json({ + data: venues, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }); + } catch (err) { + return res + .status(500) + .json({ error: err.message, message: "Failed to fetch venues" }); + } +} + +module.exports = listVenues; \ No newline at end of file diff --git a/backend/controllers/venue/setVenueVisibility.js b/backend/controllers/venue/setVenueVisibility.js new file mode 100644 index 000000000..7a4912dd8 --- /dev/null +++ b/backend/controllers/venue/setVenueVisibility.js @@ -0,0 +1,28 @@ +const Venues = require("../../models/venue"); +const { VENUE_STATUSES } = require("../../constants/venue"); + +// PATCH /venueOwner/venues/visibility/:id +// Toggles the isActive flag on an APPROVED venue (venue owner-controlled show/hide). +// +// Rules: +// - Venue must belong to req.user._id (venue owner check) +// - Current status must be APPROVED — only live venues can be toggled +// - req.body.isActive must be a boolean +// +// TODO: +// 1. Validate req.body.isActive is strictly a boolean; if not return 400 +// { message: "isActive must be a boolean" } +// 2. Find venue by req.params.id where venueOwner === req.user._id and deletedAt === null +// 3. If not found return 404 { message: "Venue not found" } +// 4. If venue.status !== VENUE_STATUSES.APPROVED return 400 +// { message: "Only APPROVED venues can be enabled or disabled" } +// 5. Set venue.isActive = req.body.isActive and call venue.save() +// 6. Return 200 { message: isActive ? "Venue enabled" : "Venue disabled", +// data: { isActive: venue.isActive } } +// 7. On error return 500 { message: "Failed to update venue visibility" } +async function setVenueVisibility(req, res) { + // TODO: implement + return res.status(501).json({ message: "Not implemented" }); +} + +module.exports = setVenueVisibility; diff --git a/backend/controllers/venue/shared.js b/backend/controllers/venue/shared.js new file mode 100644 index 000000000..ed8294387 --- /dev/null +++ b/backend/controllers/venue/shared.js @@ -0,0 +1,173 @@ +const { + VENUE_STATUSES, + SUBMITTABLE_STATUSES, + DELETABLE_STATUSES, + IN_PLACE_EDIT_STATUSES, +} = require("../../constants/venue"); +const Categories = require("../../models/category"); + +const DEFAULT_PAGE = 1; +const DEFAULT_LIMIT = 20; + +const PUBLIC_VENUE_FILTER = { + status: VENUE_STATUSES.APPROVED, + // isActive: true, keep commented for now + deletedAt: null, +}; + +const PUBLIC_FIELDS = "name description venueCategory amenities capacity addressLine state district city pincode location basePrice images createdAt"; + +// Populate specs for a venue's referenced lookups. Every venue read that +// resolves the category label also resolves amenity labels, so callers spread +// VENUE_POPULATE rather than passing CATEGORY_POPULATE alone. +const CATEGORY_POPULATE = { path: "venueCategory", select: "identifier name" }; +const AMENITIES_POPULATE = { path: "amenities", select: "identifier name" }; +const VENUE_POPULATE = [CATEGORY_POPULATE, AMENITIES_POPULATE]; + +function parsePageParam(rawValue, fallback) { + const parsed = Number.parseInt(rawValue, 10); + if (Number.isNaN(parsed) || parsed < 1) return fallback; + return parsed; +} + +// Resolves a category identifier string (e.g. "resort") to its ObjectId. +// Returns null if the identifier doesn't match any active category — which +// will cause the query to return zero results (correct behaviour: unknown +// category = no matches, not an error). +async function resolveCategoryId(identifier) { + if (!identifier) return undefined; + const cat = await Categories.findOne({ + identifier: identifier.toLowerCase().trim(), + isActive: true, + deletedAt: null, + }).select("_id").lean(); + return cat ? cat._id : null; +} + +// Builds the MongoDB filter for the public venue listing. +// Always starts from PUBLIC_VENUE_FILTER (approved + not deleted) and +// optionally layers on district, category, and price constraints. +async function buildVenueFilter({ district, category, minPrice, maxPrice } = {}) { + const filter = { ...PUBLIC_VENUE_FILTER }; + + if (district) { + // Case-insensitive exact match on district name. + filter.district = { $regex: new RegExp(`^${district.trim()}$`, "i") }; + } + + if (category) { + const categoryId = await resolveCategoryId(category); + // null means the identifier was provided but matched nothing — force zero results. + // undefined means it was not provided — skip the filter. + if (categoryId !== undefined) { + filter.venueCategory = categoryId; + } + } + + if (minPrice !== undefined || maxPrice !== undefined) { + filter.basePrice = {}; + const min = Number(minPrice); + const max = Number(maxPrice); + if (!Number.isNaN(min)) filter.basePrice.$gte = min; + if (!Number.isNaN(max)) filter.basePrice.$lte = max; + // If both were provided and unparseable, drop the empty object. + if (Object.keys(filter.basePrice).length === 0) delete filter.basePrice; + } + + return filter; +} + +// Status gate per venue owner action: the statuses that permit it, and the +// past-tense verb for the 400 message. Single source of truth so the guard and +// its error message can't drift apart. +const STATUS_GATE_BY_ACTION = { + submit: { statuses: SUBMITTABLE_STATUSES, verb: "submitted" }, + delete: { statuses: DELETABLE_STATUSES, verb: "deleted" }, + edit: { statuses: IN_PLACE_EDIT_STATUSES, verb: "edited" }, +}; + +// Builds the 400 message for a status-gated venue owner action, looking the +// allowed list up by action so it never drifts from the guard. Quotes each +// status and joins grammatically: +// ["DRAFT"] → Only venues with status "DRAFT" can be deleted +// ["DRAFT","EDIT_DRAFT"] → ... "DRAFT" or "EDIT_DRAFT" can be deleted +// ["DRAFT","EDIT_DRAFT","REJECTED"] → ... "DRAFT", "EDIT_DRAFT" or "REJECTED" can be deleted +function statusNotAllowedMessage(action) { + const { statuses, verb } = STATUS_GATE_BY_ACTION[action]; + const quoted = statuses.map((status) => `"${status}"`); + const list = quoted.length === 1 + ? quoted.join("") + : `${quoted.slice(0, -1).join(", ")} or ${quoted.at(-1)}`; + return `Only venues with status ${list} can be ${verb}`; +} + +// Venue owner-side projection +const OWNER_HIDDEN_FIELDS = "-deletedAt -__v"; + +// Fields a venue owner may set on a venue. Never spread req.body directly — pick from this list +// so venueOwner/status/editOf/isActive cannot be client-set. +const EDITABLE_VENUE_FIELDS = [ + "name", "description", "venueCategory", "amenities", "capacity", + "addressLine", "city", "district", "state", "pincode", + "location", "basePrice", "images", +]; + +// Fields that must be present before a draft can be submitted for review. +// Drafts persist incomplete (model fields default to "" / null), so completeness +// is enforced here at submit time. `location` and `images` +// are intentionally omitted — coordinates are optional and images are not yet +// part of the MVP upload pipeline. +const REQUIRED_VENUE_FIELDS = [ + "name", "description", "venueCategory", "amenities", "capacity", + "addressLine", "city", "district", "state", "pincode", "basePrice", +]; + +// Returns the REQUIRED_VENUE_FIELDS that are missing/empty on a venue document. +// Empty string, null, undefined, NaN, and empty array all count as missing (the +// last so a venue must carry at least one amenity); a 0 capacity or price is +// allowed through here (the schema's min:0 governs range separately). +function missingRequiredVenueFields(venue) { + return REQUIRED_VENUE_FIELDS.filter((field) => { + const value = venue[field]; + if (value === null || value === undefined) return true; + if (typeof value === "string" && value.trim() === "") return true; + if (typeof value === "number" && Number.isNaN(value)) return true; + if (Array.isArray(value) && value.length === 0) return true; + return false; + }); +} + +// Builds the seed for a new EDIT_DRAFT copy of an APPROVED venue: every editable +// content field copied from the original, plus the copy-identifying fields +// (status + editOf). venueOwner is carried from the original so ownership is +// preserved. Nothing else (isActive/rejectionReason/timestamps) is copied — +// those take their model defaults on the fresh document. +function buildEditDraftSeed(original) { + const seed = { + venueOwner: original.venueOwner, + editOf: original._id, + status: VENUE_STATUSES.EDIT_DRAFT, + }; + for (const field of EDITABLE_VENUE_FIELDS) { + seed[field] = original[field]; + } + return seed; +} + +module.exports = { + DEFAULT_PAGE, + DEFAULT_LIMIT, + PUBLIC_VENUE_FILTER, + PUBLIC_FIELDS, + CATEGORY_POPULATE, + AMENITIES_POPULATE, + VENUE_POPULATE, + OWNER_HIDDEN_FIELDS, + EDITABLE_VENUE_FIELDS, + REQUIRED_VENUE_FIELDS, + missingRequiredVenueFields, + buildEditDraftSeed, + statusNotAllowedMessage, + parsePageParam, + buildVenueFilter, +}; \ No newline at end of file diff --git a/backend/controllers/venue/venueOwnerCreateVenue.js b/backend/controllers/venue/venueOwnerCreateVenue.js new file mode 100644 index 000000000..8d280684e --- /dev/null +++ b/backend/controllers/venue/venueOwnerCreateVenue.js @@ -0,0 +1,12 @@ +const Venues = require("../../models/venue"); + +async function venueOwnerCreateVenue(req, res) { + try { + const venue = await Venues.create({ venueOwner: req.user._id }); + return res.status(201).json({ data: venue }); + } catch (err) { + return res.status(500).json({ error: err.message, message: "Failed to create venue" }); + } +} + +module.exports = venueOwnerCreateVenue; diff --git a/backend/controllers/venue/venueOwnerDeleteVenue.js b/backend/controllers/venue/venueOwnerDeleteVenue.js new file mode 100644 index 000000000..085bf17c4 --- /dev/null +++ b/backend/controllers/venue/venueOwnerDeleteVenue.js @@ -0,0 +1,33 @@ +const Venues = require("../../models/venue"); +const { DELETABLE_STATUSES } = require("../../constants/venue"); +const { statusNotAllowedMessage } = require("./shared"); + +// DELETE /venueOwner/venues/delete/:id +// Hard-deletes a DRAFT document OR an EDIT_DRAFT clone document. +// This is a permanent delete (not soft-delete) because: +// - DRAFT : venue was never submitted, safe to drop entirely +// - EDIT_DRAFT: it is a clone of an APPROVED venue; deleting it discards the +// in-progress edits without affecting the original APPROVED document +async function venueOwnerDeleteVenue(req, res) { + try { + const venue = await Venues.findOne({ + _id: req.params.id, + venueOwner: req.user._id, + deletedAt: null, + }); + if (!venue) return res.status(404).json({ message: "Venue not found" }); + + if (!DELETABLE_STATUSES.includes(venue.status)) { + return res.status(400).json({ + message: statusNotAllowedMessage("delete"), + }); + } + + await Venues.deleteOne({ _id: venue._id }); + return res.status(200).json({ message: "Venue deleted" }); + } catch (err) { + return res.status(500).json({ error: err.message, message: "Failed to delete venue" }); + } +} + +module.exports = venueOwnerDeleteVenue; diff --git a/backend/controllers/venue/venueOwnerGetVenueById.js b/backend/controllers/venue/venueOwnerGetVenueById.js new file mode 100644 index 000000000..22a5bd375 --- /dev/null +++ b/backend/controllers/venue/venueOwnerGetVenueById.js @@ -0,0 +1,35 @@ +const mongoose = require("mongoose"); +const Venues = require("../../models/venue"); +const { VENUE_POPULATE, OWNER_HIDDEN_FIELDS } = require("./shared"); + +// GET /venueOwner/venues/:id +// Returns a single venue owned by the authenticated venue owner, in ANY status +// (DRAFT/PENDING/APPROVED/REJECTED/EDIT_DRAFT/CHANGES_PENDING), excluding soft-deleted. +async function venueOwnerGetVenueById(req, res) { + try { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid venue id" }); + } + + const venue = await Venues.findOne({ + _id: id, + venueOwner: req.user._id, + deletedAt: null, + }) + .select(OWNER_HIDDEN_FIELDS) + .populate(VENUE_POPULATE) + .lean(); + + if (!venue) { + return res.status(404).json({ message: "Venue not found" }); + } + + return res.status(200).json({ data: venue }); + } catch (err) { + return res.status(500).json({ error: err.message, message: "Failed to fetch venue" }); + } +} + +module.exports = venueOwnerGetVenueById; diff --git a/backend/controllers/venue/venueOwnerGetVenueForEdit.js b/backend/controllers/venue/venueOwnerGetVenueForEdit.js new file mode 100644 index 000000000..612b1dbe6 --- /dev/null +++ b/backend/controllers/venue/venueOwnerGetVenueForEdit.js @@ -0,0 +1,80 @@ +const mongoose = require("mongoose"); +const Venues = require("../../models/venue"); +const { VENUE_STATUSES } = require("../../constants/venue"); +const { buildEditDraftSeed, VENUE_POPULATE, OWNER_HIDDEN_FIELDS } = require("./shared"); + +// POST /venueOwner/getVenueForEdit/:id +// Starts (or resumes) editing of a live APPROVED venue. Editing an APPROVED +// venue never mutates it in place; instead an EDIT_DRAFT copy (editOf -> original) +// is worked on, then submitted as CHANGES_PENDING for admin re-approval. +// +// :id is the APPROVED original. This endpoint is idempotent on the open edit copy: +// - existing EDIT_DRAFT copy -> return it (resume the in-progress edit) +// - existing CHANGES_PENDING -> 409 (edits already submitted, awaiting admin) +// - no copy yet -> create a fresh EDIT_DRAFT seeded from the original +async function venueOwnerGetVenueForEdit(req, res) { + try { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid venue id" }); + } + + const original = await Venues.findOne({ + _id: id, + venueOwner: req.user._id, + deletedAt: null, + }); + + if (!original) { + return res.status(404).json({ message: "Venue not found" }); + } + + if (original.status !== VENUE_STATUSES.APPROVED) { + return res.status(400).json({ + message: 'Only venues with "APPROVED" status can be taken under edit', + }); + } + + // An edit copy already in admin review blocks starting a new one. The owner + // must wait for the admin decision before editing again. + const pendingCopy = await Venues.findOne({ + editOf: original._id, + status: VENUE_STATUSES.CHANGES_PENDING, + deletedAt: null, + }).select("_id").lean(); + + if (pendingCopy) { + return res.status(409).json({ + message: "Edits for this venue have already been submitted for approval", + }); + } + + // Resume an in-progress edit copy if one exists (idempotent — never spawn a + // second EDIT_DRAFT for the same original). + const existingDraft = await Venues.findOne({ + editOf: original._id, + status: VENUE_STATUSES.EDIT_DRAFT, + deletedAt: null, + }) + .select(OWNER_HIDDEN_FIELDS) + .populate(VENUE_POPULATE) + .lean(); + + if (existingDraft) { + return res.status(200).json({ data: existingDraft }); + } + + const created = await Venues.create(buildEditDraftSeed(original)); + const editDraft = await Venues.findById(created._id) + .select(OWNER_HIDDEN_FIELDS) + .populate(VENUE_POPULATE) + .lean(); + + return res.status(200).json({ data: editDraft }); + } catch (err) { + return res.status(500).json({ error: err.message, message: "Failed to start venue edit" }); + } +} + +module.exports = venueOwnerGetVenueForEdit; diff --git a/backend/controllers/venue/venueOwnerListVenues.js b/backend/controllers/venue/venueOwnerListVenues.js new file mode 100644 index 000000000..f3f80868e --- /dev/null +++ b/backend/controllers/venue/venueOwnerListVenues.js @@ -0,0 +1,102 @@ +const Venues = require("../../models/venue"); +const { VENUE_STATUS_VALUES, VENUE_STATUSES } = require("../../constants/venue"); +const { + DEFAULT_PAGE, + DEFAULT_LIMIT, + CATEGORY_POPULATE, + OWNER_HIDDEN_FIELDS, + parsePageParam, +} = require("./shared"); + +// Statuses that only exist on edit copies (editOf is set). When these are requested +// we must NOT filter editOf:null or we'd get zero results. +const EDIT_COPY_STATUSES = [VENUE_STATUSES.EDIT_DRAFT, VENUE_STATUSES.CHANGES_PENDING]; + +// Marks each APPROVED venue in the list with `editStatus`: the status of its +// in-progress edit copy ("EDIT_DRAFT" = venue owner is editing, "CHANGES_PENDING" = +// edits submitted and awaiting admin), or null when no open copy exists. Lets the +// venue owner UI relabel/guard the Edit button without a per-row request. One extra +// query for the whole page. Non-APPROVED venues are left untouched (editStatus +// only ever applies to a live original). +async function attachEditStatus(venues, ownerId) { + const approvedIds = venues + .filter((v) => v.status === VENUE_STATUSES.APPROVED) + .map((v) => v._id); + + if (approvedIds.length === 0) return venues; + + const copiesOfApprovedVenues = await Venues.find({ + venueOwner: ownerId, + editOf: { $in: approvedIds }, + status: { $in: EDIT_COPY_STATUSES }, + deletedAt: null, + }) + .select("editOf status") + .lean(); + + // original id -> copy status. Only one open copy per original is possible + // (getVenueForEdit is idempotent) + const statusByOriginal = new Map(copiesOfApprovedVenues.map((c) => [String(c.editOf), c.status])); + + for (const venue of venues) { + if (venue.status === VENUE_STATUSES.APPROVED) { + venue.editStatus = statusByOriginal.get(String(venue._id)) ?? null; + } + } + return venues; +} + +// GET /venueOwner/venues — paginated venue owner venue list based on statuses passed. +// Query params: +// status comma-separated status values (e.g. "APPROVED" or "DRAFT,EDIT_DRAFT") +// page integer ≥1 (default 1) +// limit integer ≥1 (default 20) +// countOnly "true" — returns { data: { total } } with no venue docs +async function venueOwnerListVenues(req, res) { + try { + const page = parsePageParam(req.query.page, DEFAULT_PAGE); + const limit = parsePageParam(req.query.limit, DEFAULT_LIMIT); + const skip = (page - 1) * limit; + + const filter = { venueOwner: req.user._id, deletedAt: null }; + + if (req.query.status) { + const requested = req.query.status.split(",").map(s => s.trim()).filter(s => VENUE_STATUS_VALUES.includes(s)); + if (requested.length === 0) return res.status(400).json({ message: "Invalid status value(s)" }); + filter.status = { $in: requested }; + // Only exclude edit copies when none of the requested statuses are copy-statuses + const hasCopyStatus = requested.some(s => EDIT_COPY_STATUSES.includes(s)); + if (!hasCopyStatus) filter.editOf = null; + } else { + // No status filter — exclude copies so venue owner doesn't see duplicates + filter.editOf = null; + } + + if (req.query.countOnly === 'true') { + const total = await Venues.countDocuments(filter); + return res.status(200).json({ data: { total } }); + } + + const [venues, total] = await Promise.all([ + Venues.find(filter) + .select(OWNER_HIDDEN_FIELDS) + .populate(CATEGORY_POPULATE) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .lean(), + Venues.countDocuments(filter), + ]); + + await attachEditStatus(venues, req.user._id); + + return res.status(200).json({ + data: venues, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }); + } catch (err) { + return res.status(500).json({ error: err.message, message: "Failed to fetch venues" }); + } +} + +module.exports = venueOwnerListVenues; diff --git a/backend/controllers/venue/venueOwnerSubmitVenue.js b/backend/controllers/venue/venueOwnerSubmitVenue.js new file mode 100644 index 000000000..20046144c --- /dev/null +++ b/backend/controllers/venue/venueOwnerSubmitVenue.js @@ -0,0 +1,50 @@ +const Venues = require("../../models/venue"); +const { VENUE_STATUSES, SUBMITTABLE_STATUSES } = require("../../constants/venue"); +const { missingRequiredVenueFields, statusNotAllowedMessage } = require("./shared"); + +// DRAFT enters the new-venue queue; EDIT_DRAFT enters the re-approval queue. +const NEXT_STATUS_ON_SUBMIT = { + [VENUE_STATUSES.DRAFT]: VENUE_STATUSES.PENDING, + [VENUE_STATUSES.EDIT_DRAFT]: VENUE_STATUSES.CHANGES_PENDING, +}; + +// POST /venueOwner/venues/submit/:id +// Moves a venue from DRAFT → PENDING or EDIT_DRAFT → CHANGES_PENDING, +// putting it in the admin review queue. Drafts persist incomplete, so this is +// the completeness gate: all REQUIRED_VENUE_FIELDS must be filled before submit. +async function venueOwnerSubmitVenue(req, res) { + try { + const venue = await Venues.findOne({ + _id: req.params.id, + venueOwner: req.user._id, + deletedAt: null, + }); + if (!venue) return res.status(404).json({ message: "Venue not found" }); + + if (!SUBMITTABLE_STATUSES.includes(venue.status)) { + return res.status(400).json({ + message: statusNotAllowedMessage("submit"), + }); + } + + const missingFields = missingRequiredVenueFields(venue); + if (missingFields.length) { + return res.status(400).json({ + message: "Required fields are missing", + missingFields, + }); + } + + venue.status = NEXT_STATUS_ON_SUBMIT[venue.status]; + await venue.save(); + + return res.status(200).json({ + message: "Venue submitted for review", + data: { status: venue.status }, + }); + } catch (err) { + return res.status(500).json({ error: err.message, message: "Failed to submit venue" }); + } +} + +module.exports = venueOwnerSubmitVenue; diff --git a/backend/controllers/venue/venueOwnerUpdateVenue.js b/backend/controllers/venue/venueOwnerUpdateVenue.js new file mode 100644 index 000000000..f6bc9429e --- /dev/null +++ b/backend/controllers/venue/venueOwnerUpdateVenue.js @@ -0,0 +1,53 @@ +const Venues = require("../../models/venue"); +const { IN_PLACE_EDIT_STATUSES } = require("../../constants/venue"); +const { EDITABLE_VENUE_FIELDS, statusNotAllowedMessage } = require("./shared"); + +// PATCH /venueOwner/venues/update/:id +// Autosave endpoint — partial update of a DRAFT or EDIT_DRAFT venue. +// Called debounced (~2s after the user stops typing) from the edit form. +// +// Rules: +// - Venue must belong to req.user._id (venue owner check) +// - Current status must be in IN_PLACE_EDIT_STATUSES (DRAFT or EDIT_DRAFT) +// - Only fields listed in EDITABLE_VENUE_FIELDS may be updated — never trust +// raw req.body spread; pick allowed keys only to prevent venue owner overwriting +// status / venueOwner / editOf / isActive +// +// TODO: +// 1. Find venue by req.params.id where venueOwner === req.user._id and deletedAt === null +// 2. If not found return 404 { message: "Venue not found" } +// 3. If venue.status not in IN_PLACE_EDIT_STATUSES return 400 +// { message: "Only DRAFT or EDIT_DRAFT venues can be edited" } +// 4. Build an update object by picking only EDITABLE_VENUE_FIELDS keys from req.body +// (ignore any other keys silently) +// 5. Apply picked fields onto the venue document and call venue.save() +// 6. Return 200 { message: "Venue saved", data: { updatedAt: venue.updatedAt } } +// 7. On error return 500 { message: "Failed to update venue" } +async function venueOwnerUpdateVenue(req, res) { + try { + const venue = await Venues.findOne({ + _id: req.params.id, + venueOwner: req.user._id, + deletedAt: null, + }); + if (!venue) return res.status(404).json({ message: "Venue not found" }); + + if (!IN_PLACE_EDIT_STATUSES.includes(venue.status)) { + return res.status(400).json({ message: statusNotAllowedMessage("edit") }); + } + + for (const field of EDITABLE_VENUE_FIELDS) { + if (Object.hasOwn(req.body, field)) venue[field] = req.body[field]; + } + + // Autosave persists whatever the venue owner typed (a draft can be half-finished). + // Schema validators are skipped here; the submit API fully validates + // when they submit. + await venue.save({ validateBeforeSave: false }); + return res.status(200).json({ message: "Venue saved", data: { updatedAt: venue.updatedAt } }); + } catch (err) { + return res.status(500).json({ error: err.message, message: "Failed to update venue" }); + } +} + +module.exports = venueOwnerUpdateVenue; diff --git a/backend/docs/README.md b/backend/docs/README.md new file mode 100644 index 000000000..6a8a83d41 --- /dev/null +++ b/backend/docs/README.md @@ -0,0 +1,10 @@ +# BookMyVenue — API Documentation + +**Base URL:** `http://localhost:8000` + +## Routes + +| Route | Docs | Description | +| ----------------- | ------------------------------------------ | ------------------------------------ | +| `/venues` | [venues.md](./venues.md) | List venues & get a venue by ID. | +| `/venueCategories`| [venueCategories.md](./venueCategories.md) | List active venue categories. | diff --git a/backend/docs/venueCategories.md b/backend/docs/venueCategories.md new file mode 100644 index 000000000..cef38e812 --- /dev/null +++ b/backend/docs/venueCategories.md @@ -0,0 +1,41 @@ +# Venue Categories API + +Public endpoint for listing venue categories. + +**Base URL:** `http://localhost:8000` + +--- + +## List Venue Categories + +Returns all **active**, non-deleted categories, sorted alphabetically by name. Used for the filter dropdown and for resolving category labels. + +``` +GET /venueCategories +``` + +### Example + +```bash +curl -X GET "http://localhost:8000/venueCategories" -H "Accept: application/json" +``` + +### Response `200 OK` + +```json +{ + "data": [ + { "_id": "6a1c7a113b650d96cb4438fa", "identifier": "auditorium", "name": "Auditorium" }, + { "_id": "6a1c7a113b650d96cb4438f6", "identifier": "banquet-hall", "name": "Banquet Hall" }, + { "_id": "6a1c7a113b650d96cb4438f7", "identifier": "birthday-hall", "name": "Birthday Hall" }, + { "_id": "6a1c7a113b650d96cb4438f8", "identifier": "cafe", "name": "Cafe" }, + { "_id": "6a1c7a113b650d96cb4438f9", "identifier": "hotel", "name": "Hotel" } + ] +} +``` + +### Errors + +| Status | Body | +| ------ | ------------------------------------------------------------------- | +| `500` | `{ "error": "", "message": "Failed to fetch categories" }` | diff --git a/backend/docs/venues.md b/backend/docs/venues.md new file mode 100644 index 000000000..38e173cfc --- /dev/null +++ b/backend/docs/venues.md @@ -0,0 +1,135 @@ +# Venues API + +Public endpoints for browsing venues. + +**Base URL:** `http://localhost:8000` + +--- + +## List Venues + +Returns a paginated list of **approved**, non-deleted venues, sorted by newest first. + +``` +GET /venues +``` + +### Query Parameters + +| Param | Type | Default | Description | +| ------- | ------- | ------- | ------------------------------------- | +| `page` | integer | `1` | Page number (min 1). | +| `limit` | integer | `20` | Items per page (min 1). | + +### Example + +```bash +curl -X GET "http://localhost:8000/venues?page=1&limit=10" -H "Accept: application/json" +``` + +### Response `200 OK` + +```json +{ + "data": [ + { + "_id": "6a1c7a1112f6e3767f5951b1", + "name": "Backwater Banquet Hall", + "description": "Spacious banquet hall overlooking the Kochi backwaters.", + "venueCategory": { + "_id": "6a1c7a113b650d96cb4438f6", + "identifier": "banquet-hall", + "name": "Banquet Hall" + }, + "capacity": 300, + "addressLine": "Marine Drive", + "state": "Kerala", + "district": "Ernakulam", + "city": "Kochi", + "pincode": "682031", + "location": { "type": "Point", "coordinates": [76.2809, 9.9816] }, + "basePrice": 2500000, + "images": [ + { + "url": "https://picsum.photos/seed/bmv-banquet/800/600", + "sortOrder": 0, + "isCover": true + } + ], + "createdAt": "2026-05-31T18:12:33.920Z" + } + ], + "pagination": { "page": 1, "limit": 10, "total": 4, "totalPages": 1 } +} +``` + +### Errors + +| Status | Body | +| ------ | --------------------------------------------------------------- | +| `500` | `{ "error": "", "message": "Failed to fetch venues" }` | + +--- + +## Get Venue by ID + +Returns the public detail for a single **approved** venue. + +``` +GET /venues/:id +``` + +### Path Parameters + +| Param | Type | Description | +| ----- | ---------------- | ------------------ | +| `id` | MongoDB ObjectId | The venue's `_id`. | + +### Example + +```bash +curl -X GET "http://localhost:8000/venues/6a1c7a1112f6e3767f5951b1" -H "Accept: application/json" +``` + +### Response `200 OK` + +```json +{ + "data": { + "_id": "6a1c7a1112f6e3767f5951b1", + "name": "Backwater Banquet Hall", + "description": "Spacious banquet hall overlooking the Kochi backwaters.", + "venueCategory": { + "_id": "6a1c7a113b650d96cb4438f6", + "identifier": "banquet-hall", + "name": "Banquet Hall" + }, + "capacity": 300, + "addressLine": "Marine Drive", + "state": "Kerala", + "district": "Ernakulam", + "city": "Kochi", + "pincode": "682031", + "location": { "type": "Point", "coordinates": [76.2809, 9.9816] }, + "basePrice": 2500000, + "images": [ + { + "url": "https://picsum.photos/seed/bmv-banquet/800/600", + "sortOrder": 0, + "isCover": true + } + ], + "createdAt": "2026-05-31T18:12:33.920Z" + } +} +``` + +### Errors + +| Status | Body | When | +| ------ | --------------------------------------------------------------- | ---------------------------------------- | +| `400` | `{ "error": "Invalid venue id", "message": "Venue not found" }` | `id` is not a valid ObjectId. | +| `404` | `{ "message": "Venue not found" }` | No matching approved, non-deleted venue. | +| `500` | `{ "error": "", "message": "Failed to fetch venue" }` | Server error. | + +> A non-public venue (pending/rejected/deleted) returns `404` — the API never reveals whether such a listing exists. diff --git a/backend/index.js b/backend/index.js new file mode 100644 index 000000000..c42e9333a --- /dev/null +++ b/backend/index.js @@ -0,0 +1,37 @@ +require("dotenv").config(); +const express = require("express"); +const cors = require("cors"); +const { connectDB } = require("./config/database"); +const { corsOrigin } = require("./config/config"); +const venueRoutes = require("./routes/venue"); +const venueOwnerRoutes = require("./routes/venueOwner"); +const categoryRoutes = require("./routes/category"); +const amenityRoutes = require("./routes/amenity"); +const authRoutes = require("./routes/auth"); +const adminRoutes = require("./routes/admin"); + +const PORT = process.env.PORT || 8000; + +const app = express(); + +// Allow the frontend origin(s) to call the API from the browser. +app.use(cors({ origin: corsOrigin.split(",").map((o) => o.trim()) })); +app.use(express.json()); + +app.use("/api/auth", authRoutes); +app.use("/api/venues", venueRoutes); +app.use("/api/venueOwner", venueOwnerRoutes); +app.use("/api/venueCategories", categoryRoutes); +app.use("/api/amenities", amenityRoutes); +app.use("/api/admin", adminRoutes); + +connectDB() + .then(() => { + console.log("Connected to database successfully!"); + app.listen(PORT, () => { + console.log(`Server started and listening on port ${PORT}`); + }); + }) + .catch((err) => { + console.log("Database connection failed: " + err.message); + }); \ No newline at end of file diff --git a/backend/middleware/authenticate.js b/backend/middleware/authenticate.js new file mode 100644 index 000000000..9577ddf34 --- /dev/null +++ b/backend/middleware/authenticate.js @@ -0,0 +1,37 @@ +const Users = require("../models/user"); +const { verifyAuthToken } = require("../utils/jwt"); + +// Protects routes that require a logged-in user. +// Expects an "Authorization: Bearer " header carrying our app JWT. +// On success attaches the user document to req.user; otherwise 401. +async function authenticate(req, res, next) { + try { + const authHeader = req.headers.authorization || ""; + const [scheme, token] = authHeader.split(" "); + + if (scheme !== "Bearer" || !token) { + return res.status(401).json({ message: "Authentication required" }); + } + + let payload; + try { + payload = verifyAuthToken(token); + } catch (err) { + return res.status(401).json({ message: "Invalid or expired token" }); + } + + const user = await Users.findById(payload.userId); + if (!user || user.deletedAt) { + return res.status(401).json({ message: "User not found" }); + } + + req.user = user; + return next(); + } catch (err) { + return res + .status(500) + .json({ error: err.message, message: "Authentication failed" }); + } +} + +module.exports = authenticate; diff --git a/backend/middleware/handleExpressValidatorErrors.js b/backend/middleware/handleExpressValidatorErrors.js new file mode 100644 index 000000000..0411ba088 --- /dev/null +++ b/backend/middleware/handleExpressValidatorErrors.js @@ -0,0 +1,13 @@ +const { validationResult } = require("express-validator"); + +module.exports.handleExpressValidatorErrors = (req, res, next) => { + const dataValidationErrors = validationResult(req); + if(!dataValidationErrors.isEmpty()) { + return res + .status(400) + .json({ + message: "Request failed", + errors: dataValidationErrors.array().map((err) => err.msg) + }); + } else next(); +} \ No newline at end of file diff --git a/backend/middleware/loginDataValidations.js b/backend/middleware/loginDataValidations.js new file mode 100644 index 000000000..e25b06d8d --- /dev/null +++ b/backend/middleware/loginDataValidations.js @@ -0,0 +1,11 @@ +const { body } = require("express-validator"); + +module.exports.loginDataValidations = [ + body("email") + .trim() + .isEmail() + .withMessage("Please provide a valid email."), + body("password") + .notEmpty() + .withMessage("Password is required."), +]; \ No newline at end of file diff --git a/backend/middleware/requireRole.js b/backend/middleware/requireRole.js new file mode 100644 index 000000000..f0c1718ee --- /dev/null +++ b/backend/middleware/requireRole.js @@ -0,0 +1,9 @@ +function requireRole(...roles) { + return function checkRole(req, res, next) { + if (!req.user) return res.status(401).json({ message: "Authentication required" }); + if (!roles.includes(req.user.role)) return res.status(403).json({ message: "Insufficient permissions" }); + return next(); + }; +} + +module.exports = requireRole; diff --git a/backend/middleware/signUpDataValidations.js b/backend/middleware/signUpDataValidations.js new file mode 100644 index 000000000..0af25abd6 --- /dev/null +++ b/backend/middleware/signUpDataValidations.js @@ -0,0 +1,26 @@ +const { body } = require("express-validator"); + +module.exports.signUpDataValidations = [ + body("name") + .trim() + .isLength({ min: 3, max: 50 }) + .withMessage("Name is required and must be between 3 and 50 characters."), + body("email") + .trim() + .isEmail() + .withMessage("Please provide a valid email."), + body("password") + .isStrongPassword({ + minLength: 8, + minLowercase: 1, + minUppercase: 1, + minNumbers: 1, + minSymbols: 1, + }) + .withMessage("Password must contain at least 8 characters with 1 lowercase, 1 uppercase, 1 number and 1 symbol."), + body("confirmPassword") + .exists() + .withMessage("Confirm password is required.") + .custom((value, { req }) => value === req.body.password) + .withMessage("Passwords do not match."), +]; \ No newline at end of file diff --git a/backend/models/amenity.js b/backend/models/amenity.js new file mode 100644 index 000000000..d394d74b6 --- /dev/null +++ b/backend/models/amenity.js @@ -0,0 +1,28 @@ +const mongoose = require("mongoose"); + +const amenitySchema = new mongoose.Schema( + { + identifier: { + type: String, + required: true, + trim: true, + lowercase: true, + unique: true, + }, + + // Human-readable label shown on the frontend + name: { type: String, required: true, trim: true }, + + /* + Soft on/off switch so an admin can retire an amenity without deleting it + (and without orphaning venues that already reference it). + */ + isActive: { type: Boolean, default: true }, + + // Soft-delete marker. null = live. + deletedAt: { type: Date, default: null }, + }, + { timestamps: true } +); + +module.exports = mongoose.model("Amenity", amenitySchema, "amenities"); diff --git a/backend/models/category.js b/backend/models/category.js new file mode 100644 index 000000000..c91136d58 --- /dev/null +++ b/backend/models/category.js @@ -0,0 +1,28 @@ +const mongoose = require("mongoose"); + +const categorySchema = new mongoose.Schema( + { + identifier: { + type: String, + required: true, + trim: true, + lowercase: true, + unique: true, + }, + + // Human-readable label shown on the frontend + name: { type: String, required: true, trim: true }, + + /* + Soft on/off switch so an admin can retire a category without deleting it + (and without orphaning venues that already reference it). + */ + isActive: { type: Boolean, default: true }, + + // Soft-delete marker. null = live. + deletedAt: { type: Date, default: null }, + }, + { timestamps: true } +); + +module.exports = mongoose.model("Category", categorySchema, "categories"); diff --git a/backend/models/user.js b/backend/models/user.js new file mode 100644 index 000000000..fabda8be9 --- /dev/null +++ b/backend/models/user.js @@ -0,0 +1,34 @@ +const mongoose = require("mongoose"); +const { USER_ROLE_VALUES } = require("../constants/user"); + +const userSchema = new mongoose.Schema( + { + // Google's stable account id (the "sub" claim). Stored as supplementary + // data; email is the primary identity key + googleId: { type: String }, + + email: { + type: String, + required: true, + unique: true, + trim: true, + lowercase: true, + }, + password: { type: String }, + name: { type: String, trim: true, default: "" }, + // Profile picture URL provided by Google. + picture: { type: String, trim: true, default: "" }, + + role: { + type: String, + enum: USER_ROLE_VALUES, + index: true, + }, + + // Soft-delete marker. null = active + deletedAt: { type: Date, default: null }, + }, + { timestamps: true } +); + +module.exports = mongoose.model("User", userSchema, "users"); diff --git a/backend/models/venue.js b/backend/models/venue.js new file mode 100644 index 000000000..563b31946 --- /dev/null +++ b/backend/models/venue.js @@ -0,0 +1,117 @@ +const mongoose = require("mongoose"); +const { + VENUE_STATUS_VALUES, + VENUE_STATUSES, + HISTORY_ACTION_VALUES, +} = require("../constants/venue"); + +// Embedded image reference. MVP stores plain URLs only (no upload pipeline for now) +const venueImageSchema = new mongoose.Schema( + { + url: { type: String, required: true, trim: true }, + sortOrder: { type: Number, default: 0 }, + isCover: { type: Boolean, default: false }, + }, + { _id: false } +); + +// One admin decision on this venue. APPROVAL entries stamp the version the +// approval produced; REJECTION entries carry the reason shown to the owner. The +// full log lives on the surviving/original doc (an approved edit copy is +// hard-deleted, so its decisions are recorded on the original it merged into). +const editHistorySchema = new mongoose.Schema( + { + action: { type: String, enum: HISTORY_ACTION_VALUES, required: true }, + by: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + at: { type: Date, required: true }, + version: { type: Number, required: true }, + // Only set on REJECTION entries. + rejectionReason: { type: String, trim: true }, + }, + { _id: false } +); + +const venueSchema = new mongoose.Schema( + { + // venueOwner id — required by the data model, but auth/venue owner isn't built yet. + // Kept optional for now so seed data can exist before the User collection does. + venueOwner: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, + + // Set on an EDIT COPY only: points to the original APPROVED venue this copy + // edits. null on every normal venue. + // Required to (a) link copy-> original for + // the merge, (b) exclude copies from the venue owner's venue list, (c) tell a new + // PENDING venue apart from an edit awaiting re-approval. + editOf: { + type: mongoose.Schema.Types.ObjectId, + ref: "Venue", + default: null, + index: true, + }, + + name: { type: String, trim: true, default: "" }, + description: { type: String, trim: true, default: "" }, + // References the Category collection (admin-managed). + venueCategory: { + type: mongoose.Schema.Types.ObjectId, + ref: "Category", + default: null, + index: true, + }, + // References the Amenity collection (admin-managed). A venue may offer many + // amenities, so this is an array of refs (unlike the single venueCategory). + amenities: { + type: [{ type: mongoose.Schema.Types.ObjectId, ref: "Amenity" }], + default: [], + }, + capacity: { type: Number, min: 0 }, + + // Address fields. + addressLine: { type: String, trim: true, default: "" }, + city: { type: String, trim: true }, + district: { type: String, trim: true, index: true }, + state: { type: String, trim: true, default: "Kerala" }, + pincode: { type: String, trim: true }, + + // GeoJSON Point: [longitude, latitude]. Shaped for the Phase-1 radius + // search ("venues near me"); the 2dsphere index will be added later. + location: { + type: { type: String, enum: ["Point"], default: "Point" }, + coordinates: { type: [Number], default: undefined }, // [lng, lat] + }, + + basePrice: { type: Number, min: 0, default: null }, + + images: { type: [venueImageSchema], default: [] }, + + // Listing lifecycle. New venues start PENDING; only APPROVED is public. + status: { + type: String, + enum: VENUE_STATUS_VALUES, + default: VENUE_STATUSES.DRAFT, + index: true, + }, + + // Venue owner-controlled visibility toggle, orthogonal to `status`. + // false = venue owner disabled the listing; hidden from public but not deleted. + isActive: { type: Boolean, default: true }, + + // Set by admin on rejection (admin endpoints come later). Surfaced to the venue owner. + rejectionReason: { type: String, trim: true, default: "" }, + + // Live version counter. 0 = the DRAFT document (never approved). Increments + // by 1 on every admin approval — the first approval of a new venue makes it + // version 1, and each later approved edit-merge bumps it again. + version: { type: Number, default: 0 }, + + // Combined audit log of admin decisions (approvals + rejections) on this + // venue, in chronological order. See editHistorySchema above. + editHistory: { type: [editHistorySchema], default: [] }, + + // Soft-delete marker. null = live(active). + deletedAt: { type: Date, default: null }, + }, + { timestamps: true } +); + +module.exports = mongoose.model("Venue", venueSchema, "venues"); diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 000000000..9a7f67184 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1498 @@ +{ + "name": "bookmyvenue", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bookmyvenue", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "bcrypt": "^6.0.0", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^5.2.1", + "express-validator": "^7.3.2", + "google-auth-library": "^10.6.2", + "jsonwebtoken": "^9.0.3", + "mongoose": "^9.6.3" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.11.tgz", + "integrity": "sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, + "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/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/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "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.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/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/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "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": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-validator": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", + "integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.18.1", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "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": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "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/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/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/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/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-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.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/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/kareem": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.3.0.tgz", + "integrity": "sha512-kpSuLD3/7RenBnjnJdOHXCKC8dTd1JzeOiJhN0necWWci6cC+qX+VuwPnMVgb+a4+KNJSfgqahpnfWaeDXCimw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "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": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mongodb": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.2.0.tgz", + "integrity": "sha512-F/2+BMZtLVhY30ioZp0dAmZ+IRZMBqI+nrv6t5+9/1AIwCa8sMRC3jBf81lpxMhnZgqq8CoUD503Z1oZWq1/sw==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.2.0", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongoose": { + "version": "9.6.3", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.6.3.tgz", + "integrity": "sha512-vI6dTTlQnfMCyyQ5TrvhG0bCRs4dq5e1uFNPtOOWsOhn0fSg8AoIHjfyyCYr8aybyvPs845dRHGxsC3w/fHcBA==", + "license": "MIT", + "dependencies": { + "kareem": "3.3.0", + "mongodb": "~7.2", + "mpath": "0.9.0", + "mquery": "6.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", + "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "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/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", + "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "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/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "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": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "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": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/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": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "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/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "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/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/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "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/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 000000000..0848661ce --- /dev/null +++ b/backend/package.json @@ -0,0 +1,22 @@ +{ + "name": "bookmyvenue", + "version": "1.0.0", + "description": "BookMyVenue application's backend server with nodejs express", + "main": "index.js", + "scripts": { + "start": "node index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Team of people", + "license": "ISC", + "dependencies": { + "bcrypt": "^6.0.0", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^5.2.1", + "express-validator": "^7.3.2", + "google-auth-library": "^10.6.2", + "jsonwebtoken": "^9.0.3", + "mongoose": "^9.6.3" + } +} diff --git a/backend/routes/admin.js b/backend/routes/admin.js new file mode 100644 index 000000000..62b610b92 --- /dev/null +++ b/backend/routes/admin.js @@ -0,0 +1,38 @@ +const express = require("express"); +const authenticate = require("../middleware/authenticate"); +const requireRole = require("../middleware/requireRole"); +const { USER_ROLES } = require("../constants/user"); +const adminListVenues = require("../controllers/admin/adminListVenues"); +const adminGetVenueById = require("../controllers/admin/adminGetVenueById"); + +const adminListAmenities = require("../controllers/amenity/adminListAmenities"); +const createAmenity = require("../controllers/amenity/createAmenity"); +const updateAmenity = require("../controllers/amenity/updateAmenity"); +const deleteAmenity = require("../controllers/amenity/deleteAmenity"); + +const adminListCategories = require("../controllers/category/adminListCategories"); +const createCategory = require("../controllers/category/createCategory"); +const updateCategory = require("../controllers/category/updateCategory"); +const deleteCategory = require("../controllers/category/deleteCategory"); + +const router = express.Router(); + +// All routes in this file require a valid admin session. +router.use(authenticate, requireRole(USER_ROLES.ADMIN)); + +router.get("/venues", adminListVenues); +router.get("/venues/:id", adminGetVenueById); + +// Amenities management (Venue Options page) +router.get("/amenities", adminListAmenities); +router.post("/amenities", createAmenity); +router.patch("/amenities/:id", updateAmenity); +router.delete("/amenities/:id", deleteAmenity); + +// Categories management (Venue Options page) +router.get("/categories", adminListCategories); +router.post("/categories", createCategory); +router.patch("/categories/:id", updateCategory); +router.delete("/categories/:id", deleteCategory); + +module.exports = router; diff --git a/backend/routes/amenity.js b/backend/routes/amenity.js new file mode 100644 index 000000000..a567d857d --- /dev/null +++ b/backend/routes/amenity.js @@ -0,0 +1,8 @@ +const express = require("express"); +const listAmenities = require("../controllers/amenity/listAmenities"); + +const router = express.Router(); + +router.get("/", listAmenities); + +module.exports = router; diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 000000000..d8397c9f2 --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,30 @@ +const express = require("express"); +const googleLogin = require("../controllers/auth/googleLogin"); +const getMe = require("../controllers/auth/getMe"); +const authenticate = require("../middleware/authenticate"); +const { signUpDataValidations } = require("../middleware/signUpDataValidations"); +const { loginDataValidations } = require("../middleware/loginDataValidations"); +const { handleExpressValidatorErrors } = require("../middleware/handleExpressValidatorErrors"); +const { venueOwnerSignUp } = require("../controllers/auth/venueOwnerSignUp"); +const { login } = require("../controllers/auth/login"); + +const router = express.Router(); + +router.post("/googleLogin", googleLogin); +router.get("/me", authenticate, getMe); + +router.post( + "/venueOwner/signup", + signUpDataValidations, + handleExpressValidatorErrors, + venueOwnerSignUp +); + +router.post( + "/login", + loginDataValidations, + handleExpressValidatorErrors, + login +); + +module.exports = router; diff --git a/backend/routes/category.js b/backend/routes/category.js new file mode 100644 index 000000000..5d7dcffaa --- /dev/null +++ b/backend/routes/category.js @@ -0,0 +1,8 @@ +const express = require("express"); +const listCategories = require("../controllers/category/listCategories"); + +const router = express.Router(); + +router.get("/", listCategories); + +module.exports = router; diff --git a/backend/routes/venue.js b/backend/routes/venue.js new file mode 100644 index 000000000..2b68d6b7f --- /dev/null +++ b/backend/routes/venue.js @@ -0,0 +1,10 @@ +const express = require("express"); +const listVenues = require("../controllers/venue/listVenues"); +const getVenueById = require("../controllers/venue/getVenueById"); + +const router = express.Router(); + +router.get("/", listVenues); +router.get("/:id", getVenueById); + +module.exports = router; diff --git a/backend/routes/venueOwner.js b/backend/routes/venueOwner.js new file mode 100644 index 000000000..b565082c9 --- /dev/null +++ b/backend/routes/venueOwner.js @@ -0,0 +1,28 @@ +const express = require("express"); +const authenticate = require("../middleware/authenticate"); +const requireRole = require("../middleware/requireRole"); +const { USER_ROLES } = require("../constants/user"); +const venueOwnerListVenues = require("../controllers/venue/venueOwnerListVenues"); +const venueOwnerGetVenueById = require("../controllers/venue/venueOwnerGetVenueById"); +const venueOwnerCreateVenue = require("../controllers/venue/venueOwnerCreateVenue"); +const venueOwnerSubmitVenue = require("../controllers/venue/venueOwnerSubmitVenue"); +const venueOwnerUpdateVenue = require("../controllers/venue/venueOwnerUpdateVenue"); +const venueOwnerGetVenueForEdit = require("../controllers/venue/venueOwnerGetVenueForEdit"); +const setVenueVisibility = require("../controllers/venue/setVenueVisibility"); +const venueOwnerDeleteVenue = require("../controllers/venue/venueOwnerDeleteVenue"); + +const router = express.Router(); + +// All routes in this file require a valid venueOwner session. +router.use(authenticate, requireRole(USER_ROLES.VENUE_OWNER)); + +router.get("/venues", venueOwnerListVenues); +router.get("/venues/:id", venueOwnerGetVenueById); +router.post("/venues", venueOwnerCreateVenue); +router.post("/venues/submit/:id", venueOwnerSubmitVenue); +router.post("/getVenueForEdit/:id", venueOwnerGetVenueForEdit); +router.patch("/venues/update/:id", venueOwnerUpdateVenue); +router.patch("/venues/visibility/:id", setVenueVisibility); +router.delete("/venues/delete/:id", venueOwnerDeleteVenue); + +module.exports = router; diff --git a/backend/scripts/seedAdmin.js b/backend/scripts/seedAdmin.js new file mode 100644 index 000000000..e8677d13c --- /dev/null +++ b/backend/scripts/seedAdmin.js @@ -0,0 +1,49 @@ +// Seeds a single admin user. +// +// Provide credentials via env when running the script: +// ADMIN_EMAIL=you@admin.com ADMIN_PASSWORD='StrongPass!23' +// Load backend/.env regardless of the directory the script is launched from. +require("dotenv").config({ path: require("path").resolve(__dirname, "../.env") }); + +const bcrypt = require("bcrypt"); +const mongoose = require("mongoose"); +const { connectDB } = require("../config/database"); +const Users = require("../models/user"); +const { USER_ROLES } = require("../constants/user"); + +const email = process.env.ADMIN_EMAIL?.toLowerCase(); +const password = process.env.ADMIN_PASSWORD; +const name = process.env.ADMIN_NAME || "Platform Admin"; + +async function seedAdmin() { + if (!email || !password) { + throw new Error("ADMIN_EMAIL and ADMIN_PASSWORD env variables are required."); + } + + await connectDB(); + + const existing = await Users.findOne({ email }); + if (existing) { + console.log(`Account already exists for ${email} (role: ${existing.role})`); + return; + } + + const passwordHash = await bcrypt.hash(password, 10); + + const admin = new Users({ + name, + email, + password: passwordHash, + role: USER_ROLES.ADMIN, + }); + await admin.save(); + + console.log(`Admin created: ${email}`); +} + +seedAdmin() + .catch((error) => { + console.error("Failed to seed admin:", error.message); + process.exitCode = 1; + }) + .finally(() => mongoose.disconnect()); diff --git a/backend/utils/jwt.js b/backend/utils/jwt.js new file mode 100644 index 000000000..992f71889 --- /dev/null +++ b/backend/utils/jwt.js @@ -0,0 +1,18 @@ +const jwt = require("jsonwebtoken"); +const { jwtSecret, jwtExpiresIn } = require("../config/config"); + +// Signs our own app JWT. Payload is intentionally small: the user id and role +// are all the API needs to authorize requests. +function signAuthToken(user) { + const payload = { userId: user._id.toString() }; + const expiresIn = jwtExpiresIn[user.role]; + return jwt.sign(payload, jwtSecret, { expiresIn }); +} + +// Verifies an app JWT and returns its decoded payload. +// Throws if the token is missing, expired, or tampered with. +function verifyAuthToken(token) { + return jwt.verify(token, jwtSecret); +} + +module.exports = { signAuthToken, verifyAuthToken }; diff --git a/backend/utils/slugify.js b/backend/utils/slugify.js new file mode 100644 index 000000000..a422cbcff --- /dev/null +++ b/backend/utils/slugify.js @@ -0,0 +1,11 @@ +// Turns a human-readable name into a URL/identifier-safe slug. +// "Swimming Pool!" -> "swimming-pool" +function slugify(input) { + return String(input) + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +module.exports = slugify; diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 000000000..389aba163 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,5 @@ +# Copy this file to ".env" and adjust values for your local setup. +# Vite only exposes vars prefixed with VITE_ to the app (via import.meta.env). + +# Base URL of the backend API. +VITE_API_URL=http://localhost:8000/api diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..1cac5597e --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,25 @@ +# 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? +.env \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 000000000..a36934d87 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 000000000..ea36dd3dc --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,21 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 000000000..bcc4a417d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + BookMyVenue + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 000000000..fd36e0b99 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2804 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@react-oauth/google": "^0.13.5", + "@tailwindcss/vite": "^4.3.0", + "lucide-react": "^1.17.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-hook-form": "^7.78.0", + "react-router-dom": "^7.16.0", + "tailwindcss": "^4.3.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "vite": "^8.0.12" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@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==", + "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==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@react-oauth/google": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.5.tgz", + "integrity": "sha512-xQWri2s/3nNekZJ4uuov2aAfQYu83bN3864KcFqw2pK1nNbFurQIjPFDXhWaKH3IjYJ2r/9yyIIpsn5lMqrheQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/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/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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": 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/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/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/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/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": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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.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/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-hook-form": { + "version": "7.78.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.78.0.tgz", + "integrity": "sha512-EEZqc+N23moyzTlz61Pj+JvcXo76ICkpfOZo8JZw+sM4+wLQGh6nI2Ms+PdMOYNluFu0ghlM7B8mCzhRYtJCnA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-router": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz", + "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", + "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.16.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz", + "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==", + "license": "MIT", + "dependencies": { + "react-router": "7.16.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "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/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", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..feda0ff93 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@react-oauth/google": "^0.13.5", + "@tailwindcss/vite": "^4.3.0", + "lucide-react": "^1.17.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-hook-form": "^7.78.0", + "react-router-dom": "^7.16.0", + "tailwindcss": "^4.3.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "vite": "^8.0.12" + } +} diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 000000000..8243365fb Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 000000000..67d5ddbe8 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,67 @@ +import { Routes, Route } from 'react-router-dom'; + +import { LoginModal } from './components/shared/LoginModal.jsx'; +import { ToastViewport } from './components/shared/ToastViewport.jsx'; +import { RequireAuth } from './components/shared/RequireAuth.jsx'; +import { UserLayout } from './pages/UserPages/UserLayout.jsx'; +import { VenueOwnerLayout } from './pages/VenueOwnerPage/VenueOwnerLayout.jsx'; +import { Home, Venue, Category,VenueDetails } from './pages/UserPages/UserPages.js'; +import { + VenueOwnerLogin, + VenueOwnerDashboard, + VenueOwnerMyVenues, + EditVenuePage, + VenueOwnerBookings, + VenueOwnerAnalytics, + VenueOwnerSettings, +} from "./pages/VenueOwnerPage/VenueOwnerPage.js"; +import { AdminLayout } from './pages/AdminPage/AdminLayout.jsx'; +import { AdminLogin,AdminHome,AdminVenueApprovals, AdminVenueDetails, AdminVenueOptions } from './pages/AdminPage/AdminPages.js'; + +function App() { + return ( + <> + {/* Global overlays — mounted once, fired from anywhere */} + + + + + {/* Public User Routes */} + }> + } /> + } /> + } /> + } /> + + + {/* Venue Owner Routings */} + } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + {/* Admin Routings */} + } /> + }> + }> + } /> + } /> + } /> + } /> + + + + + ) +} + +export default App; \ No newline at end of file diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 000000000..6dad6dec5 --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,85 @@ +// Central API client — a thin wrapper around fetch. +// All backend calls should go through here so we configure the base URL, +// headers, auth token, and error handling in one place. +// +// Vite reads env vars prefixed with VITE_ (see .env). Falls back to the + +import { showError } from "../utils/toastBus"; + +// local backend if VITE_API_URL is not set. +const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api' + +// Where the app JWT lives in the browser. +const TOKEN_KEY = 'bmv_token' + +export function getToken() { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token) { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); +} + +// Called when the backend rejects our token (401) on any request, so the auth +// context can clear the stale session and route guards can redirect to login. +// AuthProvider registers a handler here on mount; until then it's a no-op. +let onUnauthorized = () => {}; + +export function setUnauthorizedHandler(handler) { + onUnauthorized = handler; +} + +async function request(path, options = {}) { + const token = getToken(); + + let res; + try { + res = await fetch(`${BASE_URL}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.headers || {}), + }, + }) + } catch (error) { + showError(error.message); + throw error; + } + + if (!res.ok) { + let detail = res.statusText; + let body = null; + try { + body = await res.json(); + detail = body.message || detail; + } catch { + // response had no JSON body; keep statusText + } + // Token invalid/expired/revoked — drop the stale session so route guards + // bounce the user to login. Skipped during the initial /auth/me restore, + // which handles its own 401 (no session exists yet to clear). + if (res.status === 401) { + setToken(null); + onUnauthorized(); + } + showError(detail); + const error = new Error(`API error ${res.status}: ${detail}`); + // Preserve the structured response so callers can react to it — e.g. submit + // returns { missingFields: [...] } for per-field form highlighting. + error.status = res.status; + error.data = body; + throw error; + } + + return res.json() +} + +export const api = { + get: (path) => request(path), + post: (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) }), + put: (path, body) => request(path, { method: 'PUT', body: JSON.stringify(body) }), + patch: (path, body) => request(path, { method: 'PATCH', body: JSON.stringify(body) }), + del: (path) => request(path, { method: 'DELETE' }), +} diff --git a/frontend/src/assets/bmvLogo.svg b/frontend/src/assets/bmvLogo.svg new file mode 100644 index 000000000..1a6cca3fc --- /dev/null +++ b/frontend/src/assets/bmvLogo.svg @@ -0,0 +1,47 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/components/admin/AdminNavbar.jsx b/frontend/src/components/admin/AdminNavbar.jsx new file mode 100644 index 000000000..b1bdd1363 --- /dev/null +++ b/frontend/src/components/admin/AdminNavbar.jsx @@ -0,0 +1,95 @@ +import { NavLink, Link, useNavigate } from "react-router-dom"; +import { + LayoutDashboard, + Clock3, + Settings2, + LogOut, +} from "lucide-react"; +import { useAuth } from "../../context/authContext.js"; + +const navItems = [ + { + label: "Dashboard", + icon: LayoutDashboard, + path: "/admin/home", + }, + { + label: "Venue Approvals", + icon: Clock3, + path: "/admin/venues/pending", + }, + { + label: "Venue Options", + icon: Settings2, + path: "/admin/venue-options", + }, +]; + +export default function AdminSidebar() { + const { logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = () => { + logout(); + navigate("/admin", { replace: true }); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/admin/OptionManagerTable.jsx b/frontend/src/components/admin/OptionManagerTable.jsx new file mode 100644 index 000000000..70654e7a9 --- /dev/null +++ b/frontend/src/components/admin/OptionManagerTable.jsx @@ -0,0 +1,358 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { useEffect, useMemo, useState } from "react"; +import { + Pencil, + Trash2, + Plus, + Check, + X, + Power, +} from "lucide-react"; +import { showInfo } from "../../utils/toastBus"; + +export default function OptionManagerTable({ + title, + itemLabel, + list, + create, + update, + remove, +}) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const [search, setSearch] = useState(""); + + const [newName, setNewName] = useState(""); + const [showCreate, setShowCreate] = useState(false); + + const [editingId, setEditingId] = useState(null); + const [editValue, setEditValue] = useState(""); + + async function load() { + setLoading(true); + setError(""); + + try { + const data = await list(); + + setItems( + data.sort((a, b) => a.name.localeCompare(b.name)) + ); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + load(); + }, []); + + async function handleCreate() { + if (!newName.trim()) return; + + try { + const created = await create(newName.trim()); + + setItems((prev) => + [...prev, created].sort((a, b) => + a.name.localeCompare(b.name) + ) + ); + + setNewName(""); + setShowCreate(false); + + showInfo(`${itemLabel} added`); + } catch { + // handled by api client + } + } + + async function save(id) { + if (!editValue.trim()) return; + + try { + const updated = await update(id, { + name: editValue.trim(), + }); + + setItems((prev) => + prev + .map((item) => + item._id === id ? updated : item + ) + .sort((a, b) => + a.name.localeCompare(b.name) + ) + ); + + setEditingId(null); + setEditValue(""); + + showInfo(`${itemLabel} updated`); + } catch { + // handled by api client + } + } + + async function toggle(item) { + try { + const updated = await update(item._id, { + isActive: !item.isActive, + }); + + setItems((prev) => + prev.map((it) => + it._id === item._id ? updated : it + ) + ); + } catch { + // handled by api client + } + } + + async function handleDelete(id) { + try { + await remove(id); + + setItems((prev) => + prev.filter((item) => item._id !== id) + ); + + showInfo(`${itemLabel} deleted`); + } catch { + // handled by api client + } + } + + const filtered = useMemo(() => { + return items.filter((item) => + item.name + .toLowerCase() + .includes(search.toLowerCase()) + ); + }, [items, search]); + + return ( + <> + {/* Toolbar */} +
+ + + setSearch(e.target.value)} + className="w-80 rounded-lg border border-gray-300 px-4 py-2 outline-none focus:border-red-500" + /> +
+ + {/* Create Row */} + {showCreate && ( +
+
+ setNewName(e.target.value)} + placeholder={`Enter ${itemLabel.toLowerCase()} name`} + className="flex-1 rounded-lg border border-gray-300 px-4 py-2 outline-none focus:border-red-500" + /> + + + + +
+
+ )} + + {/* Table */} +
+ + + + + + + + + + + {loading && ( + + + + )} + + {!loading && error && ( + + + + )} + + {!loading && + !error && + filtered.map((item) => ( + + + + + + + + ))} + + {!loading && + !error && + filtered.length === 0 && ( + + + + )} + +
NameStatus + Actions +
+ Loading... +
+ {error} +
+ {editingId === item._id ? ( +
+ + setEditValue(e.target.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") + save(item._id); + + if (e.key === "Escape") { + setEditingId(null); + setEditValue(""); + } + }} + className="w-72 rounded-lg border border-gray-300 px-3 py-2 outline-none focus:border-red-500" + /> + + + + +
+ ) : ( + + {item.name} + + )} +
+ + {item.isActive + ? "Active" + : "Inactive"} + + +
+ + + + + +
+
+ No {title.toLowerCase()} found. +
+
+ + ); +} \ No newline at end of file diff --git a/frontend/src/components/shared/LoginModal.jsx b/frontend/src/components/shared/LoginModal.jsx new file mode 100644 index 000000000..2c542149d --- /dev/null +++ b/frontend/src/components/shared/LoginModal.jsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import { GoogleLogin } from '@react-oauth/google'; +import { useAuth } from '../../context/authContext.js'; + +// Customer Google login popup +export function LoginModal() { + const { loginOpen, closeLogin, loginWithGoogle } = useAuth(); + const [error, setError] = useState(''); + + if (!loginOpen) return null; + + async function handleSuccess(credentialResponse) { + setError(''); + try { + // credentialResponse.credential is the Google ID token (a JWT). + // loginWithGoogle closes this modal on success. + await loginWithGoogle(credentialResponse.credential); + } catch (err) { + setError(err.message || 'Login failed. Please try again.'); + } + } + + return ( +
{ + if (e.target === e.currentTarget) closeLogin(); + }} + > +
+ + {/* Header */} +
+

Login to BookMyVenue

+ +
+ + {/* Body */} +
+ +

+ Login to discover and book venues. +

+ + setError('Google login was cancelled or failed.')} + /> + + {error &&

{error}

} +
+
+
+ ); +} diff --git a/frontend/src/components/shared/RequireAuth.jsx b/frontend/src/components/shared/RequireAuth.jsx new file mode 100644 index 000000000..2699344db --- /dev/null +++ b/frontend/src/components/shared/RequireAuth.jsx @@ -0,0 +1,54 @@ +import { useEffect } from "react"; +import { Navigate, Outlet } from "react-router-dom"; +import { useAuth } from "../../context/authContext.js"; + +// Reusable route guard. Wrap a group of routes with it as a layout route so +// every nested route is protected by default. +// +// On denial — not logged in, OR logged in with the wrong role — the user is +// sent to that route's "area". Where that is depends on `loginPath`: +// +// // venue owners only -> denial redirects to the venue owner login page +// }>... +// +// // admins only -> denial redirects to the admin login page +// }>... +// +// // customer area has no login page, just the global login modal: +// // omit loginPath -> denial sends them home and opens the modal +// }>... +// +// `roles` is required — every guarded area belongs to a specific role. The user +// must be logged in AND have one of those roles to enter. +// +// Auth is already verified by AuthProvider (it validates the token against the +// backend on load and clears it if invalid/expired), so here we only read the +// resolved result. +export function RequireAuth({ roles, loginPath }) { + const { user, loading, openLogin } = useAuth(); + + // Wait for the initial token check so a hard refresh doesn't bounce a + // logged-in user to the login page before their session is restored. + if (loading) return null; + + const allowed = user && roles.includes(user.role); + if (allowed) return ; + + // Denied. Areas with their own login page (venue owner/admin) redirect there. + // Customer-area routes have no page — they fall back to the home page plus + // the global login modal. + if (loginPath) { + return ; + } + return ; +} + +// Customer-area denial: navigate home and open the login modal. Opening the +// modal is a state update, so it has to run in an effect, not during render. +function RedirectToLoginModal({ openLogin }) { + useEffect(() => { + openLogin(); + }, [openLogin]); + + return ; +} diff --git a/frontend/src/components/shared/Toast.jsx b/frontend/src/components/shared/Toast.jsx new file mode 100644 index 000000000..6565cc404 --- /dev/null +++ b/frontend/src/components/shared/Toast.jsx @@ -0,0 +1,15 @@ +// App-wide toast. `type` selects the color: "error" → red, "info" → green. +const TYPE_STYLES = { + error: { container: "bg-red-500", close: "hover:text-red-200" }, + info: { container: "bg-green-500", close: "hover:text-green-200" }, +}; + +export function Toast({ message, type = "error", onClose }) { + const styles = TYPE_STYLES[type] ?? TYPE_STYLES.error; // defaults is error + return ( +
+ {message} + +
+ ); +} diff --git a/frontend/src/components/shared/ToastViewport.jsx b/frontend/src/components/shared/ToastViewport.jsx new file mode 100644 index 000000000..c8d10fee6 --- /dev/null +++ b/frontend/src/components/shared/ToastViewport.jsx @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useState } from "react"; +import { toastBus } from "../../utils/toastBus"; +import { Toast } from "./Toast"; + +// Mount once near the app root. Subscribes to the toast bus and renders the +// active toast. Components and plain modules fire toasts via showError / +// showInfo from utils/toastBus — this only owns the on-screen state and +// auto-dismiss. +export function ToastViewport() { + const [toast, setToast] = useState(null); + + const showToast = useCallback((message, type) => { + setToast({ message, type }); + setTimeout(() => setToast(null), 5000); + }, []); + + useEffect(() => { + toastBus.subscribe(showToast); + return () => toastBus.unsubscribe(showToast); + }, [showToast]); + + if (!toast) return null; + + return ( + setToast(null)} /> + ); +} \ No newline at end of file diff --git a/frontend/src/components/shared/form/InputWithIcon.jsx b/frontend/src/components/shared/form/InputWithIcon.jsx new file mode 100644 index 000000000..b9ec73ec9 --- /dev/null +++ b/frontend/src/components/shared/form/InputWithIcon.jsx @@ -0,0 +1,44 @@ +import { inputClass } from "./inputClasses.js"; + +// Labeled text input with a leading icon and inline error message. +// `icon` is a lucide-react component. `error` is the matching RHF error object +// (e.g. errors.email). `registration` is the spread of register("field", rules). +// `rightSlot` renders inside the input wrapper on the right (e.g. a show/hide +// button) — pass it and the caller is responsible for the input's right padding. +// Any extra props (type, placeholder, ...) pass through to the . +export function InputWithIcon({ + id, + label, + icon: Icon, + error, + registration, + rightSlot, + ...inputProps +}) { + return ( +
+ +
+ + + {rightSlot} +
+ {error && ( +

{error.message}

+ )} +
+ ); +} diff --git a/frontend/src/components/shared/form/PasswordField.jsx b/frontend/src/components/shared/form/PasswordField.jsx new file mode 100644 index 000000000..ca4f738af --- /dev/null +++ b/frontend/src/components/shared/form/PasswordField.jsx @@ -0,0 +1,25 @@ +import { Lock, Eye, EyeOff } from "lucide-react"; +import { InputWithIcon } from "./InputWithIcon.jsx"; + +// Password input built on InputWithIcon: fixed Lock icon plus a show/hide toggle. +// The toggle is controlled by the parent (`show` + `onToggle`) so multiple +// password fields can share one visibility state. +export function PasswordField({ show, onToggle, ...props }) { + return ( + + {show ? : } + + } + {...props} + /> + ); +} \ No newline at end of file diff --git a/frontend/src/components/shared/form/inputClasses.js b/frontend/src/components/shared/form/inputClasses.js new file mode 100644 index 000000000..1b4ddd2b6 --- /dev/null +++ b/frontend/src/components/shared/form/inputClasses.js @@ -0,0 +1,9 @@ +// Simple email format check, shared by email-field validation rules. +export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +// Shared text-input styling; swaps the border colour when the field has an error. +export const inputClass = (hasError) => + "w-full rounded-lg border py-3 pl-10 pr-3 text-gray-900 placeholder-gray-400 transition focus:outline-none focus:ring-2 " + + (hasError + ? "border-red-400 focus:border-red-500 focus:ring-red-100" + : "border-gray-300 focus:border-red-500 focus:ring-red-100"); diff --git a/frontend/src/components/user/CategoryCard.jsx b/frontend/src/components/user/CategoryCard.jsx new file mode 100644 index 000000000..cd924fd2b --- /dev/null +++ b/frontend/src/components/user/CategoryCard.jsx @@ -0,0 +1,39 @@ +export default function CategoryCard({ category, bgColor }) { + return ( +
+

+ {category.name} +

+
+ ); +} + diff --git a/frontend/src/components/user/HomeBannerSection.jsx b/frontend/src/components/user/HomeBannerSection.jsx new file mode 100644 index 000000000..580b2102c --- /dev/null +++ b/frontend/src/components/user/HomeBannerSection.jsx @@ -0,0 +1,27 @@ +import { Link } from "react-router-dom" + +export default function HomeBannerSection() { + return ( +
+
+ +

+ Find & Book the Perfect Venue Across Kerala +

+ +

+ Wedding halls, auditoriums, resorts, cafés, studios and + more — all in one place. +

+ + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/user/HomeVenueCard.jsx b/frontend/src/components/user/HomeVenueCard.jsx new file mode 100644 index 000000000..7a2726474 --- /dev/null +++ b/frontend/src/components/user/HomeVenueCard.jsx @@ -0,0 +1,35 @@ +import { useNavigate } from "react-router-dom"; +export default function HomeVenueCard({ venue }) { + const navigate = useNavigate(); + return ( +
+ {venue.name} + +
+

+ {venue.name} +

+ +

+ {venue.venueCategory.name} +

+ +

+ 📍 {venue.district} +

+ +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/user/HomeVenueSection.jsx b/frontend/src/components/user/HomeVenueSection.jsx new file mode 100644 index 000000000..07ab23bca --- /dev/null +++ b/frontend/src/components/user/HomeVenueSection.jsx @@ -0,0 +1,34 @@ +import { Link } from "react-router-dom"; +import VenueCard from "./HomeVenueCard.jsx"; + +export default function HomeVenueSection({ + title, + venues, + showAllLink = "/venue", +}) { + return ( +
+
+

+ {title} +

+ + + Show All → + +
+ +
+ {venues.map((venue) => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/user/LocationModal.jsx b/frontend/src/components/user/LocationModal.jsx new file mode 100644 index 000000000..bd48f4749 --- /dev/null +++ b/frontend/src/components/user/LocationModal.jsx @@ -0,0 +1,66 @@ +const KERALA_DISTRICTS = [ + "Thiruvananthapuram", + "Kollam", + "Pathanamthitta", + "Alappuzha", + "Kottayam", + "Idukki", + "Ernakulam", + "Thrissur", + "Palakkad", + "Malappuram", + "Kozhikode", + "Wayanad", + "Kannur", + "Kasaragod", +]; + +export function LocationModal({ open, onClose, onSelect }) { + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ + +
+

+ Select District +

+ + +
+ + {/* District Listing */} +
+
+ + {KERALA_DISTRICTS.map((district) => ( + + ))} + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/user/Navbar.jsx b/frontend/src/components/user/Navbar.jsx new file mode 100644 index 000000000..4b9e422f6 --- /dev/null +++ b/frontend/src/components/user/Navbar.jsx @@ -0,0 +1,167 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { LocationModal } from "./LocationModal.jsx"; +import { useAuth } from "../../context/authContext.js"; + +export default function Navbar() { + const [menuOpen, setMenuOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + const [locationOpen, setLocationOpen] = useState(false); + const [district, setDistrict] = useState("Ernakulam"); + const { user, logout, openLogin } = useAuth(); + + return ( + + + ) +} + diff --git a/frontend/src/components/user/VenueCard.jsx b/frontend/src/components/user/VenueCard.jsx new file mode 100644 index 000000000..fb13bcabe --- /dev/null +++ b/frontend/src/components/user/VenueCard.jsx @@ -0,0 +1,47 @@ +import { useNavigate } from "react-router-dom"; +function VenueCard({ venue }) { + const navigate = useNavigate(); + return ( +
+ + {venue.name} + +
+ + {venue.venueCategory.name} + + +

+ {venue.name} +

+ +

+ {venue.description} +

+ +
+

📍 {venue.city}, {venue.state}

+

👥 Capacity: {venue.capacity}

+
+ +
+ + ₹{venue.basePrice.toLocaleString()} + + + +
+
+
+ ); +} + +export default VenueCard; \ No newline at end of file diff --git a/frontend/src/components/user/VenueFilterModal.jsx b/frontend/src/components/user/VenueFilterModal.jsx new file mode 100644 index 000000000..f300ddae3 --- /dev/null +++ b/frontend/src/components/user/VenueFilterModal.jsx @@ -0,0 +1,181 @@ +import { useState } from "react"; + +export default function VenueFilterModal({ + open, + onClose, + appliedFilters, + onApply, + categories, +}) { + const [activeSection, setActiveSection] = useState("category"); + + const [tempFilters, setTempFilters] = useState(appliedFilters); + + + const PRICE_RANGES = [ + { + label: "< ₹45K", + minPrice: "", + maxPrice: 45000, + }, + { + label: "₹45K - ₹60K", + minPrice: 45000, + maxPrice: 60000, + }, + { + label: "₹60K - ₹100K", + minPrice: 60000, + maxPrice: 100000, + }, + { + label: "₹100K+", + minPrice: 100000, + maxPrice: "", + }, + ]; + + const clearFilters = () => { + setTempFilters({ + district: "", + category: "", + minPrice: "", + maxPrice: "", + }); + }; + + const handleApply = () => { + onApply(tempFilters); + onClose(); + }; + + if (!open) return null; + + return ( +
+ +
+ + {/* Header */} +
+

+ Filters +

+ + +
+ + {/* Body */} +
+ + {/* Left Menu */} +
+ + + + + +
+ + {/* Right Side */} +
+ + {activeSection === "category" && ( +
+ + {categories.map((category) => ( + + ))} + +
+ )} + + {activeSection === "price" && ( +
+ + {PRICE_RANGES.map((range) => ( + + ))} + +
+ )} + +
+ +
+ + {/* Footer */} +
+ + + + + +
+ +
+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/venueOwner/BookingHistory.jsx b/frontend/src/components/venueOwner/BookingHistory.jsx new file mode 100644 index 000000000..0609cb72d --- /dev/null +++ b/frontend/src/components/venueOwner/BookingHistory.jsx @@ -0,0 +1,29 @@ +import { statusStyle } from "../../utils/ownerPageUtils"; + +export function BookingHistory({ history }) { + return ( +
+
+

Booking History

+
+
+ {history.map((h) => ( +
+
+

{h.venue}

+

{h.customer} · {h.date}

+
+
+ + {h.status} + + + ₹{h.amount.toLocaleString()} + +
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/venueOwner/CategoryBreakdown.jsx b/frontend/src/components/venueOwner/CategoryBreakdown.jsx new file mode 100644 index 000000000..d19635c15 --- /dev/null +++ b/frontend/src/components/venueOwner/CategoryBreakdown.jsx @@ -0,0 +1,28 @@ +import { CATEGORY_COLORS } from "../../utils/ownerPageUtils"; + +export function CategoryBreakdown({ entries, totalRevenue }) { + return ( +
+

Revenue by Category

+
+ {entries.map(([category, value], i) => { + const pct = Math.round((value / totalRevenue) * 100); + return ( +
+
+ {category} + {pct}% +
+
+
+
+
+ ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/venueOwner/DashboardStatCard.jsx b/frontend/src/components/venueOwner/DashboardStatCard.jsx new file mode 100644 index 000000000..36176a3f8 --- /dev/null +++ b/frontend/src/components/venueOwner/DashboardStatCard.jsx @@ -0,0 +1,27 @@ +export default function DashboardStatCard({ + icon: Icon, + title, + value, + iconBg, + iconColor, +}) { + return ( +
+
+
+ +
+ + + {title} + +
+ +

+ {value} +

+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/venueOwner/DashboardTabs.jsx b/frontend/src/components/venueOwner/DashboardTabs.jsx new file mode 100644 index 000000000..2b896bd44 --- /dev/null +++ b/frontend/src/components/venueOwner/DashboardTabs.jsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; + +export const TABS = [ + { + key: "active", + label: "Active Venues", + statuses: ["APPROVED"], + emptyTabText: "No approved venues yet.", + }, + { + key: "inProgress", + label: "In Progress", + statuses: ["DRAFT", "EDIT_DRAFT"], + emptyTabText: "No drafts in progress.", + }, + { + key: "inReview", + label: "Under Review", + statuses: ["PENDING", "CHANGES_PENDING", "REJECTED"], + emptyTabText: "No venues under review.", + }, +]; + +export function getEmptyTabText(tabKey) { + return TABS.find(tab => tab.key === tabKey).emptyTabText; +} + +export default function DashboardTabs({ activeTab, counts, onTabChange }) { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
+ {/* Desktop */} +
+ {TABS.map(tab => ( + + ))} +
+ + {/* Mobile: active tab label + hamburger */} +
+ + {TABS.find(t => t.key === activeTab)?.label} + ({counts[activeTab] ?? 0}) + + +
+ + {/* Mobile sidebar */} + {sidebarOpen && ( +
+
setSidebarOpen(false)} /> +
+
+ Sections + +
+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/venueOwner/DaySchedule.jsx b/frontend/src/components/venueOwner/DaySchedule.jsx new file mode 100644 index 000000000..32a42aa97 --- /dev/null +++ b/frontend/src/components/venueOwner/DaySchedule.jsx @@ -0,0 +1,114 @@ +import { Clock3, Users, Wallet, X } from "lucide-react"; +import { HOUR_WIDTH, SCHEDULE_END, SCHEDULE_START, statusStyle, timeToFraction } from "../../utils/ownerPageUtils"; + +export function DaySchedule({ dayKey, bookings, onClose }) { + const hours = []; + for (let h = SCHEDULE_START; h <= SCHEDULE_END; h++) hours.push(h); + + const dateLabel = dayKey + ? new Date(dayKey + "T00:00:00").toLocaleDateString("default", { + weekday: "long", + month: "long", + day: "numeric", + }) + : null; + + return ( +
+
+

+ {dayKey ? dateLabel : "Select a date"} +

+ {dayKey && ( + + )} +
+ + {!dayKey && ( +

+ Click a date on the calendar to see its booking timeline. +

+ )} + + {dayKey && bookings.length === 0 && ( +

+ No bookings for this date. +

+ )} + + {dayKey && bookings.length > 0 && ( + +
+
+ {/* Hour ruler */} +
+ {hours.map((h) => ( +
+ {h % 24}:00 +
+ ))} +
+ + +
+ {bookings.map((b) => { + const startFrac = Math.max(timeToFraction(b.start), SCHEDULE_START); + const endFrac = Math.min(timeToFraction(b.end), SCHEDULE_END + 1); + const left = (startFrac - SCHEDULE_START) * HOUR_WIDTH; + const width = Math.max((endFrac - startFrac) * HOUR_WIDTH, 24); + + return ( +
+
+ {b.venue} + {b.customer} +
+
+
+ + + {b.start}–{b.end} · ₹{b.amount.toLocaleString()} + +
+
+
+ ); + })} +
+
+
+ )} + + {dayKey && bookings.length > 0 && ( +
+ {bookings.map((b) => ( +
+
+ {b.venue} + + {b.status} + +
+
+ {b.guests} + {b.start}–{b.end} + ₹{b.amount.toLocaleString()} +
+
+ ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/venueOwner/MiniCalendar.jsx b/frontend/src/components/venueOwner/MiniCalendar.jsx new file mode 100644 index 000000000..18ecea025 --- /dev/null +++ b/frontend/src/components/venueOwner/MiniCalendar.jsx @@ -0,0 +1,81 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { fmtKey } from "../../utils/ownerPageUtils"; + +const TODAY = new Date(); + +export function MiniCalendar({ year, month, onMonthChange, bookingsByDay, selectedDay, onSelectDay }) { + const firstDayOfMonth = new Date(year, month, 1).getDay(); // 0 = Sun + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const monthLabel = new Date(year, month, 1).toLocaleString("default", { + month: "long", + year: "numeric", + }); + + const cells = []; + for (let i = 0; i < firstDayOfMonth; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(d); + + const isToday = (d) => + d === TODAY.getDate() && month === TODAY.getMonth() && year === TODAY.getFullYear(); + + return ( +
+
+

{monthLabel}

+
+ + +
+
+ +
+ {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((d) => ( +
{d}
+ ))} +
+ +
+ {cells.map((d, i) => { + if (d === null) return
; + const key = fmtKey(year, month, d); + const dayBookings = bookingsByDay[key] || []; + const isSelected = selectedDay === key; + + return ( + + ); + })} +
+ +
+ + Has bookings +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/venueOwner/QuickAccessCard.jsx b/frontend/src/components/venueOwner/QuickAccessCard.jsx new file mode 100644 index 000000000..e48dbb1b8 --- /dev/null +++ b/frontend/src/components/venueOwner/QuickAccessCard.jsx @@ -0,0 +1,26 @@ +export default function QuickAccessCard({ + icon: Icon, + title, + iconColor, + bgColor, + onClick, +}) { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/venueOwner/RecentActivities.jsx b/frontend/src/components/venueOwner/RecentActivities.jsx new file mode 100644 index 000000000..a23616401 --- /dev/null +++ b/frontend/src/components/venueOwner/RecentActivities.jsx @@ -0,0 +1,64 @@ +import { CheckCircle2, CalendarDays, Clock3, XCircle } from "lucide-react"; + +const activities = [ + { + id: 1, + icon: CheckCircle2, + title: 'Venue "Royal Hall" approved', + time: "2 hours ago", + color: "text-green-600", + }, + { + id: 2, + icon: CalendarDays, + title: "New booking from Rahul", + time: "4 hours ago", + color: "text-blue-600", + }, + { + id: 3, + icon: Clock3, + title: 'Venue "Grand Resort" submitted for review', + time: "Yesterday", + color: "text-yellow-600", + }, + { + id: 4, + icon: XCircle, + title: 'Venue "ABC Hall" rejected', + time: "2 days ago", + color: "text-red-600", + }, +]; + +export default function RecentActivities() { + return ( +
+ {activities.map((activity) => { + const Icon = activity.icon; + + return ( +
+
+ + +

+ {activity.title} +

+
+ +

+ {activity.time} +

+
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/venueOwner/RevenueTrendChart.jsx b/frontend/src/components/venueOwner/RevenueTrendChart.jsx new file mode 100644 index 000000000..f63d83059 --- /dev/null +++ b/frontend/src/components/venueOwner/RevenueTrendChart.jsx @@ -0,0 +1,35 @@ +export function RevenueTrendChart({ monthlyTrend }) { + const max = Math.max(...monthlyTrend.map((m) => m.value)); + + return ( +
+
+

Revenue Trend

+ Last 6 months +
+ +
+ {monthlyTrend.map((m) => { + const heightPct = (m.value / max) * 100; + const isLast = m.month === monthlyTrend[monthlyTrend.length - 1].month; + return ( +
+ + ₹{(m.value / 1000).toFixed(0)}K + +
+
+
+ {m.month} +
+ ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/venueOwner/StatCard.jsx b/frontend/src/components/venueOwner/StatCard.jsx new file mode 100644 index 000000000..9ca8d38f7 --- /dev/null +++ b/frontend/src/components/venueOwner/StatCard.jsx @@ -0,0 +1,14 @@ +export function StatCard({ icon: Icon, title, value, sub, iconBg, iconColor }) { + return ( +
+
+
+ +
+ {title} +
+

{value}

+ {sub &&

{sub}

} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/venueOwner/VenueForm.jsx b/frontend/src/components/venueOwner/VenueForm.jsx new file mode 100644 index 000000000..597664109 --- /dev/null +++ b/frontend/src/components/venueOwner/VenueForm.jsx @@ -0,0 +1,658 @@ +import { useEffect, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { getVenueCategories } from "../../services/venueCategory.service.js"; +import { getAmenities } from "../../services/amenity.service.js"; + +const AUTOSAVE_DELAY_MS = 2000; + +// Builds the API payload from raw RHF values. Autosave persists whatever the +// owner has typed — including blanks (so clearing a field is saved) and +// format-invalid values — since a draft may be incomplete. Number fields are +// enforced numeric at the input level, so they coerce cleanly here (empty → null +// to clear). Used by both autosave and the final submit. +function buildPayload(data) { + const payload = { + name: (data.name ?? "").trim(), + description: (data.description ?? "").trim(), + venueCategory: data.venueCategory || "", + // RHF collects the checked amenity checkboxes into an array of _id + // strings (or a bare string if the group renders a single box). Normalise + // to an array and drop any falsy entries left by unchecked boxes. + amenities: [data.amenities].flat().filter(Boolean), + addressLine: (data.addressLine ?? "").trim(), + city: (data.city ?? "").trim(), + district: data.district || "", + state: (data.state ?? "").trim(), + pincode: (data.pincode ?? "").trim(), + // With valueAsNumber, an empty/invalid number input yields NaN — store + // null in that case + capacity: Number.isFinite(data.capacity) ? data.capacity : null, + basePrice: Number.isFinite(data.basePrice) ? data.basePrice : null, + location: + data.latitude && data.longitude + ? { type: "Point", coordinates: [parseFloat(data.longitude), parseFloat(data.latitude)] } + : null, + }; + return payload; +} + +const KERALA_DISTRICTS = [ + "Thiruvananthapuram", "Kollam", "Pathanamthitta", "Alappuzha", + "Kottayam", "Idukki", "Ernakulam", "Thrissur", "Palakkad", + "Malappuram", "Kozhikode", "Wayanad", "Kannur", "Kasaragod", +]; + +// --- Small helpers ----------------------------------------------------------- + +function FieldError({ error }) { + if (!error) return null; + return

{error.message}

; +} + +// Blocks characters type="number" still permits (e, E, +, -) plus the decimal +// point for integer fields — so number inputs only ever hold digits. +function blockNonInteger(e) { + if (["e", "E", "+", "-", "."].includes(e.key)) e.preventDefault(); +} + +function inputCls(hasError) { + return ( + "w-full rounded-lg border px-4 py-2.5 text-sm text-gray-900 " + + "placeholder-gray-400 transition focus:outline-none focus:ring-2 " + + (hasError + ? "border-red-400 focus:border-red-500 focus:ring-red-100" + : "border-gray-300 focus:border-red-500 focus:ring-red-100") + ); +} + +// ---------------------------------------------------------------------------- + +export default function VenueForm({ initialValues = {}, onAutosave, onSubmit, onContinueLater }) { + const [categories, setCategories] = useState([]); + const [loadingCategories, setLoadingCategories] = useState(true); + const [amenities, setAmenities] = useState([]); + const [loadingAmenities, setLoadingAmenities] = useState(true); + const [imageFiles, setImageFiles] = useState([]); // new uploads + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(""); + // "idle" | "saving" | "saved" — drives the subtle autosave indicator. + const [saveStatus, setSaveStatus] = useState("idle"); + // True from the moment an edit arms the debounce timer until that autosave + // settles — used to block "Continue Editing Later" so a pending save isn't + // dropped by navigating away. + const [pendingSave, setPendingSave] = useState(false); + + const { + register, + handleSubmit, + trigger, + subscribe, + setError, + getValues, + formState: { errors }, + } = useForm({ + defaultValues: { + name: initialValues.name || "", + description: initialValues.description || "", + venueCategory: initialValues.venueCategory?._id + || initialValues.venueCategory + || "", + // Populated amenities arrive as {_id, identifier, name} objects (or + // bare id strings on an unpopulated read); reduce to id strings so the + // checkbox `value`s match and pre-check the venue's saved amenities. + amenities: (initialValues.amenities || []).map((a) => a?._id || a), + capacity: initialValues.capacity || "", + basePrice: initialValues.basePrice || "", + addressLine: initialValues.addressLine || "", + city: initialValues.city || "", + district: initialValues.district || "", + state: initialValues.state || "", + pincode: initialValues.pincode || "", + latitude: initialValues.location?.coordinates?.[1] || "", + longitude: initialValues.location?.coordinates?.[0] || "", + }, + }); + + useEffect(() => { + async function loadCategories() { + try { + const data = await getVenueCategories(); + setCategories(data); + } catch (err) { + console.error(err); + } finally { + setLoadingCategories(false); + } + } + async function loadAmenities() { + try { + const data = await getAmenities(); + setAmenities(data); + } catch (err) { + console.error(err); + } finally { + setLoadingAmenities(false); + } + } + loadCategories(); + loadAmenities(); + }, []); + + // ── Autosave ───────────────────────────────────────────────────────────── + // Debounced ~2s after the user stops typing. The full payload is saved as-is + // (including blank/invalid values — a draft may be incomplete; the submit + // endpoint is the validation gate), skipping the API call when nothing changed + // since the last save. The parent's onAutosave performs the PATCH; we only + // drive the "saving"/"saved" indicator here. + const lastSavedRef = useRef(""); // JSON snapshot of the last payload we sent + const onAutosaveRef = useRef(onAutosave); + onAutosaveRef.current = onAutosave; + // Fields changed since the last autosave — each cycle validates only these + // (see runAutosave). Because trigger() is additive, errors accumulate across + // cycles, so every edited field stays flagged while untouched fields stay quiet. + const dirtyFieldsRef = useRef(new Set()); + + useEffect(() => { + if (!onAutosave) return; + const unsubscribe = subscribe({ + formState: { values: true }, + callback: ({ name }) => { + if (name) dirtyFieldsRef.current.add(name); + if (autosaveTimer.current) clearTimeout(autosaveTimer.current); + setPendingSave(true); + autosaveTimer.current = setTimeout(runAutosave, AUTOSAVE_DELAY_MS); + }, + }); + return () => { + unsubscribe(); + if (autosaveTimer.current) clearTimeout(autosaveTimer.current); + }; + // runAutosave is intentionally omitted — including it would re-subscribe + // the listener on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subscribe, onAutosave]); + + const autosaveTimer = useRef(null); + + async function runAutosave() { + // Cleared once this run settles (any exit path) so "Continue Editing + // Later" re-enables only after the pending save is resolved. + try { + const payload = buildPayload(getValues()); + + // Validate only the fields changed this cycle. trigger() is additive + // — it updates the errors for just these fields and leaves all other + // entries in formState.errors untouched. So errors accumulate: every + // field the user has edited since opening the form keeps showing its + // error until that field is re-validated as valid, while fields never + // touched stay quiet. We save regardless of validity (a draft may be + // incomplete; the submit endpoint is the gate) + const changedFields = [...dirtyFieldsRef.current]; + dirtyFieldsRef.current.clear(); + if (changedFields.length) trigger(changedFields); + + const snapshot = JSON.stringify(payload); + if (snapshot === lastSavedRef.current) return; // nothing changed + + setSaveStatus("saving"); + try { + await onAutosaveRef.current(payload); + lastSavedRef.current = snapshot; + setSaveStatus("saved"); + } catch { + // api client already toasts; reset the indicator so it isn't stuck on "saving". + setSaveStatus("idle"); + } + } finally { + setPendingSave(false); + } + } + + // "Submit for Approval" — full validation gate, then hand the complete payload up. + async function handleFormSubmit(data) { + if (!onSubmit) return; + + setSubmitting(true); + setSubmitError(""); + try { + await onSubmit({ ...buildPayload(data), imageFiles }); + } catch (err) { + // The submit endpoint returns { missingFields: [...] } when the draft + // is incomplete. The field names match this form's register keys, so + // flag each one inline; focus the first so the user lands on it. + const missingFields = err.data?.missingFields; + if (Array.isArray(missingFields) && missingFields.length) { + missingFields.forEach((field, i) => + setError(field, { type: "server", message: "This field is required." }, { shouldFocus: i === 0 }) + ); + setSubmitError("Please complete the highlighted fields before submitting."); + } else { + setSubmitError(err.message || "Something went wrong. Please try again."); + } + } finally { + setSubmitting(false); + } + } + + return ( +
+ {/* Header — title + autosave indicator on the left, actions top-right */} +
+
+

+ {initialValues.status === "DRAFT" ? "Add Venue" : "Edit Venue"} +

+ {/* Subtle autosave indicator, directly under the title */} +

+ {saveStatus === "saving" && ( + Saving… + )} + {saveStatus === "saved" && ( + Saved + )} + {saveStatus === "idle" && ( + Changes save automatically. + )} +

+
+ +
+ + +
+
+ + {/* Global submit error */} + {submitError && ( +
+ {submitError} +
+ )} + + {/* ── Basic Information ─────────────────────────────────────── */} +
+

+ Basic Information +

+ +
+ {/* Venue Name */} +
+ + + +
+ + {/* Description */} +
+ +