297 lines
14 KiB
TypeScript
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 />
|
|
</>
|
|
)
|
|
} |