Spaces:
Sleeping
Sleeping
| import React, { useMemo, useState } from 'react'; | |
| import { useParams } from 'react-router-dom'; | |
| import { ALL_PROJECTS } from './Projects.jsx'; | |
| import ProjectCard from '../components/ProjectCard.jsx'; | |
| import Reveal from '../components/Reveal.jsx'; | |
| import SectionIntro from '../components/SectionIntro.jsx'; | |
| const CATEGORIES = ['All', 'Residential', 'Commercial', 'Retail', 'Mixed Use']; | |
| const STATUSES = ['All', 'Completed', 'Ongoing', 'Upcoming']; | |
| export default function ProjectsSection() { | |
| const { sectionId } = useParams(); | |
| const sectionTitle = | |
| sectionId?.toLowerCase() === 'sra' | |
| ? 'SRA' | |
| : sectionId?.toLowerCase() === 'construction' | |
| ? 'Project Contracting' | |
| : sectionId?.charAt(0).toUpperCase() + sectionId?.slice(1).toLowerCase(); | |
| const [query, setQuery] = useState(''); | |
| const [filter, setFilter] = useState('All'); | |
| const [statusFilter, setStatusFilter] = useState('All'); | |
| const data = useMemo(() => { | |
| return ALL_PROJECTS.filter((p) => { | |
| const section = p.section?.toLowerCase(); | |
| const current = sectionId?.toLowerCase(); | |
| const matchesSection = | |
| current === 'construction' | |
| ? ['construction', 'development', 'redevelopment'].includes(section) | |
| : section === current; | |
| const matchesCategory = | |
| filter === 'All' || (Array.isArray(p.categories) && p.categories.includes(filter)); | |
| const matchesStatus = statusFilter === 'All' || p.status === statusFilter; | |
| const q = query.trim().toLowerCase(); | |
| const matchesQuery = | |
| !q || | |
| p.title.toLowerCase().includes(q) || | |
| p.location.toLowerCase().includes(q) || | |
| p.description.toLowerCase().includes(q); | |
| return matchesSection && matchesCategory && matchesStatus && matchesQuery; | |
| }); | |
| }, [sectionId, filter, statusFilter, query]); | |
| return ( | |
| <section className="section" aria-labelledby="projects-section-heading"> | |
| <div className="container"> | |
| <header className="mb-8 text-center"> | |
| <h1 id="projects-section-heading" className="h2">{sectionTitle}</h1> | |
| <p className="lead mt-3">Explore our {sectionTitle === 'SRA' ? 'SRA' : sectionTitle?.toLowerCase()} portfolio.</p> | |
| </header> | |
| <SectionIntro sectionId={sectionId} /> | |
| <div className="mb-8 card p-6 bg-gradient-to-br from-slate-50 to-white border-slate-200"> | |
| <div className="grid grid-cols-1 gap-4 md:grid-cols-4"> | |
| {/* Search Input */} | |
| <div className="md:col-span-2"> | |
| <label htmlFor="search" className="mb-2 flex items-center gap-2 text-sm font-semibold text-slate-700"> | |
| <svg className="h-4 w-4 text-brand-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> | |
| </svg> | |
| Search Projects | |
| </label> | |
| <div className="relative"> | |
| <input | |
| id="search" | |
| placeholder="Search by name, location, or description..." | |
| value={query} | |
| onChange={(e) => setQuery(e.target.value)} | |
| className="w-full rounded-lg border border-slate-300 bg-white px-4 py-3 pl-10 shadow-sm transition-all focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20" | |
| /> | |
| <svg className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> | |
| </svg> | |
| </div> | |
| </div> | |
| {/* Category Filter */} | |
| <div className="md:col-span-1"> | |
| <label htmlFor="category-filter" className="mb-2 flex items-center gap-2 text-sm font-semibold text-slate-700"> | |
| <svg className="h-4 w-4 text-brand-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /> | |
| </svg> | |
| Category | |
| </label> | |
| <div className="relative"> | |
| <select | |
| id="category-filter" | |
| value={filter} | |
| onChange={(e) => setFilter(e.target.value)} | |
| className="w-full appearance-none rounded-lg border border-slate-300 bg-white px-4 py-3 pr-10 shadow-sm transition-all focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20" | |
| > | |
| {CATEGORIES.map((c) => ( | |
| <option key={c} value={c}>{c}</option> | |
| ))} | |
| </select> | |
| <svg className="pointer-events-none absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> | |
| </svg> | |
| </div> | |
| </div> | |
| {/* Status Filter */} | |
| <div className="md:col-span-1"> | |
| <label htmlFor="status-filter" className="mb-2 flex items-center gap-2 text-sm font-semibold text-slate-700"> | |
| <svg className="h-4 w-4 text-brand-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> | |
| </svg> | |
| Status | |
| </label> | |
| <div className="relative"> | |
| <select | |
| id="status-filter" | |
| value={statusFilter} | |
| onChange={(e) => setStatusFilter(e.target.value)} | |
| className="w-full appearance-none rounded-lg border border-slate-300 bg-white px-4 py-3 pr-10 shadow-sm transition-all focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20" | |
| > | |
| {STATUSES.map((s) => ( | |
| <option key={s} value={s}>{s}</option> | |
| ))} | |
| </select> | |
| <svg className="pointer-events-none absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> | |
| </svg> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {(() => { | |
| const ORDER = ['Ongoing', 'Upcoming', 'Completed']; | |
| const buckets = (statusFilter === 'All' ? ORDER : [statusFilter]).map((s) => ({ | |
| status: s, | |
| items: data.filter((p) => p.status === s) | |
| })); | |
| const any = buckets.some((b) => b.items.length > 0); | |
| if (!any) return <p className="text-slate-600">No projects found.</p>; | |
| return buckets.map((b) => { | |
| if (b.items.length === 0) return null; | |
| return ( | |
| <section key={b.status} className="mt-10 first:mt-0" aria-labelledby={`status-${b.status}`}> | |
| <div className="mb-6 flex items-center justify-between"> | |
| <h2 id={`status-${b.status}`} className="h3">{b.status}</h2> | |
| <div className="h-px flex-1 ml-6 bg-slate-200"></div> | |
| </div> | |
| {(() => { | |
| const itemsSorted = [...b.items].sort((a, b) => { | |
| const aHas = (Array.isArray(a.gallery) && a.gallery.length > 0) || !!a.image; | |
| const bHas = (Array.isArray(b.gallery) && b.gallery.length > 0) || !!b.image; | |
| // true first | |
| if (aHas === bHas) return 0; | |
| return aHas ? -1 : 1; | |
| }); | |
| return ( | |
| <ul className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> | |
| {itemsSorted.map((p, i) => ( | |
| <li key={p.slug}> | |
| <Reveal delay={i * 60}> | |
| <ProjectCard project={p} /> | |
| </Reveal> | |
| </li> | |
| ))} | |
| </ul> | |
| ); | |
| })()} | |
| </section> | |
| ); | |
| }); | |
| })()} | |
| </div> | |
| </section> | |
| ); | |
| } | |