diff --git a/frontend/_admin_analytics_page.txt b/frontend/_admin_analytics_page.txt new file mode 100644 index 00000000..339dfa40 --- /dev/null +++ b/frontend/_admin_analytics_page.txt @@ -0,0 +1,260 @@ +"use client"; + +import { useState } from "react"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import { useGetAdminAnalytics } from "@/lib/react-query/hooks/admin/analytics/useGetAdminAnalytics"; +import { + TrendingUp, + BookOpen, + Users, + FileText, + MonitorCheck, + RefreshCw, + Trophy, +} from "lucide-react"; + +function formatNaira(kobo: number): string { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 0, + notation: kobo >= 100_000_000 ? "compact" : "standard", + }).format(kobo / 100); +} + +function StatCard({ + label, + value, + sub, + icon: Icon, + color = "bg-gray-50 text-gray-500", +}: { + label: string; + value: string; + sub?: string; + icon: React.ElementType; + color?: string; +}) { + return ( +
+ + + +

{value}

+

{label}

+ {sub &&

{sub}

} +
+ ); +} + +export default function AdminAnalyticsPage() { + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [appliedFrom, setAppliedFrom] = useState(); + const [appliedTo, setAppliedTo] = useState(); + + const { data, isLoading, refetch } = useGetAdminAnalytics( + appliedFrom, + appliedTo + ); + + const analytics = data?.data; + + const applyFilter = () => { + setAppliedFrom(from || undefined); + setAppliedTo(to || undefined); + }; + + const clearFilter = () => { + setFrom(""); + setTo(""); + setAppliedFrom(undefined); + setAppliedTo(undefined); + }; + + // Derive booking totals from byStatus map + const bookingsByStatus = analytics?.bookings.byStatus ?? {}; + const totalBookings = Object.values(bookingsByStatus).reduce( + (sum, n) => sum + n, + 0 + ); + + return ( + +
+
+

Analytics

+

+ Platform-wide business intelligence +

+
+ + {/* Date filter */} +
+ setFrom(e.target.value)} + className="px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-200" + /> + to + setTo(e.target.value)} + className="px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-200" + /> + + {(appliedFrom || appliedTo) && ( + + )} + +
+
+ + {isLoading ? ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ ))} +
+ ) : !analytics ? ( +

+ No analytics data available. +

+ ) : ( +
+ {/* Revenue */} +
+

+ Revenue +

+
+ + + + +
+
+ + {/* Bookings */} +
+

+ Bookings +

+
+ + + + +
+
+ + {/* Occupancy */} +
+

+ Live occupancy +

+
+ + + +
+ + {/* Occupancy bar */} +
+
+ Seat utilisation + + {analytics.occupancy.occupiedSeats} /{" "} + {analytics.occupancy.totalSeats} + +
+
+
diff --git a/frontend/_admin_payments_page.txt b/frontend/_admin_payments_page.txt new file mode 100644 index 00000000..c5594fcf --- /dev/null +++ b/frontend/_admin_payments_page.txt @@ -0,0 +1,260 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import { useAuthState } from "@/lib/store/authStore"; +import { useGetAdminPayments } from "@/lib/react-query/hooks/admin/payments/useGetAdminPayments"; +import { useRefundPayment } from "@/lib/react-query/hooks/admin/payments/useRefundPayment"; +import { useGetAdminAnalytics } from "@/lib/react-query/hooks/admin/analytics/useGetAdminAnalytics"; +import { PaymentStatus, PaymentProvider } from "@/lib/types/payment"; +import { + CreditCard, + TrendingUp, + Clock, + XCircle, + ChevronLeft, + ChevronRight, + X, +} from "lucide-react"; + +const STATUS_TABS: { value: PaymentStatus | ""; label: string }[] = [ + { value: "", label: "All" }, + { value: "pending", label: "Pending" }, + { value: "success", label: "Success" }, + { value: "failed", label: "Failed" }, + { value: "refunded", label: "Refunded" }, +]; + +const PROVIDER_OPTIONS: { value: PaymentProvider | ""; label: string }[] = [ + { value: "", label: "All Providers" }, + { value: "paystack", label: "Paystack" }, + { value: "soroban", label: "Soroban" }, +]; + +const STATUS_COLORS: Record = { + pending: "bg-amber-50 text-amber-700", + success: "bg-emerald-50 text-emerald-600", + failed: "bg-red-50 text-red-600", + refunded: "bg-blue-50 text-blue-700", +}; + +function formatNaira(kobo: number): string { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 0, + notation: kobo >= 100_000_000 ? "compact" : "standard", + }).format(kobo / 100); +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-NG", { dateStyle: "medium" }); +} + +function StatCard({ + label, + value, + icon: Icon, + color, +}: { + label: string; + value: string; + icon: React.ElementType; + color: string; +}) { + return ( +
+ + + +

