diff --git a/frontend/app/resources/[id]/page.tsx b/frontend/app/resources/[id]/page.tsx new file mode 100644 index 0000000..b1ad696 --- /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.name} + ))} +
+
+ +
+
+
+

{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" + /> +
+
+
+ + setStartTime(e.target.value)} + className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm outline-none" + /> +
+
+ + setEndTime(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 0000000..6d2dc7d --- /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.name} +
+ + {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 b8722d4..0815292 100644 --- a/frontend/components/dashboard/DashboardSidebar.tsx +++ b/frontend/components/dashboard/DashboardSidebar.tsx @@ -19,6 +19,7 @@ import { Bell, BarChart3, CreditCard, + Boxes, Calendar, MapPin, Wrench, @@ -30,6 +31,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 0aed027..0d8b3f9 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 0000000..a0ef0af --- /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; + }; +}