diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..6cdec2c69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.turbo +.env +dist \ No newline at end of file diff --git a/apps/admin/.env.example b/apps/admin/.env.example new file mode 100644 index 000000000..e767feb50 --- /dev/null +++ b/apps/admin/.env.example @@ -0,0 +1,9 @@ +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= + +NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in +NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/ + +CLERK_WEBHOOK_SECRET= + +DATABASE_URL="postgresql://:@:/bookmyvenue?schema=public" diff --git a/apps/admin/.gitignore b/apps/admin/.gitignore new file mode 100644 index 000000000..4210409e4 --- /dev/null +++ b/apps/admin/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/admin/README.md b/apps/admin/README.md new file mode 100644 index 000000000..e215bc4cc --- /dev/null +++ b/apps/admin/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/admin/app/(admin)/bookings/page.tsx b/apps/admin/app/(admin)/bookings/page.tsx new file mode 100644 index 000000000..bc51ebc25 --- /dev/null +++ b/apps/admin/app/(admin)/bookings/page.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Search, Download, CalendarCheck } from "lucide-react"; +import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from "../../../components/ui/table"; +import { Booking, fetchBookings } from "../../actions/booking"; +import { BookingStatus } from "@bookmyvenue/database/enums"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "../../../components/ui/pagination"; +import { BOOKING_STATUS_STYLE, fmtAmount } from "../../../lib/utils"; +import { Skeleton } from "../../../components/ui/skeleton"; + +const PAGE_SIZE = 10; + +export default function BookingsPage() { + const [bookingSearch, setBookingSearch] = useState(""); + const [bookingFilter, setBookingFilter] = useState("All"); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [bookings, setBookings] = useState([]); + const [loading, setLoading] = useState(true); + + const bookingFilters: (BookingStatus | "All")[] = [ + "All", + ...Object.values(BookingStatus).filter((status) => status !== BookingStatus.PENDING), + ]; + + useEffect(() => { + fetchBookings("All", 1, PAGE_SIZE).then((result) => { + setTotal(result.total); + setBookings(result.bookings); + setLoading(false); + }); + }, []); + + const handleFilterChange = async (filter: BookingStatus | "All") => { + setBookingFilter(filter); + setPage(1); + setLoading(true); + fetchBookings(filter, 1, PAGE_SIZE).then((result) => { + setTotal(result.total); + setBookings(result.bookings); + setLoading(false); + }); + }; + + const handlePageChange = async (p: number) => { + setPage(p); + setLoading(true); + fetchBookings(bookingFilter, p, PAGE_SIZE).then((result) => { + setTotal(result.total); + setBookings(result.bookings); + setLoading(false); + }); + }; + + const filteredBookings = bookings.filter((b) => { + const mq = + b.client.toLowerCase().includes(bookingSearch.toLowerCase()) || + b.venue.toLowerCase().includes(bookingSearch.toLowerCase()) || + b.id.toLowerCase().includes(bookingSearch.toLowerCase()); + return mq; + }); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const pagedBookings = filteredBookings; + + const getPageNumbers = () => { + if (totalPages <= 5) return Array.from({ length: totalPages }, (_, i) => i + 1); + if (page <= 3) return [1, 2, 3, 4, null, totalPages]; + if (page >= totalPages - 2) + return [1, null, totalPages - 3, totalPages - 2, totalPages - 1, totalPages]; + return [1, null, page - 1, page, page + 1, null, totalPages]; + }; + + return ( +
+
+
+ + setBookingSearch(e.target.value)} + /> +
+
+ {bookingFilters.map((s) => ( + + ))} +
+ +
+ +
+ + + + {[ + "Booking ID", + "Client", + "Venue", + "Owner", + "Date", + "Category", + "Amount", + "Status", + ].map((h) => ( + + {h} + + ))} + + + + {loading + ? Array.from({ length: 6 }).map((_, i) => ( + + {Array.from({ length: 8 }).map((_, j) => ( + + + + ))} + + )) + : pagedBookings.map((b) => ( + + + {b.id} + + + {b.client} + + + {b.venue} + + + {b.owner} + + + {b.date} + + + {b.category} + + + {fmtAmount(b.amount)} + + + + {b.status} + + + + ))} + +
+ {!loading && pagedBookings.length === 0 && ( +
+ +

No bookings match your filter.

+
+ )} +
+ +
+ + Showing {total === 0 ? 0 : (page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)}{" "} + of {total} bookings + + + + + { + e.preventDefault(); + handlePageChange(Math.max(1, page - 1)); + }} + aria-disabled={page === 1} + className={page === 1 ? "pointer-events-none opacity-50" : ""} + /> + + {getPageNumbers().map((n, i) => + n === null ? ( + + + + ) : ( + + { + e.preventDefault(); + handlePageChange(n); + }} + > + {n} + + + ), + )} + + { + e.preventDefault(); + handlePageChange(Math.min(totalPages, page + 1)); + }} + aria-disabled={page === totalPages} + className={page === totalPages ? "pointer-events-none opacity-50" : ""} + /> + + + +
+
+ ); +} diff --git a/apps/admin/app/(admin)/layout.tsx b/apps/admin/app/(admin)/layout.tsx new file mode 100644 index 000000000..ae1f9d670 --- /dev/null +++ b/apps/admin/app/(admin)/layout.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useState } from "react"; +import { Sidebar } from "../../components/Sidebar"; +import { TopBar } from "../../components/TopBar"; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
+
+ +
+ + {sidebarOpen && ( +
+
setSidebarOpen(false)} /> +
+ +
+
+ )} + +
+ +
+ {children} +
+
+
+ ); +} diff --git a/apps/admin/app/(admin)/users/page.tsx b/apps/admin/app/(admin)/users/page.tsx new file mode 100644 index 000000000..76c88e726 --- /dev/null +++ b/apps/admin/app/(admin)/users/page.tsx @@ -0,0 +1,5 @@ +import { UsersPage as UsersTab } from "../../../components/tabs/Users"; + +export default function UsersPage() { + return ; +} diff --git a/apps/admin/app/(admin)/venues/page.tsx b/apps/admin/app/(admin)/venues/page.tsx new file mode 100644 index 000000000..474f889a7 --- /dev/null +++ b/apps/admin/app/(admin)/venues/page.tsx @@ -0,0 +1,5 @@ +import { VenuesPage as VenuesTab } from "../../../components/tabs/Venues"; + +export default function VenuesPage() { + return ; +} diff --git a/apps/admin/app/actions/booking.ts b/apps/admin/app/actions/booking.ts new file mode 100644 index 000000000..d22fcce1d --- /dev/null +++ b/apps/admin/app/actions/booking.ts @@ -0,0 +1,39 @@ +"use server"; + +import { unstable_cache } from "next/cache"; +import { prisma, BookingStatus, VenueCategory } from "@bookmyvenue/database"; +import { mapBooking, SELECT_BOOKING } from "./utils"; + +export type Booking = { + id: string; + client: string; + venue: string; + owner: string; + date: string; + category: VenueCategory; + purpose: string; + amount: number; + status: BookingStatus; +}; + +export type BookingPageResult = { bookings: Booking[]; total: number }; + +export const fetchBookings = unstable_cache( + async (status: BookingStatus | "All", page: number, pageSize: number): Promise => { + const where = status === "All" ? {} : { status }; + + const [rows, total] = await Promise.all([ + prisma.booking.findMany({ + where, + select: SELECT_BOOKING, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.booking.count(), + ]); + return { bookings: rows.map((r) => mapBooking(r)), total }; + }, + ["all-bookings"], + { revalidate: 2 * 60, tags: ["bookings"] }, +); diff --git a/apps/admin/app/actions/dashboard.ts b/apps/admin/app/actions/dashboard.ts new file mode 100644 index 000000000..0328deffb --- /dev/null +++ b/apps/admin/app/actions/dashboard.ts @@ -0,0 +1,179 @@ +"use server"; + +import { BookingStatus, prisma, VerificationStatus, District, VenueCategory } from "@bookmyvenue/database"; +import { unstable_cache } from "next/cache"; +import { fromSmallUnit } from "../../lib/utils"; + +export type AdminDashboardStats = { + totalRevenue: number; + totalBookings: number; + approvedVenues: number; + totalUsers: number; +}; + +export type AdminDashboardRevenueData = { + month: string; + revenue: number; + bookings: number; +}; + +export type AdminDashboardCategoryData = { + name: VenueCategory; + value: number; +}; + +export type AdminDashboardDistrictData = { + district: District; + venues: number; + bookings: number; +}; + +export type AdminDashboardRecentBooking = { + id: string; + status: BookingStatus; + user: { + name: string | null; + }; + venue: { + name: string; + }; + eventDate: string | null; + totalAmount: number; +}; + +export type AdminDashboardResponse = { + stats: AdminDashboardStats; + revenueData: AdminDashboardRevenueData[]; + categoryData: AdminDashboardCategoryData[]; + districtData: AdminDashboardDistrictData[]; + recentBookings: AdminDashboardRecentBooking[]; +}; + + +export const fetchAdminDashboard = unstable_cache( + async () => { + const [ + totalBookings, + approvedVenues, + totalUsers, + revenueResult, + categoryResult, + districtResult, + recentBookingsResult, + monthlyBookingSessions, + monthlyBookings, + ] = await Promise.all([ + prisma.venue.count(), + prisma.venue.count({ where: { verificationStatus: VerificationStatus.APPROVED } }), + prisma.user.count(), + prisma.bookingSession.aggregate({ + where: { booking: { status: BookingStatus.CONFIRMED } }, + _sum: { pricePaid: true }, + }), + prisma.venue.groupBy({ by: ["category"], _count: { id: true } }), + prisma.venue.groupBy({ by: ["district"], _count: { id: true } }), + prisma.booking.findMany({ + take: 5, + orderBy: { + createdAt: "desc", + }, + select: { + id: true, + status: true, + user: { select: { name: true } }, + venue: { select: { name: true } }, + bookingSessions: { select: { eventDate: true, pricePaid: true } }, + }, + }), + prisma.bookingSession.findMany({ + where: { booking: { status: "CONFIRMED" } }, + select: { pricePaid: true, booking: { select: { createdAt: true } } }, + }), + prisma.booking.findMany({ select: { createdAt: true } }), + ]); + const totalVenues = categoryResult.reduce((total, category) => total + category._count.id, 0); + + const categoryData = categoryResult.map((category) => ({ + name: category.category, + value: totalVenues > 0 ? Math.round((category._count.id / totalVenues) * 100) : 0, + })); + + const districtBookingCounts = await prisma.booking.groupBy({ + by: ["venueId"], + _count: { + id: true, + }, + }); + + const venueDistricts = await prisma.venue.findMany({ + select: { + id: true, + district: true, + }, + }); + const bookingCountByVenue = new Map( + districtBookingCounts.map((booking) => [booking.venueId, booking._count.id]), + ); + + const bookingsByDistrict = venueDistricts.reduce>>((result, venue) => { + result[venue.district] = (result[venue.district] ?? 0) + (bookingCountByVenue.get(venue.id) ?? 0); + + return result; + }, {}); + + const districtData = districtResult.map((district) => ({ + district: district.district, + venues: district._count.id, + bookings: bookingsByDistrict[district.district] ?? 0, + })); + + const revenueByMonth = new Map(); + const bookingsByMonth = new Map(); + + for (const bookingSession of monthlyBookingSessions) { + const month = bookingSession.booking.createdAt.toLocaleString("en-US", { + month: "short", + }); + revenueByMonth.set(month, (revenueByMonth.get(month) ?? 0) + bookingSession.pricePaid); + } + + for (const booking of monthlyBookings) { + const month = booking.createdAt.toLocaleString("en-US", { + month: "short", + }); + + bookingsByMonth.set(month, (bookingsByMonth.get(month) ?? 0) + 1); + } + + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"]; + + const revenueData = months.map((month) => ({ + month, + revenue: fromSmallUnit(revenueByMonth.get(month) ?? 0), + bookings: bookingsByMonth.get(month) ?? 0, + })); + + const recentBookings = recentBookingsResult.map(({ bookingSessions, ...booking }) => ({ + ...booking, + eventDate: bookingSessions[0]?.eventDate?.toISOString() ?? null, + totalAmount: fromSmallUnit( + bookingSessions.reduce((total, session) => total + session.pricePaid, 0), + ), + })); + + return { + stats: { + totalRevenue: fromSmallUnit(revenueResult._sum.pricePaid ?? 0), + totalBookings, + approvedVenues, + totalUsers, + }, + revenueData, + categoryData, + districtData, + recentBookings, + }; + }, + ["admin-dashboard"], + { revalidate: 60, tags: ["admin-dashboard"] }, +); diff --git a/apps/admin/app/actions/user.ts b/apps/admin/app/actions/user.ts new file mode 100644 index 000000000..4fe503ac1 --- /dev/null +++ b/apps/admin/app/actions/user.ts @@ -0,0 +1,64 @@ +"use server"; + +import { unstable_cache } from "next/cache"; +import { prisma, Role } from "@bookmyvenue/database"; +import { mapOwner, mapCustomer, SELECT_OWNER, SELECT_CUSTOMER } from "./utils"; + +export type Owner = { + id: string; + name: string | null; + email: string; + role: Role; + joined: string; + venues: number; +}; + +export type Customer = { + id: string; + name: string | null; + email: string; + role: Role; + joined: string; + bookings: number; +}; + +export type OwnerPageResult = { owners: Owner[]; total: number }; +export type CustomerPageResult = { customers: Customer[]; total: number }; + +export const fetchOwners = unstable_cache( + async (page: number, pageSize: number): Promise => { + const where = { role: Role.OWNER }; + const [rows, total] = await Promise.all([ + prisma.user.findMany({ + where, + select: SELECT_OWNER, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.user.count({ where }), + ]); + return { owners: rows.map((u) => mapOwner(u)), total }; + }, + ["owners"], + { revalidate: 3 * 60, tags: ["users"] }, +); + +export const fetchCustomers = unstable_cache( + async (page: number, pageSize: number): Promise => { + const where = { role: Role.USER }; + const [rows, total] = await Promise.all([ + prisma.user.findMany({ + where, + select: SELECT_CUSTOMER, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.user.count({ where }), + ]); + return { customers: rows.map((u) => mapCustomer(u)), total }; + }, + ["customers"], + { revalidate: 3 * 60, tags: ["users"] }, +); diff --git a/apps/admin/app/actions/utils.ts b/apps/admin/app/actions/utils.ts new file mode 100644 index 000000000..62615ab9a --- /dev/null +++ b/apps/admin/app/actions/utils.ts @@ -0,0 +1,158 @@ +import { Prisma } from "@bookmyvenue/database"; +import { Venue } from "./venue"; +import { Owner, Customer } from "./user"; +import { Booking } from "./booking"; + +export const SELECT_VENUE = { + id: true, + name: true, + description: true, + location: true, + district: true, + category: true, + capacity: true, + images: true, + amenities: true, + isActive: true, + verificationStatus: true, + createdAt: true, + owner: { select: { email: true } }, + sessions: { + select: { + id: true, + label: true, + startTime: true, + endTime: true, + price: true, + isActive: true, + }, + orderBy: { price: "asc" as const }, + }, + reviews: { select: { rating: true } }, + _count: { select: { bookings: true } }, +} as const; + +export type MappedVenues = Prisma.VenueGetPayload<{ + select: typeof SELECT_VENUE; +}>; + +export const mapVenue = (v: MappedVenues): Venue => { + return { + id: String(v.id), + name: v.name, + description: v.description, + owner: v.owner.email, + location: v.location, + district: v.district, + category: v.category, + capacity: v.capacity, + isActive: v.isActive, + status: v.verificationStatus, + submitted: v.createdAt.toISOString().split("T")[0]!, + rating: v.reviews.length ? v.reviews.reduce((sum, r) => sum + r.rating, 0) / v.reviews.length : 0, + bookings: v._count.bookings, + images: v.images, + amenities: v.amenities, + sessions: v.sessions, + }; +}; + +export const SELECT_OWNER = { + id: true, + email: true, + name: true, + role: true, + createdAt: true, + _count: { select: { venues: true } }, +} as const; + +export type MappedOwner = Prisma.UserGetPayload<{ + select: typeof SELECT_OWNER; +}>; + +export const mapOwner = (u: MappedOwner): Owner => { + return { + id: u.id, + name: u.name, + email: u.email, + role: u.role, + joined: u.createdAt.toISOString().split("T")[0]!, + venues: u._count.venues, + }; +}; + +export const SELECT_CUSTOMER = { + id: true, + email: true, + name: true, + role: true, + createdAt: true, + _count: { select: { bookings: true } }, +} as const; + +export type MappedCustomer = Prisma.UserGetPayload<{ + select: typeof SELECT_CUSTOMER; +}>; + +export const mapCustomer = (u: MappedCustomer): Customer => { + return { + id: u.id, + name: u.name, + email: u.email, + role: u.role, + joined: u.createdAt.toISOString().split("T")[0]!, + bookings: u._count.bookings, + }; +}; + +export const SELECT_BOOKING = { + id: true, + purpose: true, + status: true, + user: { + select: { + name: true, + email: true, + }, + }, + venue: { + select: { + name: true, + category: true, + owner: { + select: { + name: true, + }, + }, + }, + }, + bookingSessions: { + include: { + session: true, + }, + }, +}; + +export type MappedBookings = Prisma.BookingGetPayload<{ + select: typeof SELECT_BOOKING; +}>; + +export type BookingSession = MappedBookings["bookingSessions"][number]; + + +export const mapBooking = (b: MappedBookings): Booking => { + return { + id: b.id, + client: b.user.name ?? "", + venue: b.venue.name, + owner: b.venue.owner.name ?? "", + date: b.bookingSessions[0]?.eventDate.toISOString().split("T")[0] ?? "", + category: b.venue.category, + purpose: b.purpose ?? "", + amount: b.bookingSessions.reduce( + (sum: number, session: BookingSession) => sum + session.pricePaid, + 0, + ), + status: b.status, + }; +}; diff --git a/apps/admin/app/actions/venue.ts b/apps/admin/app/actions/venue.ts new file mode 100644 index 000000000..9d068a368 --- /dev/null +++ b/apps/admin/app/actions/venue.ts @@ -0,0 +1,147 @@ +"use server"; + +import { unstable_cache, revalidateTag } from "next/cache"; +import { prisma, VerificationStatus, VenueCategory } from "@bookmyvenue/database"; +import { mapVenue, SELECT_VENUE } from "./utils"; +import { producer } from "../../lib/kafka"; + +export type VenueSession = { + id: number; + label: string; + startTime: string; + endTime: string; + price: number; + isActive: boolean; +}; + +export type Venue = { + id: string; + name: string; + description: string; + owner: string; + location: string; + district: string; + category: VenueCategory; + capacity: number; + isActive: boolean; + status: VerificationStatus; + submitted: string; + rating: number; + bookings: number; + images: string[]; + amenities: string[]; + sessions: VenueSession[]; +}; + +export type VenuePageResult = { venues: Venue[]; total: number }; + +export const fetchAllVenues = unstable_cache( + async (page: number, pageSize: number): Promise => { + const [rows, total] = await Promise.all([ + prisma.venue.findMany({ + select: SELECT_VENUE, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.venue.count(), + ]); + return { venues: rows.map((v) => mapVenue(v)), total }; + }, + ["all-venues"], + { revalidate: 3 * 60, tags: ["venues"] }, +); + +export const fetchPendingVenues = unstable_cache( + async (page: number, pageSize: number): Promise => { + const where = { verificationStatus: VerificationStatus.PENDING, isActive: true }; + const [rows, total] = await Promise.all([ + prisma.venue.findMany({ + where, + select: SELECT_VENUE, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.venue.count({ where }), + ]); + return { venues: rows.map((v) => mapVenue(v)), total }; + }, + ["pending-venues"], + { revalidate: 3 * 60, tags: ["venues"] }, +); + +export const fetchVenueById = unstable_cache( + async (id: number): Promise => { + const row = await prisma.venue.findUnique({ + where: { id }, + select: SELECT_VENUE, + }); + return row ? mapVenue(row) : null; + }, + ["venue-by-id"], + { revalidate: 3 * 60, tags: ["venues"] }, +); + +export async function approveVenue(id: string): Promise { + const venue = await prisma.venue.update({ + where: { id: Number(id) }, + data: { verificationStatus: VerificationStatus.APPROVED, verificationReason: null }, + select: { + id: true, + name: true, + verificationStatus: true, + owner: { select: { email: true, name: true } }, + }, + }); + + await producer.send("venue-verification-updated", { + venueId: venue.id, + venueName: venue.name, + status: venue.verificationStatus, + reason: null, + owner: venue.owner, + }); + + revalidateTag("venues", "default"); +} + +export async function rejectVenue(id: string, reason: string): Promise { + const venue = await prisma.venue.update({ + where: { id: Number(id) }, + data: { verificationStatus: VerificationStatus.REJECTED, verificationReason: reason }, + select: { + id: true, + name: true, + verificationStatus: true, + owner: { select: { email: true, name: true } }, + }, + }); + await producer.send("venue-verification-updated", { + venueId: venue.id, + venueName: venue.name, + status: venue.verificationStatus, + reason, + owner: venue.owner, + }); + revalidateTag("venues", "default"); +} + +export const fetchApproved = unstable_cache( + async (page: number, pageSize: number): Promise => { + const where = { verificationStatus: VerificationStatus.APPROVED, isActive: true }; + const [rows, total] = await Promise.all([ + prisma.venue.findMany({ + where, + select: SELECT_VENUE, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.venue.count({ where }), + ]); + return { venues: rows.map((v) => mapVenue(v)), total }; + }, + ["approved-venues"], + { revalidate: 3 * 60, tags: ["venues"] }, +); diff --git a/apps/admin/app/globals.css b/apps/admin/app/globals.css new file mode 100644 index 000000000..cc8100402 --- /dev/null +++ b/apps/admin/app/globals.css @@ -0,0 +1,130 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-sans); + --font-mono: var(--font-geist-mono); + --font-heading: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-mono; + } +} \ No newline at end of file diff --git a/apps/admin/app/icon.svg b/apps/admin/app/icon.svg new file mode 100644 index 000000000..c2cbae7e3 --- /dev/null +++ b/apps/admin/app/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/admin/app/layout.tsx b/apps/admin/app/layout.tsx new file mode 100644 index 000000000..1bbb40572 --- /dev/null +++ b/apps/admin/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import { ClerkProvider } from "@clerk/nextjs"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Bookmyvenue Admin", + description: "Bookmyvenue Admin", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/apps/admin/app/page.tsx b/apps/admin/app/page.tsx new file mode 100644 index 000000000..534ce3fdb --- /dev/null +++ b/apps/admin/app/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useState } from "react"; +import { Sidebar } from "../components/Sidebar"; +import { TopBar } from "../components/TopBar"; +import { OverviewPage } from "../components/tabs/Overview"; + + +export default function App() { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
+ {/* Sidebar desktop */} +
+ +
+ + {/* Mobile sidebar overlay */} + {sidebarOpen && ( +
+
setSidebarOpen(false)} /> +
+ +
+
+ )} + +
+ + +
+ +
+
+
+ ); +} diff --git a/apps/admin/app/sign-in/[[...sign-in]]/page.tsx b/apps/admin/app/sign-in/[[...sign-in]]/page.tsx new file mode 100644 index 000000000..f7e420f63 --- /dev/null +++ b/apps/admin/app/sign-in/[[...sign-in]]/page.tsx @@ -0,0 +1,9 @@ +import { SignIn } from "@clerk/nextjs"; + +export default function Page() { + return ( +
+ +
+ ); +} diff --git a/apps/admin/components.json b/apps/admin/components.json new file mode 100644 index 000000000..02e61e070 --- /dev/null +++ b/apps/admin/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/admin/components/ChartTooltip.tsx b/apps/admin/components/ChartTooltip.tsx new file mode 100644 index 000000000..c4683ff07 --- /dev/null +++ b/apps/admin/components/ChartTooltip.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { fromSmallUnit } from "../lib/utils"; + + +export function ChartTooltip({ active, payload, label }: any) { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+ {payload.map((p: any) => ( +

+ {p.name}: {p.name === "revenue" ? fromSmallUnit(p.value) : p.value} +

+ ))} +
+ ); +} diff --git a/apps/admin/components/Sidebar.tsx b/apps/admin/components/Sidebar.tsx new file mode 100644 index 000000000..70a0acfa2 --- /dev/null +++ b/apps/admin/components/Sidebar.tsx @@ -0,0 +1,72 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { MapPin, LogOut, Home, Building2, Users, CalendarCheck } from "lucide-react"; +import { useClerk } from "@clerk/nextjs"; + +interface SidebarProps { + setSidebarOpen: (open: boolean) => void; + mobile?: boolean; +} + +type Tab = "overview" | "venues" | "users" | "bookings"; + + +const NAV = [ + { key: "overview" as Tab, label: "Overview", icon: Home, href: "/" }, + { key: "venues" as Tab, label: "Venues", icon: Building2, href: "/venues" }, + { key: "users" as Tab, label: "Users", icon: Users, href: "/users" }, + { key: "bookings" as Tab, label: "Bookings", icon: CalendarCheck, href: "/bookings" }, +]; + +export function Sidebar({ setSidebarOpen, mobile = false }: SidebarProps) { + const pathname = usePathname(); + const { signOut } = useClerk(); + + return ( +
+
+
+
+ +
+
+

BookMyVenues

+

Admin Console

+
+
+
+ + + +
+ +
+
+ ); +} diff --git a/apps/admin/components/TopBar.tsx b/apps/admin/components/TopBar.tsx new file mode 100644 index 000000000..9c100c4cf --- /dev/null +++ b/apps/admin/components/TopBar.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { Menu } from "lucide-react"; +import { Show, SignInButton } from "@clerk/nextjs"; + +const TITLES: Record = { + "/": "Dashboard", + "/venues": "Venues", + "/users": "Users", + "/bookings": "Bookings", +}; + +interface TopBarProps { + setSidebarOpen: (open: boolean) => void; +} + +export function TopBar({ setSidebarOpen }: TopBarProps) { + const pathname = usePathname(); + const title = TITLES[pathname] ?? "Dashboard"; + + return ( +
+
+ +
+

{title}

+

+ {new Date().toLocaleDateString("en-IN", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + })} +

+
+
+ +
+ + + + {/* + + */} +
+
+ ); +} diff --git a/apps/admin/components/VenueDetailModal.tsx b/apps/admin/components/VenueDetailModal.tsx new file mode 100644 index 000000000..f6e349b17 --- /dev/null +++ b/apps/admin/components/VenueDetailModal.tsx @@ -0,0 +1,197 @@ +"use client"; + +import Image from "next/image"; +import { useState } from "react"; +import { MapPin, Users, Star, CalendarDays, BookOpen, ChevronLeft, ChevronRight } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; +import type { Venue } from "../app/actions/venue"; +import { VENUE_STATUS_LABEL, VENUE_STATUS_STYLE } from "../lib/utils"; + +interface Props { + venue: Venue | null; + onClose: () => void; +} + +export function VenueDetailModal({ venue, onClose }: Props) { + const [imgIndex, setImgIndex] = useState(0); + + if (!venue) return null; + + const images = venue.images.length > 0 ? venue.images : []; + const prev = () => setImgIndex((i) => (i - 1 + images.length) % images.length); + const next = () => setImgIndex((i) => (i + 1) % images.length); + + return ( + { if (!open) onClose(); }}> + + {/* Image gallery */} + {images.length > 0 ? ( +
+ {venue.name} + {images.length > 1 && ( + <> + + + + )} +
+ ) : ( +
+ No images +
+ )} + +
+ +
+ {venue.name} + + {VENUE_STATUS_LABEL[venue.status]} + +
+
+ + {/* Stats row */} +
+ + + {venue.rating > 0 ? venue.rating.toFixed(1) : "No ratings"} + + + + {venue.bookings} bookings + + + + Submitted {venue.submitted} + +
+ + {/* Info grid */} +
+ + + + + {venue.location} + + } + /> + + + + {venue.capacity} guests + + } + /> +
+ + {/* Description */} + {venue.description && ( +
+

+ Description +

+

{venue.description}

+
+ )} + + {/* Amenities */} + {venue.amenities.length > 0 && ( +
+

+ Amenities +

+
+ {venue.amenities.map((a) => ( + + {a} + + ))} +
+
+ )} + + {/* Sessions */} + {venue.sessions.length > 0 && ( +
+

+ Sessions +

+
+ + + + + + + + + + + {venue.sessions.map((s) => ( + + + + + + + ))} + +
LabelTimePriceActive
{s.label} + {s.startTime} – {s.endTime} + + {"₹" + s.price.toLocaleString()} + + + {s.isActive ? "Yes" : "No"} + +
+
+
+ )} +
+
+
+ ); +} + +function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/apps/admin/components/tabs/Overview.tsx b/apps/admin/components/tabs/Overview.tsx new file mode 100644 index 000000000..567241245 --- /dev/null +++ b/apps/admin/components/tabs/Overview.tsx @@ -0,0 +1,336 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + IndianRupee, + CalendarCheck, + Building2, + Users, + TrendingUp, +} from "lucide-react"; +import { + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + PieChart, + Pie, +} from "recharts"; +import Link from "next/link"; +import { ChartTooltip } from "../ChartTooltip"; +import { + fetchAdminDashboard, + type AdminDashboardResponse, +} from "../../app/actions/dashboard"; +import { BOOKING_STATUS_STYLE, fmtAmount } from "../../lib/utils"; + +const CATEGORY_COLORS = ["#7B1F2E", "#C8790A", "#2563EB", "#059669", "#7C3AED", "#DB2777"]; + +export function OverviewPage() { + const [dashboard, setDashboard] = useState(null); + + useEffect(() => { + void fetchAdminDashboard().then(setDashboard); + }, []); + + if (!dashboard) { + return null; + } + + const { stats, revenueData, categoryData, districtData, recentBookings } = dashboard; + + const categoryChartData = categoryData.map((category, index) => ({ + ...category, + fill: CATEGORY_COLORS[index % CATEGORY_COLORS.length], + })); + + const STATS = [ + { + label: "Total Revenue", + value: fmtAmount(stats.totalRevenue), + change: "+18%", + icon: IndianRupee, + color: "text-emerald-600 bg-emerald-50", + }, + { + label: "Total Bookings", + value: stats.totalBookings, + change: "+12%", + icon: CalendarCheck, + color: "text-blue-600 bg-blue-50", + }, + { + label: "Approved Venues", + value: stats.approvedVenues, + change: "+5%", + icon: Building2, + color: "text-primary bg-primary/10", + }, + { + label: "Registered Users", + value: stats.totalUsers, + change: "+23%", + icon: Users, + color: "text-purple-600 bg-purple-50", + }, + ]; + + return ( +
+
+ {STATS.map(({ label, value, change, icon: Icon, color }) => ( +
+
+
+ +
+ + + + {change} + +
+ +

{value}

+

{label}

+
+ ))} +
+ +
+
+
+

Revenue & Bookings

+ Jan – Jul +
+ + + + + + + + + + + + + + + + + + + + + value >= 1000 ? `${value / 1000}k` : String(value) + } + /> + + } /> + + + + + + + +
+ + + Revenue + + + + + Bookings + +
+
+ +
+