{value}

+

{label}

+
+ ); +} + +export default function AdminPaymentsPage() { + const router = useRouter(); + const { user } = useAuthState(); + + const [page, setPage] = useState(1); + const [statusFilter, setStatusFilter] = useState(""); + const [providerFilter, setProviderFilter] = useState(""); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [appliedFrom, setAppliedFrom] = useState(""); + const [appliedTo, setAppliedTo] = useState(""); + const [refundTarget, setRefundTarget] = useState(null); + + const isAdmin = user?.role === "admin"; + + useEffect(() => { + if (user && !isAdmin) { + router.replace("/dashboard"); + } + }, [user, isAdmin, router]); + + const { data, isLoading } = useGetAdminPayments({ + page, + limit: 20, + status: statusFilter || undefined, + provider: providerFilter || undefined, + from: appliedFrom || undefined, + to: appliedTo || undefined, + }); + + const { data: analyticsData } = useGetAdminAnalytics(); + + const { data: pendingData } = useGetAdminPayments({ status: "pending", limit: 1 }); + const { data: failedData } = useGetAdminPayments({ status: "failed", limit: 1 }); + + const refund = useRefundPayment(); + + if (!user || !isAdmin) return null; + + const payments = data?.data ?? []; + const meta = data?.meta; + const revenue = analyticsData?.data?.revenue; + + const handleApplyFilter = () => { + setAppliedFrom(from); + setAppliedTo(to); + setPage(1); + }; + + const handleClearFilter = () => { + setFrom(""); + setTo(""); + setAppliedFrom(""); + setAppliedTo(""); + setPage(1); + }; + + const handleRefund = async (id: string) => { + await refund.mutateAsync(id); + setRefundTarget(null); + }; + + return ( + +
+

Payments

+

+ {meta?.total ?? 0} total payments +

+
+ + {/* Stat cards */} +
+ + + + +
+ + {/* Filters */} +
+ {/* Provider dropdown */} +
+ + +
+ + {/* Date range */} +
+ + setFrom(e.target.value)} + className="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-1 focus:ring-gray-300" + /> +
+
+ + setTo(e.target.value)} + className="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-1 focus:ring-gray-300" + /> +
+ + {(appliedFrom || appliedTo) && ( + + )} +
+ + {/* Status filter tabs */} +
+ {STATUS_TABS.map(({ value, label }) => ( + + ))} +
+ + {/* Confirmation modal */} + {refundTarget && ( +
+
+

diff --git a/frontend/_analytics_hook.txt b/frontend/_analytics_hook.txt new file mode 100644 index 00000000..e0a7e88d --- /dev/null +++ b/frontend/_analytics_hook.txt @@ -0,0 +1,69 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { queryKeys } from "@/lib/react-query/keys/queryKeys"; + +interface RevenueTrendPoint { + month: string; + totalKobo: number; + totalNaira: number; +} + +interface BookingTrendPoint { + month: string; + count: number; +} + +export interface AdminAnalytics { + revenue: { + total: number; + thisMonth: number; + lastMonth: number; + trend: RevenueTrendPoint[]; + }; + bookings: { + byStatus: Record; + trend: BookingTrendPoint[]; + }; + topWorkspaces: { + id: string; + name: string; + bookings: string; // raw string from SQL COUNT + revenueKobo: string; // raw string from SQL SUM + }[]; + topMembers: { + id: string; + fullName: string; + totalKobo: string; // raw string from SQL SUM + }[]; + invoices: { + total: number; + totalAmountKobo: number; + totalAmountNaira: number; + paid: number; + pending: number; + }; + occupancy: { + totalSeats: number; + occupiedSeats: number; + availableSeats: number; + occupancyPercent: number; + activeWorkspaces: number; + }; +} + +export const useGetAdminAnalytics = (from?: string, to?: string) => { + const params = new URLSearchParams(); + if (from) params.set("from", from); + if (to) params.set("to", to); + const qs = params.toString(); + + return useQuery({ + queryKey: queryKeys.admin.analytics({ from, to }), + queryFn: () => + apiClient.get<{ success: boolean; data: AdminAnalytics }>( + `/dashboard/admin/analytics${qs ? `?${qs}` : ""}` + ), + }); +}; diff --git a/frontend/_api_client.txt b/frontend/_api_client.txt new file mode 100644 index 00000000..bd9d4ade --- /dev/null +++ b/frontend/_api_client.txt @@ -0,0 +1,81 @@ +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_URL || "http://localhost:6001/api"; + +class ApiClient { + private baseURL: string; + private token: string | null = null; + + constructor(baseURL: string) { + this.baseURL = baseURL; + } + + setToken(token: string | null) { + this.token = token; + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseURL}${endpoint}`; + + const headers: Record = { + "Content-Type": "application/json", + ...(options.headers as Record), + }; + + if (this.token) { + headers["Authorization"] = `Bearer ${this.token}`; + } + + const config: RequestInit = { + ...options, + headers, + }; + + try { + const response = await fetch(url, config); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || "An API error occurred"); + } + + return await response.json(); + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error("Network error occurred"); + } + } + + async get(endpoint: string): Promise { + return this.request(endpoint, { + method: "GET", + }); + } + + async post(endpoint: string, data?: D): Promise { + return this.request(endpoint, { + method: "POST", + credentials: "include", + body: data ? JSON.stringify(data) : undefined, + }); + } + + async patch(endpoint: string, data?: D): Promise { + return this.request(endpoint, { + method: "PATCH", + body: data ? JSON.stringify(data) : undefined, + }); + } + + async delete(endpoint: string): Promise { + return this.request(endpoint, { + method: "DELETE", + }); + } +} + +export const apiClient = new ApiClient(API_BASE_URL); diff --git a/frontend/_auth_store.txt b/frontend/_auth_store.txt new file mode 100644 index 00000000..8ecde49f --- /dev/null +++ b/frontend/_auth_store.txt @@ -0,0 +1,220 @@ +import { create } from "zustand"; +import { useShallow } from "zustand/shallow"; +import { persist, devtools } from "zustand/middleware"; +import { + AuthResponse, + AuthState, + LoginUser, + RegisterUser, + User, +} from "../types/user"; +import { apiClient } from "../apiClient"; +import { storage } from "../storage"; + +interface AuthActions { + // Authentication actions + login: (data: LoginUser) => Promise; + register: (data: RegisterUser) => Promise; + logout: () => void; + refreshAccessToken: () => Promise; + + //User actions + updateProfile: (userData: Partial) => Promise; + + // State management actions + setUser: (user: User | null) => void; + setToken: (token: string | null) => void; + setLoading: (loading: boolean) => void; + initializeAuth: () => void; + clearAuth: () => void; +} + +type AuthStore = AuthState & AuthActions; + +export const useAuthStore = create()( + devtools( + persist( + (set, get) => ({ + // INITIAL STATE + user: null, + accessToken: null, + isAuthenticated: false, + isLoading: false, + + // AUTHENTICATION ACTIONS + login: async (data: LoginUser) => { + try { + set({ isLoading: true }); + + const response = await apiClient.post< + AuthResponse & { message?: string; requiresTwoFactor?: boolean; tempToken?: string } + >("/auth/login", data); + + const { user, accessToken, message, requiresTwoFactor, tempToken } = response; + + // If 2FA is required, surface a typed error so useLoginUser can redirect + if (requiresTwoFactor && tempToken) { + set({ isLoading: false }); + throw { twoFactorRequired: true, tempToken, email: data.email }; + } + + // If user is not verified, the backend returns a message without accessToken + if (!accessToken && message) { + set({ isLoading: false }); + throw { unverified: true, email: data.email, message }; + } + + // set the token in the api request headers authorization so that it always sends it to the backend when sending a request. + apiClient.setToken(accessToken); + + // update the local ui store state + set({ + user, + accessToken, + isAuthenticated: true, + isLoading: false, + }); + + // persist token to local storage and cookie for middleware usage + storage.setToken(accessToken); + + // persist user to local storage + storage.setUser(user); + } catch (error) { + set({ isLoading: false }); + throw error; + } + }, + register: async (data: RegisterUser) => { + try { + set({ isLoading: true }); + + const { user, accessToken } = await apiClient.post( + "/auth/register", + data + ); + + apiClient.setToken(accessToken); + + set({ + user, + accessToken, + isAuthenticated: true, + isLoading: false, + }); + + storage.setToken(accessToken); + storage.setUser(user); + } catch (error) { + set({ isLoading: false }); + throw error; + } + }, + logout: () => { + set({ + user: null, + accessToken: null, + isAuthenticated: false, + isLoading: false, + }); + apiClient.setToken(null); + storage.clear(); + }, + refreshAccessToken: async () => { + try { + const currentToken = get().accessToken; + + if (!currentToken) { + throw new Error("No token available"); + } + + const response = await apiClient.post( + "/auth/refresh-token" + ); + + const { accessToken, user } = response; + + apiClient.setToken(accessToken); + + set({ + user, + accessToken, + isAuthenticated: true, + }); + + storage.setToken(accessToken); + storage.setUser(user); + } catch (error) { + get().logout(); + throw error; + } + }, + + // USER ACTIONS + updateProfile: async (userData: Partial) => { + try { + set({ isLoading: true }); + + const currentUser = get().user; + if (!currentUser) throw new Error("Not authenticated"); + + const user = await apiClient.patch( + `/users/${currentUser.id}`, + userData + ); + + set({ + user, + isLoading: false, + }); + + storage.setUser(user); + } catch (error) { + set({ isLoading: false }); + throw error; + } + }, + + // STATE MANAGEMENT ACTIONS + setUser: (user: User | null) => { + set({ user, isAuthenticated: !!user }); + }, + setToken: (accessToken: string | null) => { + set({ accessToken }); + apiClient.setToken(accessToken); + }, + setLoading: (loading: boolean) => { + set({ isLoading: loading }); + }, + initializeAuth: () => { + // Get the persisted data from localstorage + const accessToken = storage.getToken(); + const user = storage.getUser(); + + if (accessToken && user) { + apiClient.setToken(accessToken); + + set({ + user, + accessToken, + isAuthenticated: true, + }); + } + }, + clearAuth: () => { + set({ + user: null, + accessToken: null, + isAuthenticated: false, + isLoading: false, + }); + apiClient.setToken(null); + storage.clear(); + }, + }), + { + name: "AuthStore", + partialize: (state) => ({ + user: state.user, + accessToken: state.accessToken, + isAuthenticated: state.isAuthenticated, diff --git a/frontend/_dashboard_sidebar.txt b/frontend/_dashboard_sidebar.txt new file mode 100644 index 00000000..088cc823 --- /dev/null +++ b/frontend/_dashboard_sidebar.txt @@ -0,0 +1,194 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + Building2, + LayoutDashboard, + User, + Settings, + LogOut, + Users, + Mail, + Menu, + X, + BookOpen, + FileText, + BriefcaseBusiness, + LogIn, + Bell, + BarChart3, + CreditCard, +} from "lucide-react"; +import { useState } from "react"; +import { useAuthState, useAuthActions } from "@/lib/store/authStore"; +import NotificationBell from "@/components/notifications/NotificationBell"; + +const navItems = [ + { label: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, + { label: "Workspaces", href: "/workspaces", icon: BriefcaseBusiness }, + { label: "My Bookings", href: "/bookings", icon: BookOpen }, + { label: "Check In / Out", href: "/check-in", icon: LogIn }, + { label: "Notifications", href: "/notifications", icon: Bell }, + { label: "Invoices", href: "/invoices", icon: FileText }, + { label: "Profile", href: "/profile", icon: User }, + { label: "Settings", href: "/settings", icon: Settings }, +]; + +const adminItems = [ + { label: "Analytics", href: "/admin/analytics", icon: BarChart3 }, + { label: "Workspaces", href: "/admin/workspaces", icon: Building2 }, + { label: "All Bookings", href: "/admin/bookings", icon: BookOpen }, + { label: "Payments", href: "/admin/payments", icon: CreditCard }, + { label: "Members", href: "/admin/members", icon: Users }, + { label: "Invoices", href: "/admin/invoices", icon: FileText }, + { label: "Newsletter", href: "/dashboard?tab=newsletter", icon: Mail }, +]; + +export default function DashboardSidebar() { + const pathname = usePathname(); + const { user } = useAuthState(); + const { logout } = useAuthActions(); + const [mobileOpen, setMobileOpen] = useState(false); + const isAdmin = user?.role === "admin"; + + const handleLogout = () => { + logout(); + window.location.href = "/login"; + }; + + const sidebar = ( +
+ {/* Brand */} +
+ + + + + + ManageHub + + +
+ + {/* Nav */} + + + {/* User + Logout */} +
+
+ Notifications + +
+
+
+ {user?.firstname?.[0]} + {user?.lastname?.[0]} +
+
+

+ {user?.firstname} {user?.lastname} +

+

{user?.email}

+
+
+ +
+
+ ); + + return ( + <> + {/* Mobile hamburger */} + + + {/* Mobile overlay */} + {mobileOpen && ( +
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.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 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.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 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; + }; +}