Files
aramesh-backend/frontend/src/pages/ProjectDetail.tsx
T

297 lines
14 KiB
TypeScript

import React, { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'
import { Reveal } from '../components/Reveal'
import { ButtonLink } from '../components/Button'
import { ProjectCard } from '../components/ProjectCard'
import { ContactSection } from '../components/ContactSection'
import { Lightbox } from '../components/Lightbox'
import { useEffect } from 'react';
import { useSite, useStatusLabel } from '../api';
import { useLang } from '../i18n/lang';
import { useAdmin } from '../admin/AdminContext';
import { LazyProjectsMapSingle } from '../map/LazyMaps';
import { createPlaceholderProject, findMapProjectByName } from '../data/mapProjects';
import { applySeo } from '../seo';
export function ProjectDetail() {
const { slug } = useParams<{ slug: string }>()
const { projects, brand } = useSite()
const { t, href, lang } = useLang()
const { canManageSite } = useAdmin()
const project = projects.find((p) => p.slug === slug)
const statusLabel = useStatusLabel(project?.status ?? '')
const [lightbox, setLightbox] = useState<number | null>(null)
// مختصات پروژه از فهرست سنجاق‌های شیت؛ نبودش → محل جایگزین تا نقشه خالی نماند
const mapLocation = project
? (findMapProjectByName(project.title) ?? createPlaceholderProject(project.title, project.summary))
: null
const mapImages = project && Array.isArray(project.gallery) ? project.gallery.filter(Boolean) : []
// عنوان تب، توضیح متا، OG و JSON-LD از خود پروژه — هم‌ارز نسخهٔ سمت سرور (seo_pages.py)
useEffect(() => {
if (!project) return;
const absolute = (path: string) => new URL(path, window.location.origin).href;
applySeo({
title: `${project.title} | ${brand.name}`,
description: project.summary,
path: href(`/projects/${project.slug}`),
image: project.image,
lang,
// alternate انگلیسی فقط با وجود ترجمهٔ واقعی
hasEn: !!(project.title_en?.trim() || project.summary_en?.trim()),
jsonLd: [
{
'@context': 'https://schema.org',
'@type': 'WebPage',
name: project.title,
description: project.summary,
image: project.image ? absolute(project.image) : undefined,
inLanguage: lang,
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: t('home'), item: absolute(href('/')) },
{ '@type': 'ListItem', position: 2, name: t('projects'), item: absolute(href('/projects')) },
{ '@type': 'ListItem', position: 3, name: project.title, item: absolute(href(`/projects/${project.slug}`)) },
],
},
],
});
}, [project?.id, lang, href]);
if (!project) {
return (
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
<h1 className="text-section font-bold text-ivory">{t('projectNotFound')}</h1>
<p className="mt-4 text-sm font-light text-ivory/60">
{t('projectNotFoundText')}
</p>
<ButtonLink to={href('/projects')} variant="copper" className="mt-8">
{t('backToProjects')}
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</section>
)
}
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
/**
* پیمایش پروژه‌ها — همان ترتیبی که فهرست پروژه‌ها دارد (position از API).
* در ابتدا/انتهای فهرست، حلقه می‌زند تا کاوش هیچ‌وقت قطع نشود.
*/
const currentIndex = projects.findIndex((p) => p.slug === project.slug)
const total = projects.length
const prevProject = total > 1 ? projects[(currentIndex + 1) % total] : undefined // قدیمی‌تر
const nextProject = total > 1 ? projects[(currentIndex - 1 + total) % total] : undefined // تازه‌تر
// مشخصات هر پروژه — همان فهرستی که تیم داده دارد:
// وضعیت / سال / تعداد طبقات / واحدها / نوع پروژه / زیربنا / کاربری
// ردیف‌های خالی نمایش داده نمی‌شوند.
const facts = [
{ label: t('specStatus'), value: statusLabel },
{ label: t('specYear'), value: project.year },
{ label: t('specLandArea'), value: project.land_area },
{ label: t('specArea'), value: project.area },
{ label: t('specUsage'), value: project.usage },
{ label: t('specPilotFloors'), value: project.pilot_floors },
{ label: t('specFloors'), value: project.floors },
{ label: t('specUnitsResidential'), value: project.units_residential },
{ label: t('specUnitsCommercial'), value: project.units_commercial },
{ label: t('specUnitsOffice'), value: project.units_office },
].filter((f) => String(f.value ?? '').trim() !== '')
return (
<>
<section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" fetchPriority="high" decoding="async" />
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
aria-hidden="true"
/>
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to={href('/')} className="transition-colors hover:text-copper">
{t('home')}
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
<Link to={href('/projects')} className="transition-colors hover:text-copper">
{t('projects')}
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
<span className="text-copper">{project.title}</span>
</nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
<p className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/70">{project.summary}</p>
{/* ویرایش این پروژه — فقط برای ادمین واردشده؛ هرگز برای بازدیدکننده رندر نشود */}
{canManageSite && (
<div className="mt-5">
<Link
to={`/admin/projects/${project.id}`}
data-admin-link
className="inline-flex items-center gap-2 rounded-full border border-copper/50 bg-ink/90 px-4 py-2 text-xs font-light text-copper backdrop-blur-sm transition-colors duration-200 hover:bg-copper hover:text-char"
>
ویرایش این پروژه (عنوان، مشخصات، گالری، متن)
</Link>
</div>
)}
</div>
</section>
{/* نقشهٔ مختصات پروژه — بلافاصله زیر هیرو */}
{mapLocation && (
<section className="bg-char py-10 lg:py-14" aria-label={t('mapAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10">
<div className="w-full max-w-full overflow-hidden rounded-2xl border border-white/10">
<LazyProjectsMapSingle
location={mapLocation}
images={mapImages}
className="h-[320px] md:h-[420px] w-full"
/>
</div>
</div>
</section>
)}
<section className="bg-char py-20 lg:py-28" aria-label={t('projectDetailsAria')}>
<div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
<Reveal className="order-1">
<span className="flex items-center gap-3 text-sm font-light text-copper">
<span aria-hidden="true" className="h-px w-8 bg-copper" />
{statusLabel}
</span>
<h2 className="mt-4 text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
{project.body.map((para) => (
<p key={para} className="mt-5 text-justify text-base font-light leading-9 text-ivory/70">
{para}
</p>
))}
<dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10">
{facts.map((f) => (
<div key={f.label} className="bg-ink p-6 md:p-8 ltr:text-left rtl:text-right">
<dt className="text-xs font-light text-ivory/50">{f.label}</dt>
<dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd>
</div>
))}
</dl>
<div className="mt-10">
<ButtonLink to={href('/contact')} variant="outline" className="font-light">
{t('similarProjectCta')}
</ButtonLink>
</div>
</Reveal>
<Reveal delay={0.1} className="order-2">
<button
type="button"
onClick={() => setLightbox(0)}
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
>
<img
src={project.gallery[0] || project.image}
alt=""
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
loading="lazy"
decoding="async"
/>
</button>
<div className="mt-4 grid grid-cols-3 gap-4">
{project.gallery.slice(1).map((img, i) => (
<button
key={img}
type="button"
onClick={() => setLightbox(i + 1)}
className="group block overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
>
<img
src={img}
alt=""
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
loading="lazy"
decoding="async"
/>
</button>
))}
</div>
</Reveal>
</div>
</section>
{/* پیمایش پروژه‌ها — رفتن به پروژهٔ قبلی/بعدی بدون بازگشت به فهرست */}
{(prevProject || nextProject) && (
<nav
className="border-t border-ivory/10 bg-char py-10 lg:py-12"
aria-label={t('projectNavAria')}
>
<div className="mx-auto flex max-w-site items-stretch justify-between gap-3 px-6 lg:px-10">
{prevProject ? (
<Link
to={href(`/projects/${prevProject.slug}`)}
className="group flex min-w-0 flex-1 items-center gap-3 rounded-2xl border border-ivory/12 bg-ink/40 px-4 py-3.5 transition-all duration-300 ease-lux hover:-translate-y-0.5 hover:border-copper/50 hover:bg-ink/70 md:max-w-[46%] md:px-6 md:py-5"
>
<span className="flex min-w-0 flex-col text-start">
<span className="text-[11px] font-light text-ivory/50">{t('prevProject')}</span>
<span className="mt-1 truncate text-sm font-bold text-ivory transition-colors duration-300 group-hover:text-copper md:text-base">
{prevProject.title}
</span>
</span>
<ArrowLeftIcon className="h-4 w-4 shrink-0 -scale-x-100 text-copper" aria-hidden="true" />
</Link>
) : (
<span className="flex-1" aria-hidden="true" />
)}
{nextProject && (
<Link
to={href(`/projects/${nextProject.slug}`)}
className="group flex min-w-0 flex-1 items-center justify-end gap-3 rounded-2xl border border-ivory/12 bg-ink/40 px-4 py-3.5 text-end transition-all duration-300 ease-lux hover:-translate-y-0.5 hover:border-copper/50 hover:bg-ink/70 md:max-w-[46%] md:px-6 md:py-5"
>
<ArrowLeftIcon className="h-4 w-4 shrink-0 text-copper" aria-hidden="true" />
<span className="flex min-w-0 flex-col text-end">
<span className="text-[11px] font-light text-ivory/50">{t('nextProject')}</span>
<span className="mt-1 truncate text-sm font-bold text-ivory transition-colors duration-300 group-hover:text-copper md:text-base">
{nextProject.title}
</span>
</span>
</Link>
)}
</div>
</nav>
)}
<section className="bg-ink py-20 lg:py-28" aria-label={t('relatedProjectsAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10">
<h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2>
<div className="mt-6 h-px w-24 bg-copper/70" />
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{related.map((item, i) => (
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
<ProjectCard project={item} />
</Reveal>
))}
</div>
</div>
</section>
<Lightbox
images={project.gallery}
index={lightbox}
onClose={() => setLightbox(null)}
onChange={setLightbox}
/>
<ContactSection />
</>
)
}