setMobileOpen(false)}
+ />
+ )}
+
+ {/* Mobile drawer */}
+
+
+ {sidebar}
+
+
+ {/* Desktop sidebar */}
+
+ >
+ );
+}
diff --git a/frontend/app/resources/[id]/page.tsx b/frontend/app/resources/[id]/page.tsx
new file mode 100644
index 00000000..b1ad6969
--- /dev/null
+++ b/frontend/app/resources/[id]/page.tsx
@@ -0,0 +1,321 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import Link from 'next/link';
+import { useParams, useRouter } from 'next/navigation';
+import DashboardLayout from '@/components/dashboard/DashboardLayout';
+import { apiClient } from '@/lib/apiClient';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { ArrowLeft, CalendarDays, Clock3, Loader2, Minus, Plus, Sparkles, CreditCard } from 'lucide-react';
+import { toast } from 'sonner';
+import { Resource, ResourceAvailability, ResourceBookingPayload, ResourceBookingResponse } from '@/lib/types/resource';
+
+declare global {
+ interface Window {
+ PaystackPop: {
+ setup: (opts: {
+ key: string;
+ email: string;
+ amount: number;
+ ref: string;
+ onClose: () => void;
+ callback: (response: { reference: string }) => void;
+ }) => { openIframe: () => void };
+ };
+ }
+}
+
+function formatPrice(price?: number) {
+ if (!price || price <= 0) return 'Free';
+ return new Intl.NumberFormat('en-NG', {
+ style: 'currency',
+ currency: 'NGN',
+ maximumFractionDigits: 0,
+ }).format(price);
+}
+
+export default function ResourceDetailPage() {
+ const router = useRouter();
+ const params = useParams<{ id: string }>();
+ const resourceId = params?.id as string;
+
+ const [date, setDate] = useState('');
+ const [startTime, setStartTime] = useState('09:00');
+ const [endTime, setEndTime] = useState('11:00');
+ const [quantity, setQuantity] = useState(1);
+ const [availability, setAvailability] = useState
(null);
+ const [isCheckingAvailability, setIsCheckingAvailability] = useState(false);
+ const [isBooking, setIsBooking] = useState(false);
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['resource', resourceId],
+ queryFn: async () => {
+ const response = await apiClient.get<{ success: boolean; data: Resource }>('/resources/' + resourceId);
+ return response;
+ },
+ enabled: Boolean(resourceId),
+ });
+
+ const resource = data?.data;
+ const price = resource?.pricePerHour ?? resource?.hourlyPrice ?? resource?.price ?? 0;
+ const isPaid = Boolean(price && price > 0);
+ const images = useMemo(() => {
+ const base = resource?.images?.filter(Boolean) ?? [];
+ if (resource?.imageUrl) base.unshift(resource.imageUrl);
+ if (resource?.thumbnail) base.unshift(resource.thumbnail);
+ if (resource?.coverImage) base.unshift(resource.coverImage);
+ return Array.from(new Set(base));
+ }, [resource]);
+
+ useEffect(() => {
+ if (!resourceId || !date) return;
+ const controller = new AbortController();
+ const run = async () => {
+ setIsCheckingAvailability(true);
+ try {
+ const response = await apiClient.get(`/resources/${resourceId}/availability?date=${date}`);
+ setAvailability(response);
+ } catch {
+ setAvailability(null);
+ } finally {
+ setIsCheckingAvailability(false);
+ }
+ };
+
+ void run();
+ return () => controller.abort();
+ }, [resourceId, date]);
+
+ useEffect(() => {
+ if (!date) {
+ setAvailability(null);
+ }
+ }, [date]);
+
+ useEffect(() => {
+ if (document.getElementById('paystack-script')) return;
+ const script = document.createElement('script');
+ script.id = 'paystack-script';
+ script.src = 'https://js.paystack.co/v1/inline.js';
+ script.async = true;
+ document.body.appendChild(script);
+ }, []);
+
+ const bookResource = useMutation({
+ mutationFn: async (payload: ResourceBookingPayload) => {
+ const response = await apiClient.post(`/resources/${resourceId}/book`, {
+ ...payload,
+ quantity,
+ });
+ return response;
+ },
+ });
+
+ const handleBook = async () => {
+ if (!resourceId || !date || !startTime || !endTime) {
+ toast.error('Please complete the availability details first.');
+ return;
+ }
+
+ try {
+ setIsBooking(true);
+ const response = await bookResource.mutateAsync({
+ date,
+ startTime,
+ endTime,
+ quantity,
+ });
+
+ if (isPaid && response?.data?.requiresPayment !== false && response?.data?.paymentRequired !== false) {
+ const bookingId = response.data?.id ?? response.data?.bookingId ?? response.data?.booking?.id;
+ const amount = response.data?.totalAmount ?? response.data?.amount ?? Math.round((price || 0) * quantity * 100);
+
+ if (!bookingId) {
+ toast.success('Booking created. Please complete payment.');
+ router.push('/bookings');
+ return;
+ }
+
+ const reference = `${bookingId}-${Date.now()}`;
+ const initResponse = await apiClient.post<{ success: boolean; data: { authorizationUrl?: string; accessCode?: string; reference?: string } }>('/payments/initialize', { bookingId });
+ const authUrl = initResponse?.data?.authorizationUrl;
+ const accessCode = initResponse?.data?.accessCode;
+ const paystackRef = initResponse?.data?.reference ?? reference;
+
+ if (!window.PaystackPop) {
+ if (authUrl) window.location.href = authUrl;
+ else toast.success('Booking created. Please continue to payment.');
+ return;
+ }
+
+ const handler = window.PaystackPop.setup({
+ key: process.env.NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY || '',
+ email: '',
+ amount,
+ ref: paystackRef,
+ onClose: () => toast.info('Payment window closed.'),
+ callback: () => {
+ toast.success('Payment submitted. Your booking will be confirmed shortly.');
+ router.push('/bookings');
+ },
+ });
+ void accessCode;
+ handler.openIframe();
+ return;
+ }
+
+ toast.success(response?.message || 'Booking confirmed successfully.');
+ router.push('/bookings');
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : 'Unable to complete booking.');
+ } finally {
+ setIsBooking(false);
+ }
+ };
+
+ const isUnavailable = Boolean(date && availability && availability.available === false);
+ const disableBooking = isBooking || isCheckingAvailability || !date || !startTime || !endTime || quantity <= 0 || isUnavailable;
+
+ if (isLoading) {
+ return (
+
+
+
+
Loading resource details...
+
+
+ );
+ }
+
+ if (isError || !resource) {
+ return (
+
+
+
This resource could not be found.
+
+
Back to resources
+
+
+
+ );
+ }
+
+ return (
+
+
+ Back to resources
+
+
+
+
+
+
+ {images.slice(0, 4).map((image, index) => (
+

+ ))}
+
+
+
+
+
+
+
{resource.type || 'Resource'}
+
{resource.name}
+
+
+ {formatPrice(price)} / hour
+
+
+
{resource.description || 'This resource is ready for your next booking.'}
+
+
+
+ {resource.capacity ? `${resource.capacity} seats` : 'Flexible use'}
+
+
+
+ Hourly booking available
+
+
+
+
+
+
+
+ Availability picker
+
+
Select a date and time to check availability before booking.
+
+
+
+
+ setDate(e.target.value)}
+ className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm outline-none"
+ />
+
+
+
+
+
+
+
{quantity}
+
+
+
+
+
+ {isCheckingAvailability ? (
+
+ Checking availability...
+
+ ) : availability ? (
+
+
{availability.available === false ? 'Unavailable' : 'Available'}
+
{availability.message || (availability.available === false ? 'This slot is already booked.' : 'This slot is open for booking.')}
+
+ ) : (
+
Select a date to check the slot status.
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/resources/page.tsx b/frontend/app/resources/page.tsx
new file mode 100644
index 00000000..6d2dc7dc
--- /dev/null
+++ b/frontend/app/resources/page.tsx
@@ -0,0 +1,135 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import Link from 'next/link';
+import DashboardLayout from '@/components/dashboard/DashboardLayout';
+import { Resource } from '@/lib/types/resource';
+import { apiClient } from '@/lib/apiClient';
+import { useQuery } from '@tanstack/react-query';
+import { Search, SlidersHorizontal, Package2, Clock3 } from 'lucide-react';
+
+const RESOURCE_TYPES = ['All', 'Meeting Room', 'Desk', 'Equipment', 'Studio'];
+
+function formatPrice(price?: number) {
+ if (!price || price <= 0) return 'Free';
+ return new Intl.NumberFormat('en-NG', {
+ style: 'currency',
+ currency: 'NGN',
+ maximumFractionDigits: 0,
+ }).format(price);
+}
+
+function ResourceCard({ resource }: { resource: Resource }) {
+ const image = resource.imageUrl || resource.images?.[0] || resource.thumbnail || resource.coverImage || '/placeholder.svg';
+ const availability = resource.isAvailable ?? resource.available ?? resource.status !== 'Booked';
+
+ return (
+
+
+

+
+
+ {resource.type || 'Resource'}
+
+
+ {availability ? 'Available' : 'Booked'}
+
+
+
+
+
+
{resource.name}
+
{resource.description || 'Flexible resource for your team needs.'}
+
+
+
+
+ {formatPrice(resource.pricePerHour ?? resource.hourlyPrice ?? resource.price)} / hr
+
+ {availability ? 'Book now' : 'Unavailable'}
+
+
+
+ );
+}
+
+export default function ResourcesPage() {
+ const [search, setSearch] = useState('');
+ const [type, setType] = useState('All');
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['resources', { search, type }],
+ queryFn: async () => {
+ const params = new URLSearchParams();
+ if (search) params.set('search', search);
+ if (type && type !== 'All') params.set('type', type);
+ const response = await apiClient.get<{ success: boolean; data: Resource[] }>('/resources' + (params.toString() ? `?${params.toString()}` : ''));
+ return response;
+ },
+ });
+
+ const resources = useMemo(() => data?.data ?? [], [data?.data]);
+
+ return (
+
+
+
Resources
+
Find meeting rooms, desks, and equipment for your next session.
+
+
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Search resources..."
+ className="w-full rounded-lg border border-gray-200 pl-9 pr-4 py-2.5 text-sm outline-none ring-0 focus:border-gray-300"
+ />
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ {[1, 2, 3, 4].map((item) => (
+
+ ))}
+
+ ) : isError ? (
+
+
+
Unable to load resources right now.
+
+ ) : resources.length === 0 ? (
+
+
+
No resources found.
+
Try a different search or filter.
+
+ ) : (
+
+ {resources.map((resource) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/components/dashboard/DashboardSidebar.tsx b/frontend/components/dashboard/DashboardSidebar.tsx
index 088cc823..006822ca 100644
--- a/frontend/components/dashboard/DashboardSidebar.tsx
+++ b/frontend/components/dashboard/DashboardSidebar.tsx
@@ -19,6 +19,7 @@ import {
Bell,
BarChart3,
CreditCard,
+ Boxes,
} from "lucide-react";
import { useState } from "react";
import { useAuthState, useAuthActions } from "@/lib/store/authStore";
@@ -27,6 +28,7 @@ import NotificationBell from "@/components/notifications/NotificationBell";
const navItems = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Workspaces", href: "/workspaces", icon: BriefcaseBusiness },
+ { label: "Resources", href: "/resources", icon: Boxes },
{ label: "My Bookings", href: "/bookings", icon: BookOpen },
{ label: "Check In / Out", href: "/check-in", icon: LogIn },
{ label: "Notifications", href: "/notifications", icon: Bell },
diff --git a/frontend/components/ui/Navbar.tsx b/frontend/components/ui/Navbar.tsx
index 0aed027f..0d8b3f93 100644
--- a/frontend/components/ui/Navbar.tsx
+++ b/frontend/components/ui/Navbar.tsx
@@ -8,6 +8,7 @@ type NavItem = { label: string; href: string };
const NAV_ITEMS: NavItem[] = [
{ label: "Features", href: "#features" },
{ label: "How it works", href: "#how-it-works" },
+ { label: "Resources", href: "/resources" },
];
export function Navbar({ items = NAV_ITEMS }: { items?: NavItem[] }) {
diff --git a/frontend/lib/types/resource.ts b/frontend/lib/types/resource.ts
new file mode 100644
index 00000000..a0ef0af6
--- /dev/null
+++ b/frontend/lib/types/resource.ts
@@ -0,0 +1,51 @@
+export interface Resource {
+ id: string;
+ name: string;
+ type?: string;
+ description?: string;
+ pricePerHour?: number;
+ hourlyPrice?: number;
+ price?: number;
+ isAvailable?: boolean;
+ available?: boolean;
+ status?: string;
+ imageUrl?: string;
+ images?: string[];
+ thumbnail?: string;
+ coverImage?: string;
+ location?: string;
+ capacity?: number;
+}
+
+export interface ResourceAvailability {
+ resourceId: string;
+ date: string;
+ available: boolean;
+ message?: string;
+ startTime?: string;
+ endTime?: string;
+ availableSlots?: number;
+}
+
+export interface ResourceBookingPayload {
+ date: string;
+ startTime: string;
+ endTime: string;
+ quantity?: number;
+}
+
+export interface ResourceBookingResponse {
+ success?: boolean;
+ message?: string;
+ data?: {
+ id?: string;
+ bookingId?: string;
+ booking?: {
+ id?: string;
+ };
+ requiresPayment?: boolean;
+ paymentRequired?: boolean;
+ totalAmount?: number;
+ amount?: number;
+ };
+}