By Category

+ + + + + + (value !== undefined ? `${value}%` : "")} + /> + + + +
+ {categoryData.map(({ name, value }, index) => ( +
+ + + {name} + + + {value}% +
+ ))} +
+
+
+ +
+
+

Venues by District

+ + + + + + + + + + } /> + + + + + + +
+ +
+
+

Recent Activity

+ + + View all + +
+ +
+ {recentBookings.map((booking) => ( +
+
+ {booking.user.name?.slice(0, 1).toUpperCase() ?? "U"} +
+ +
+

+ {booking.user.name ?? "Unknown User"} +

+ +

+ {booking.venue.name} + {booking.eventDate && + ` · ${new Date(booking.eventDate).toLocaleDateString()}`} +

+
+ +
+

+ {fmtAmount(booking.totalAmount)} +

+ + + {booking.status} + +
+
+ ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/admin/components/tabs/Users.tsx b/apps/admin/components/tabs/Users.tsx new file mode 100644 index 000000000..df4452b25 --- /dev/null +++ b/apps/admin/components/tabs/Users.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Search, Users, Eye } from "lucide-react"; +import { fetchOwners, fetchCustomers } from "../../app/actions/user"; +import type { Owner, Customer } from "../../app/actions/user"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "../ui/pagination"; + +const PAGE_SIZE = 10; + +type UserRole = "Owner" | "Customer"; + + +type Row = Owner | Customer; + +export function UsersPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [userSearch, setUserSearch] = useState(""); + const [userRoleFilter, setUserRoleFilter] = useState("Owner"); + const [page, setPage] = useState(1); + + const fetchPage = async (role: UserRole, p: number) => { + if (role === "Owner") { + const result = await fetchOwners(p, PAGE_SIZE); + setRows(result.owners); + setTotal(result.total); + } else { + const result = await fetchCustomers(p, PAGE_SIZE); + setRows(result.customers); + setTotal(result.total); + } + }; + + useEffect(() => { + fetchOwners(1, PAGE_SIZE).then((result) => { + setRows(result.owners); + setTotal(result.total); + }); + }, []); + + const handleRoleChange = async (role: UserRole) => { + setUserRoleFilter(role); + setPage(1); + await fetchPage(role, 1); + }; + + const handlePageChange = async (p: number) => { + setPage(p); + await fetchPage(userRoleFilter, p); + }; + + const isOwner = userRoleFilter === "Owner"; + + const filteredRows = rows.filter((u) => { + const q = userSearch.toLowerCase(); + return ( + (u.name?.toLowerCase().includes(q) ?? false) || + u.email.toLowerCase().includes(q) + ); + }); + + const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + return ( +
+ {/* Filters */} +
+
+ + setUserSearch(e.target.value)} + /> +
+
+ {(["Owner", "Customer"] as const).map(r => ( + + ))} +
+
+ + + + + {["Name", "Email", "Role", "Joined", isOwner ? "Venues" : "Bookings", "Actions"].map(h => ( + {h} + ))} + + + + {filteredRows.map(u => ( + + +
+
+ {(u.name ?? u.email).slice(0, 1).toUpperCase()} +
+

{u.name ?? "—"}

+
+
+ {u.email} + + + {userRoleFilter} + + + {u.joined} + + {"venues" in u ? `${u.venues} venues` : `${u.bookings} bookings`} + + + + +
+ ))} +
+
+ {filteredRows.length === 0 && ( +
+ +

No users match your filter.

+
+ )} + +
+ + {total === 0 + ? "Showing 0 users" + : `Showing ${(page - 1) * PAGE_SIZE + 1}–${Math.min(page * PAGE_SIZE, total)} of ${total} users`} + + + + + { e.preventDefault(); handlePageChange(Math.max(1, page - 1)); }} + /> + + {Array.from({ length: pageCount }, (_, i) => i + 1).map(p => ( + + { e.preventDefault(); handlePageChange(p); }} + > + {p} + + + ))} + + = pageCount} + className={page >= pageCount ? "pointer-events-none opacity-50" : undefined} + onClick={e => { e.preventDefault(); handlePageChange(Math.min(pageCount, page + 1)); }} + /> + + + +
+
+ ); +} diff --git a/apps/admin/components/tabs/Venues.tsx b/apps/admin/components/tabs/Venues.tsx new file mode 100644 index 000000000..bf1a6f213 --- /dev/null +++ b/apps/admin/components/tabs/Venues.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Search, CheckCircle2, XCircle, Eye, Building2 } from "lucide-react"; +import { VerificationStatus } from "@bookmyvenue/database/enums"; +import { + fetchAllVenues, + fetchPendingVenues, + fetchApproved, + approveVenue as approveVenueAction, + rejectVenue as rejectVenueAction, +} from "../../app/actions/venue"; +import type { Venue } from "../../app/actions/venue"; +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "../ui/table"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationPrevious, + PaginationNext, + PaginationEllipsis, +} from "../ui/pagination"; +import Image from "next/image"; +import { VenueDetailModal } from "../VenueDetailModal"; +import { VENUE_STATUS_LABEL, VENUE_STATUS_STYLE } from "../../lib/utils"; + +const PAGE_SIZE = 10; + + + +export function VenuesPage() { + const [venues, setVenues] = useState([]); + const [total, setTotal] = useState(0); + const [pendingCount, setPendingCount] = useState(0); + const [venueSearch, setVenueSearch] = useState(""); + const [venueFilter, setVenueFilter] = useState("All"); + const [page, setPage] = useState(1); + const [selectedVenue, setSelectedVenue] = useState(null); + const [loadingId, setLoadingId] = useState(null); + const [rejectTarget, setRejectTarget] = useState(null); + const [rejectReason, setRejectReason] = useState(""); + + const fetchPage = async (filter: VerificationStatus | "All", p: number) => { + let result; + if (filter === "All") result = await fetchAllVenues(p, PAGE_SIZE); + else if (filter === VerificationStatus.PENDING) result = await fetchPendingVenues(p, PAGE_SIZE); + else result = await fetchApproved(p, PAGE_SIZE); + setVenues(result.venues); + setTotal(result.total); + }; + + useEffect(() => { + fetchAllVenues(1, PAGE_SIZE).then((result) => { + setVenues(result.venues); + setTotal(result.total); + }); + }, []); + + const handleFilterChange = async (filter: VerificationStatus | "All") => { + setVenueFilter(filter); + setPage(1); + await fetchPage(filter, 1); + }; + + const handlePageChange = async (p: number) => { + setPage(p); + await fetchPage(venueFilter, p); + }; + + const filteredVenues = venues.filter((v) => { + const mq = + v.name.toLowerCase().includes(venueSearch.toLowerCase()) || + v.owner.toLowerCase().includes(venueSearch.toLowerCase()) || + v.location.toLowerCase().includes(venueSearch.toLowerCase()); + return mq; + }); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const pagedVenues = filteredVenues; + + const handleApprove = async (id: string) => { + setLoadingId(id); + try { + await approveVenueAction(id); + setVenues((v) => v.map((x) => (x.id === id ? { ...x, status: VerificationStatus.APPROVED } : x))); + setPendingCount((c) => Math.max(0, c - 1)); + } finally { + setLoadingId(null); + } + }; + + const handleReject = async () => { + if (!rejectTarget || !rejectReason.trim()) return; + setLoadingId(rejectTarget); + try { + await rejectVenueAction(rejectTarget, rejectReason.trim()); + setVenues((v) => + v.map((x) => (x.id === rejectTarget ? { ...x, status: VerificationStatus.REJECTED } : x)), + ); + setPendingCount((c) => Math.max(0, c - 1)); + setRejectTarget(null); + setRejectReason(""); + } finally { + setLoadingId(null); + } + }; + + const getPageNumbers = () => { + if (totalPages <= 5) return Array.from({ length: totalPages }, (_, i) => i + 1); + if (page <= 3) return [1, 2, 3, 4, null, totalPages]; + if (page >= totalPages - 2) + return [1, null, totalPages - 3, totalPages - 2, totalPages - 1, totalPages]; + return [1, null, page - 1, page, page + 1, null, totalPages]; + }; + + return ( + <> + setSelectedVenue(null)} /> + + {rejectTarget && ( +
+
+

Reject Venue

+

+ Provide a reason so the owner knows what to fix. +

+