feat(ui): update UI v1

This commit is contained in:
fatemeh sane
2026-09-25 22:49:30 +03:30
parent 805f4594d4
commit 853ad67db5
82 changed files with 10311 additions and 505 deletions
+13 -1
View File
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react'
import Swal from 'sweetalert2'
import type { SiteData } from '../api'
import { useSiteData } from '../api'
import { adminApi, getToken, HttpError, setToken } from './adminClient'
import { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient'
import { ENTITIES, isTranslatable } from './entities'
export type SaveStatus = 'idle' | 'saving' | 'error'
@@ -129,6 +129,18 @@ export function AdminProvider({ children }: { children: ReactNode }) {
return () => document.removeEventListener('click', onClick, true)
}, [isEditMode])
// توکن بیرون از این provider هم پاک می‌شود (پاسخ 401 بارگذاری سایت، خروج در زبانه
// دیگر) — وضعیت ورود همگام می‌ماند و نوار ادمین برای نشست مرده باقی نمی‌ماند
useEffect(() => {
const syncAuth = () => setIsAuthenticated(!!getToken())
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
window.addEventListener('storage', syncAuth)
return () => {
window.removeEventListener(AUTH_CHANGE_EVENT, syncAuth)
window.removeEventListener('storage', syncAuth)
}
}, [])
// در حین ذخیره‌سازی، از بسته‌شدن تصادفی صفحه جلوگیری می‌شود
useEffect(() => {
if (saveStatus !== 'saving') return
+4
View File
@@ -3,10 +3,14 @@ import { API_URL } from '../config'
const TOKEN_KEY = 'aramesh_admin_token'
/** رویداد تغییر وضعیت ورود — لایه‌های دیگر (مثل نوار ادمین) با آن همگام می‌مانند */
export const AUTH_CHANGE_EVENT = 'aramesh:auth-change'
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
export const setToken = (token: string | null): void => {
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
window.dispatchEvent(new CustomEvent(AUTH_CHANGE_EVENT))
}
export class HttpError extends Error {
+16 -4
View File
@@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import type { ReactNode } from 'react'
import type { Article, Project, Service } from './data/site'
import { API_URL } from './config'
import { getToken } from './admin/adminClient'
import { getToken, setToken } from './admin/adminClient'
import { localizeSite } from './i18n/localize'
import { useLang } from './i18n/lang'
@@ -230,10 +230,16 @@ interface SiteContextValue {
const SiteContext = createContext<SiteContextValue>({
data: null,
error: false,
// پیش‌فرض‌های بی‌اثر — مصرف‌کننده بیرون از Provider هم بدون خطا رندر می‌شود
// eslint-disable-next-line @typescript-eslint/no-empty-function
reload: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
updateSite: () => {},
})
/** سقف انتظار داده سایت — سرور بی‌پاسخ یعنی صفحه خطا با دکمه تلاش دوباره، نه انتظار بی‌پایان */
const SITE_TIMEOUT_MS = 15_000
export function SiteProvider({ children }: { children: ReactNode }) {
const [data, setData] = useState<SiteData | null>(null)
const [error, setError] = useState(false)
@@ -241,7 +247,9 @@ export function SiteProvider({ children }: { children: ReactNode }) {
const load = useCallback(() => {
let cancelled = false
setError(false)
fetch(`${API_URL}/api/site`)
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS)
fetch(`${API_URL}/api/site`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<SiteData>
@@ -255,14 +263,17 @@ export function SiteProvider({ children }: { children: ReactNode }) {
const res = await fetch(`${API_URL}/api/admin/articles`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (res.ok) {
if (res.status === 401) {
// توکن منقضی/نامعتبر — پاک می‌شود تا بارگذاری‌های بعدی درخواست بی‌فایده نفرستند
setToken(null)
} else if (res.ok) {
const all = (await res.json()) as Article[]
const published = new Set(json.articles.map((a) => a.id))
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
json.articles = [...json.articles, ...drafts]
}
} catch {
/* بدون پیش‌نویس‌ها ادامه بده */
/* بدون پیش‌نویس‌ها ادامه بده — بارگذاری سایت هرگز وابسته به پنل مدیریتی نیست */
}
}
if (!cancelled) {
@@ -275,6 +286,7 @@ export function SiteProvider({ children }: { children: ReactNode }) {
})
return () => {
cancelled = true
window.clearTimeout(timeoutId)
}
}, [])
+8 -8
View File
@@ -26,28 +26,28 @@ export function ArticleCard({ article }: { article: Article }) {
</div>
</div>
<div className="flex flex-1 flex-col p-7">
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
<div className="flex flex-1 flex-col p-8">
<h3 className="mb-4 md:mb-6 text-xl font-bold leading-relaxed text-ivory transition-colors duration-300 group-hover:text-copper">
{article.title}
</h3>
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3">
<p className="mb-6 md:mb-10 text-[15px] font-light leading-loose text-ivory/60 line-clamp-3">
{article.excerpt}
</p>
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
<span className="flex items-center gap-3">
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-6 md:pt-8 text-xs font-light text-ivory/50">
<span className="flex items-center gap-4">
<span>{formatJalali(article.date)}</span>
<span className="flex items-center gap-1.5">
<span className="flex items-center gap-2">
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
{toPersianDigits(article.read)}
</span>
</span>
{/* دکمه کپسولی ادامه مطلب با افکت شیشه‌ای */}
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_15px_rgba(197,143,92,0.5)] rtl:hover:[&>svg]:-translate-x-1 ltr:hover:[&>svg]:translate-x-1">
{t('readMore')}
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300" />
</span>
</div>
</div>
-110
View File
@@ -1,110 +0,0 @@
import React from 'react'
import { Link } from 'react-router-dom'
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
type BaseProps = {
children: React.ReactNode
variant?: Variant
className?: string
}
// استایل‌های فوق‌لوکس شیشه‌ای (Glassmorphic) با رنگ‌های اختصاصی پروژه
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
copper: {
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
text: 'group-hover:text-char font-semibold',
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
},
outline: {
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبه‌ها
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
fill: 'bg-white/10',
text: 'group-hover:text-copper',
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
},
dark: {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
fill: 'bg-copper',
text: 'group-hover:text-char',
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
},
'dark-ivory': {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
fill: 'bg-ivory',
text: 'group-hover:text-char',
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
},
}
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
const style = STYLES[variant]
return (
<>
{/* خط انعکاس نوری شیشه در بالای دکمه */}
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
{/* انیمیشن پر شدن موجی و نرم */}
<span
aria-hidden="true"
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
/>
{/* متن و آیکون دکمه */}
<span
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
>
{children}
</span>
</>
)
}
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشه‌ای
const SHELL =
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
export function ButtonLink({
to,
children,
variant = 'copper',
className = '',
}: BaseProps & { to: string }) {
const style = STYLES[variant]
return (
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
<Inner variant={variant}>{children}</Inner>
</Link>
)
}
export function ButtonAnchor({
href,
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
const style = STYLES[variant]
return (
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</a>
)
}
export function Button({
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const style = STYLES[variant]
return (
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</button>
)
}
+8 -14
View File
@@ -1,8 +1,8 @@
import React, { useState } from 'react'
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { Reveal } from './Reveal'
import { Button } from './Button'
import { PremiumButton } from './PremiumButton'
import { useSite } from '../api'
import { useLang } from '../i18n/lang'
import { useFieldSaver } from '../admin/AdminContext'
@@ -194,17 +194,15 @@ export function ContactSection() {
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
</p>
<Button
type="button"
variant="outline"
className="mt-8 font-light"
<PremiumButton
onClick={() => {
setSent(false)
setForm({ name: '', phone: '', note: '' })
}}
className="mt-8 font-light"
>
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
</Button>
</PremiumButton>
</div>
) : (
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
@@ -253,21 +251,17 @@ export function ContactSection() {
</div>
<div className="md:col-span-2 flex justify-start">
<Button
<PremiumButton
type="submit"
variant="copper"
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
disabled={sending}
>
{sending ? (
content.sending_label
) : (
<>
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
</>
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
)}
</Button>
</PremiumButton>
</div>
</form>
)}
+67 -5
View File
@@ -1,7 +1,18 @@
import { useLocation, useNavigate } from 'react-router-dom'
import { LanguagesIcon } from 'lucide-react'
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
/**
* تنظیمات ثابت هر زبان.
* ترتیب اینجا = ترتیب نمایش در کپسول (فارسی سمت چپ، English سمت راست).
* بجای مقادیر پیکسلی، از translate-x-full استفاده شده تا دقیقاً به اندازه عرض خودش (50%) جابجا شود.
*/
const LANGS = {
fa: { label: 'فارسی', sliderTranslate: 'translate-x-0' },
en: { label: 'English', sliderTranslate: 'translate-x-full' },
} as const
type LangKey = keyof typeof LANGS
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */
export function LangToggle({ className = '' }: { className?: string }) {
const { lang, t } = useLang()
@@ -22,10 +33,61 @@ export function LangToggle({ className = '' }: { className?: string }) {
onClick={switchLang}
aria-label={t('switchLangAria')}
title={t('switchLangAria')}
className={`group flex items-center gap-1.5 rounded-full border border-white/15 bg-white/5 px-3.5 py-2 text-xs font-bold text-ivory/80 backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 cursor-pointer ${className}`}
dir="ltr"
className={[
// ── Capsule shell ──────────────────────────────────────────────
'relative flex items-center',
'w-[152px] h-[42px] rounded-full p-1', // استفاده از p-1 (4px) به جای [4px]
'bg-[#1a1715]',
// deep inset shadow → recessed track feel
'shadow-[inset_0_3px_7px_rgba(0,0,0,0.75),inset_0_-1px_2px_rgba(255,255,255,0.04)]',
// interaction
'cursor-pointer select-none',
'active:scale-[0.975] transition-transform duration-150 ease-lux',
className,
].join(' ')}
>
<LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
<span>{t('switchLang')}</span>
{/* ── Inner Track ───────────────────────────────────────────────
این دیو داخلی باعث می‌شود پدینگ دکمه اصلی روی محاسبات 50 درصدی تاثیر نگذارد
*/}
<div className="relative w-full h-full flex items-center">
{/* ── Slider pill ─────────────────────────────────────────────── */}
<span
aria-hidden="true"
className={[
'absolute top-0 left-0', // چسبیده به لبه‌های Track داخلی
'w-1/2 h-full rounded-full', // دقیقاً 50 درصد عرض و کل ارتفاع
'bg-[#27221c] border border-[#d5874c]',
// subtle raised shadow — tactile without being flashy
'shadow-[0_2px_6px_rgba(0,0,0,0.55),0_0_0_1px_rgba(213,135,76,0.18),inset_0_1px_1px_rgba(255,255,255,0.06)]',
'transition-transform duration-[380ms] ease-lux',
LANGS[lang as LangKey].sliderTranslate,
].join(' ')}
/>
{/* ── Labels ──────────────────────────────────────────────────── */}
<span className="relative z-10 flex w-full h-full">
{(Object.keys(LANGS) as LangKey[]).map((key) => {
const isActive = lang === key
return (
<span
key={key}
className={[
// each label occupies exactly half the capsule
'w-1/2 h-full flex justify-center items-center',
'text-[11.5px] font-semibold tracking-wide leading-none',
'transition-colors duration-[380ms] ease-lux',
isActive ? 'text-[#d5874c]' : 'text-[#6a6662]',
].join(' ')}
>
{LANGS[key].label}
</span>
)
})}
</span>
</div>
</button>
)
}
}
+38 -26
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import { MdClose, MdChevronLeft, MdChevronRight } from 'react-icons/md'
type Props = {
images: string[]
@@ -26,8 +26,8 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowRight') prev()
if (e.key === 'ArrowLeft') next()
if (e.key === 'ArrowRight') next()
if (e.key === 'ArrowLeft') prev()
}
window.addEventListener('keydown', onKey)
document.body.style.overflow = 'hidden'
@@ -43,55 +43,67 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
<motion.div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-[90] flex items-center justify-center p-5 md:p-10"
className="fixed inset-0 z-[90] flex items-center justify-center p-4 md:p-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
>
{/* Solid Brand Background */}
<div
className="absolute inset-0 bg-ink/90 backdrop-blur-md"
className="absolute inset-0 bg-[#27221c]"
onClick={onClose}
aria-hidden="true"
/>
{/* Close Button at Top Right */}
<button
type="button"
onClick={onClose}
className="absolute left-6 top-6 z-10 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10 md:top-10"
className="absolute right-6 top-6 z-10 flex h-10 w-10 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
>
<XIcon className="h-5 w-5" />
<MdClose size={24} />
</button>
<motion.img
key={index}
src={images[index as number]}
alt=""
className="relative max-h-[80vh] w-auto max-w-full object-contain"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
/>
<div className="absolute bottom-6 z-10 flex items-center gap-4 md:bottom-10">
{/* Main Content Area */}
<div className="relative z-10 flex w-full max-w-7xl items-center justify-center gap-4 md:gap-8">
{/* Prev Button */}
<button
type="button"
onClick={prev}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
>
<ChevronRightIcon className="h-5 w-5" />
<MdChevronRight size={28} />
</button>
<span className="text-sm font-light text-ivory/70" dir="ltr">
{(index as number) + 1} / {images.length}
</span>
{/* Image */}
<motion.img
key={index}
src={images[index as number]}
alt=""
className="max-h-[85vh] w-auto max-w-[calc(100%-7rem)] rounded-md object-contain shadow-2xl"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
/>
{/* Next Button */}
<button
type="button"
onClick={next}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-[#c57941] focus:outline-none focus:ring-2 focus:ring-copper "
>
<ChevronLeftIcon className="h-5 w-5" />
<MdChevronLeft size={28} />
</button>
</div>
<div className="absolute bottom-6 z-10 flex items-center justify-center">
<span className="text-sm font-light text-copper" dir="ltr">
{(index as number) + 1} / {images.length}
</span>
</div>
</motion.div>
)}
+62
View File
@@ -0,0 +1,62 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
type Props = {
to?: string
href?: string
children: React.ReactNode
className?: string
onClick?: () => void
disabled?: boolean
type?: 'button' | 'submit' | 'reset'
}
/**
* Premium Button replicating the cinematic, high-end documentary aesthetic.
* Features an exact cubic-bezier background fill from the bottom, transforming
* text color smoothly to maintain contrast.
*/
export function PremiumButton({ to, href, children, className = '', onClick, disabled, type = 'button' }: Props) {
const navigate = useNavigate()
// Primary: ink, Secondary: copper
const baseClasses = `
group/btn relative inline-flex items-center justify-center overflow-hidden
rounded-full px-10 py-4 text-sm font-bold tracking-widest text-copper
border border-copper bg-ink
transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg
cursor-pointer select-none
${className}
`.trim()
const innerEffect = (
<>
<span className="absolute inset-0 z-0 bg-copper scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
{children}
</span>
</>
)
const handleClick = (e: React.MouseEvent) => {
if (onClick) onClick();
if (to) {
e.preventDefault();
navigate(to);
}
}
if (href) {
return (
<a href={href} className={baseClasses} onClick={handleClick}>
{innerEffect}
</a>
)
}
return (
<button type={type} disabled={disabled} className={baseClasses} onClick={handleClick}>
{innerEffect}
</button>
)
}
+61 -32
View File
@@ -1,60 +1,89 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftIcon } from 'lucide-react'
import { useStatusLabel } from '../api'
import { useLang } from '../i18n/lang'
import { PremiumButton } from './PremiumButton'
import type { StaticProject } from '../data/projectsData'
import type { ProjectCardItem } from '../data/site'
export function ProjectCard({ project }: any) {
type ProjectCardProps = {
project: StaticProject | ProjectCardItem;
imageUrl?: string;
detailHref?: string;
}
export function ProjectCard({ project, imageUrl, detailHref }: ProjectCardProps) {
const isOngoing = project.status === 'progress'
const statusText = useStatusLabel(project.status)
const { t, href, lang } = useLang()
const { t, lang } = useLang()
// Type guards for backward compatibility
const isStatic = 'name' in project;
// Resolve localized texts
const title = isStatic
? (project.name[lang as 'en' | 'fa'] || project.name.en)
: (project as ProjectCardItem).title;
const locRaw = isStatic ? project.location : (project as ProjectCardItem).location;
const location = typeof locRaw === 'string' ? locRaw : (locRaw?.[lang as 'en' | 'fa'] || locRaw?.en);
const areaRaw = isStatic ? project.area : (project as ProjectCardItem).area;
const area = typeof areaRaw === 'string' ? areaRaw : (areaRaw?.[lang as 'en' | 'fa'] || areaRaw?.en);
const finalImageUrl = imageUrl || (project as ProjectCardItem).image;
const finalDetailHref = detailHref || (project as ProjectCardItem).detailHref || '#';
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
return (
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
<Link to={href(`/projects/${project.slug || '#'}`)} className="flex h-full flex-col focus:outline-none">
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشه‌ای */}
<div className="relative aspect-[4/3] w-full overflow-hidden">
<img
src={project.image}
alt={project.title}
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
/>
<Link to={finalDetailHref} className="flex flex-1 flex-col focus:outline-none">
{/* Fixed height image wrapper */}
<div className="relative w-full overflow-hidden">
{finalImageUrl && (
<img
src={finalImageUrl}
alt={title}
className="w-full h-[250px] object-cover transition-transform duration-700 ease-lux group-hover:scale-110"
/>
)}
{/* نشانگر وضعیت کپسولی شیشه‌ای */}
<div className={`absolute top-4 ltr:left-4 rtl:right-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}>
<div
className={`absolute top-4 ltr:left-4 rtl:right-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}
>
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
{statusText}
</div>
{/* گوی دکمه شیشه‌ای شناور برای ورود به پروژه */}
<div className="absolute bottom-4 ltr:right-4 rtl:left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
<ArrowLeftIcon className="h-5 w-5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
</div>
</div>
<div className="flex flex-1 flex-col p-7">
<div className="flex flex-1 flex-col p-7 pb-4">
<div className="text-center">
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
{project.title}
{title}
</h3>
<p className="mt-4 text-sm font-light text-ivory/60">
{project.location}
</p>
{location && <p className="mt-4 text-sm font-light text-ivory/60">{location}</p>}
</div>
<div className="my-6 h-px w-full bg-white/10" />
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
<span>{t('yearLabel')} {project.year}</span>
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه می‌شود */}
<span>{lang === 'fa' ? `${project.area} ${t('sqm')}` : project.area}</span>
</div>
{area && (
<>
<div className="my-6 h-px w-full bg-white/10" />
<div className="mt-auto flex items-center justify-end text-sm font-light text-ivory/60">
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه می‌شود */}
<span>{lang === 'fa' ? `${area} ${t('sqm')}` : area}</span>
</div>
</>
)}
</div>
</Link>
{/* پایهٔ کارت: یک دکمه واحد */}
<div className="flex flex-col gap-2 px-7 pb-7">
<PremiumButton to={finalDetailHref} className="w-full">
{t('viewProject')}
</PremiumButton>
</div>
</article>
)
}
}
@@ -0,0 +1,19 @@
import { MdApartment } from 'react-icons/md'
import { useLang } from '../i18n/lang'
/**
* جای‌نگهدار تصویر پروژه — همان زبان بصری کارت پاپ‌آپ، برای هر پروژه‌ای که
* تصویری در داده ندارد (مثل ردیف‌های شیت). تک‌منبع این ظاهر همین‌جاست.
*/
export function ProjectImagePlaceholder({ className = '' }: { className?: string }) {
const { t } = useLang()
return (
<div
className={`flex h-full w-full flex-col items-center justify-center gap-2 bg-gradient-to-br from-char to-ink text-ivory/25 ${className}`}
>
<MdApartment className="h-8 w-8" aria-hidden="true" />
<span className="text-[10px] font-light">{t('mapImagePlaceholder')}</span>
</div>
)
}
+4 -5
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { ArrowLeftIcon } from 'lucide-react';
import { Reveal } from '../Reveal';
import { ButtonLink } from '../Button';
import { PremiumButton } from '../PremiumButton';
import { ArticleCard } from '../ArticleCard';
import { useSectionHeading, useSite } from '../../api';
import { useLang } from '../../i18n/lang';
@@ -53,10 +53,9 @@ export function Articles() {
{heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}>
<div className="mt-12 flex justify-center">
<ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light">
<PremiumButton to={href(heading.cta_href)} className="font-light">
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</PremiumButton>
</div>
</Reveal>
)}
+9 -7
View File
@@ -18,20 +18,22 @@ export function Faq({ hideHeading = false }: FaqProps) {
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
<div className="mx-auto max-w-site px-4 md:px-8 lg:px-10">
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
{/* Header — centered, full width */}
{!hideHeading && (
<div className="lg:col-span-4">
<div className="mb-8 md:mb-14 flex flex-col items-center w-full text-center">
<SectionHeading
title={t('fallbackFaqTitle')}
lead={t('fallbackFaqLead')}
headingKey="faq"
align="center"
/>
</div>
)}
<div className={hideHeading ? "" : "lg:col-span-8"}>
{/* Accordion list — constrained width, direction-aware */}
<div className="w-full max-w-3xl mx-auto">
<ul>
{faqs.map((faq, i) => {
const isOpen = open === i
@@ -43,9 +45,9 @@ export function Faq({ hideHeading = false }: FaqProps) {
type="button"
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
>
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
<span className={`text-base font-bold md:text-lg text-start ${isOpen ? 'text-copper' : 'text-ivory'}`}>
{faq.question}
</span>
<PlusIcon
@@ -63,7 +65,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden"
>
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
<p className="pb-7 ps-10 text-sm font-light leading-9 text-ivory/60 text-start">{faq.answer}</p>
</motion.div>
)}
</AnimatePresence>
@@ -1,22 +1,23 @@
import React, { useState } from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion'
import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button'
import { PremiumButton } from '../PremiumButton'
import { ProjectCard } from '../ProjectCard'
import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { useLang } from '../../i18n/lang'
import { InlineEdit } from '../admin/InlineEdit'
import { AddItemButton, EditableItem } from '../../admin/Editable'
import type { ProjectStatus } from '../../data/site'
import { projectsData } from '../../data/projectsData'
export function FeaturedProjects() {
const { projects, status_labels } = useSite()
const { status_labels } = useSite()
const { t, href } = useLang()
const heading = useSectionHeading('featured_projects')
const save = useFieldSaver()
const saveStatusLabel = (key: string, value: string) => {
const label = status_labels.find((s) => s.key === key)
if (!label) return
@@ -30,8 +31,12 @@ export function FeaturedProjects() {
{ key: 'done', label: useStatusLabel('done') },
{ key: 'progress', label: useStatusLabel('progress') },
]
const [status, setStatus] = useState<ProjectStatus>('done')
const list = projects.filter((p) => p.status === status).slice(0, 3)
// Filter and slice static project data
const list = projectsData.filter((p) => p.status === status).slice(0, 3)
const BASE_IMG_URL = "/seed-media/2026/09/";
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
@@ -44,7 +49,6 @@ export function FeaturedProjects() {
const isSelected = status === filter.key
return (
<React.Fragment key={filter.key}>
{/* خط جداکننده بین دکمه‌ها */}
{index > 0 && (
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
)}
@@ -82,19 +86,26 @@ export function FeaturedProjects() {
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="grid gap-6 lg:grid-cols-3"
>
{list.map((project, i) => (
<motion.div
key={project.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
className="h-full"
>
<EditableItem resource="projects" item={project} className="h-full">
<ProjectCard project={project} />
</EditableItem>
</motion.div>
))}
{list.map((project, i) => {
const dummyImageName = `${(i % 6) + 1}.png`;
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
return (
<motion.div
key={project.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
className="h-full"
>
<ProjectCard
project={project}
imageUrl={imageUrl}
detailHref={`/projects/${project.slug}`}
/>
</motion.div>
)
})}
</motion.div>
</AnimatePresence>
</div>
@@ -103,11 +114,7 @@ export function FeaturedProjects() {
{heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}>
<div className="mt-12 flex justify-center">
<ButtonLink
to={href(heading.cta_href)}
variant="outline"
className="group flex items-center gap-3 rounded-full border border-white/20 bg-white/[0.04] px-8 py-3.5 text-sm backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_30px_rgba(197,143,92,0.35)]"
>
<PremiumButton to={href(heading.cta_href)}>
<InlineEdit
value={heading.cta_label}
onSave={(v) =>
@@ -117,10 +124,7 @@ export function FeaturedProjects() {
})
}
/>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1 group-hover:bg-copper group-hover:text-char">
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100" />
</span>
</ButtonLink>
</PremiumButton>
</div>
</Reveal>
)}
+57 -35
View File
@@ -64,7 +64,7 @@ export function Gallery() {
<AnimatePresence>
{active !== null && gallery[active] && (
<motion.div
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
className="fixed inset-0 z-[80] flex flex-col items-center justify-center bg-ink p-4 md:p-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -74,49 +74,71 @@ export function Gallery() {
aria-label={t('viewImage')}
onClick={close}
>
{/* Close Button */}
<button
type="button"
onClick={close}
className="absolute top-8 ltr:right-8 rtl:left-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
className="absolute top-6 right-6 md:top-8 md:right-8 z-10 flex h-10 w-10 md:h-11 md:w-11 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
aria-label={t('close')}
>
<XIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
step(-1)
}}
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:right-6 md:rtl:right-10 ltr:left-6 md:ltr:left-10"
aria-label={t('prevImage')}
>
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
step(1)
}}
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:left-6 md:rtl:left-10 ltr:right-6 md:ltr:right-10"
aria-label={t('nextImage')}
>
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
</button>
<motion.img
key={active}
src={gallery[active].src}
alt=""
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
className="max-h-[85vh] w-auto max-w-[85vw] object-contain shadow-[0_30px_80px_-20px_rgba(0,0,0,0.9)]"
{/* Flex Container for Prev, Image, Next */}
<div
className="relative z-10 flex w-full max-w-7xl flex-1 items-center justify-center gap-4 md:gap-8"
onClick={(e) => e.stopPropagation()}
/>
>
<button
type="button"
onClick={() => step(-1)}
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
aria-label={t('prevImage')}
>
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
</button>
<motion.img
key={active}
src={gallery[active].src}
alt={gallery[active].alt}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
className="max-h-[75vh] w-auto max-w-[calc(100%-7rem)] rounded-lg object-contain shadow-2xl"
/>
<button
type="button"
onClick={() => step(1)}
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
aria-label={t('nextImage')}
>
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
</button>
</div>
{/* Modern Dot Pagination */}
<div
className="absolute bottom-6 md:bottom-10 flex items-center justify-center gap-2 z-10"
onClick={(e) => e.stopPropagation()}
>
{gallery.map((_, idx) => {
const isActive = idx === active;
return (
<button
key={idx}
type="button"
onClick={() => setActive(idx)}
aria-label={`${t('viewImage')} ${formatNumber(idx + 1, lang)}`}
className={`h-2.5 rounded-full transition-all duration-300 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory ${
isActive ? 'w-6 bg-copper' : 'w-2.5 bg-white/50 hover:bg-white/80'
}`}
/>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
+10 -35
View File
@@ -1,7 +1,7 @@
import React from 'react'
import { motion, useScroll, useTransform } from 'framer-motion'
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
import { ButtonLink } from '../Button'
import { ChevronDownIcon } from 'lucide-react'
import { PremiumButton } from '../PremiumButton'
import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext'
@@ -84,45 +84,20 @@ export function Hero() {
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
className="mt-10 flex flex-wrap items-center gap-4"
>
{/* دکمه اصلی: کپسولی شیشه‌ای دودی با دایره کهربایی اختصاصی برای آیکون */}
<ButtonLink
to={href(hero.cta_primary_href)}
variant="copper"
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
>
<span className="text-sm font-medium tracking-wide text-ivory">
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
</span>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1">
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5] ltr:-scale-x-100" />
</span>
</ButtonLink>
{/* دکمه اصلی */}
<PremiumButton to={href(hero.cta_primary_href)}>
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
</PremiumButton>
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
<ButtonLink
to={href(hero.cta_secondary_href)}
variant="outline"
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
>
{/* دکمه ثانویه */}
<PremiumButton to={href(hero.cta_secondary_href)} className="bg-char/40 backdrop-blur-md">
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
</ButtonLink>
</PremiumButton>
</motion.div>
</div>
{/* کادر آمار شناور (گوشه پایین چپ): شیشه‌ای با لبه‌های گرد مدرن */}
<motion.div
initial={{ opacity: 0, x: -24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
className="absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:ltr:right-10 lg:rtl:left-10"
>
<p className="text-3xl font-bold text-copper font-mono">
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
</p>
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
<InlineEdit multiline value={hero.badge_label} onSave={(v) => saveHero('badge_label', v)} />
</p>
</motion.div>
</motion.div>
{/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */}
+8 -9
View File
@@ -1,8 +1,8 @@
import React, { useState } from 'react'
import { CheckIcon, SendIcon } from 'lucide-react'
import { CheckIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { Reveal } from '../Reveal'
import { Button } from '../Button'
import { PremiumButton } from '../PremiumButton'
import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext'
@@ -98,7 +98,7 @@ export function Newsletter() {
}
return (
<section className="relative overflow-hidden bg-gradient-to-r from-copper via-[#d8965e] to-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
<section className="relative overflow-hidden bg-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
<SingletonPill resource="newsletter" label="ویرایش متن‌های خبرنامه" className="absolute left-4 top-4" />
{/* هاله نور پس‌زمینه برای عمق بصری */}
@@ -142,19 +142,18 @@ export function Newsletter() {
)}
</div>
<Button
<button
type="submit"
variant="dark"
disabled={sending}
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
className="group/btn relative inline-flex items-center justify-center overflow-hidden rounded-full px-10 py-4 text-sm font-bold tracking-widest text-white border border-ink bg-ink transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg cursor-pointer select-none disabled:opacity-50 disabled:cursor-not-allowed"
>
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
<span>
<span className="absolute inset-0 z-0 bg-white scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
{sending ? newsletter.sending_label : (
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
)}
</span>
</Button>
</button>
</form>
</Reveal>
</div>
+54 -17
View File
@@ -1,31 +1,68 @@
import React, { useEffect, useRef, useState } from 'react';
import { useInView } from 'framer-motion';
import { useSite } from '../../api';
import { useLang, formatNumber } from '../../i18n/lang';
import { useFieldSaver } from '../../admin/AdminContext';
import { AddItemButton, EditableItem } from '../../admin/Editable';
import { InlineEdit } from '../admin/InlineEdit';
function Counter({ value }: {value: number;}) {
const { lang } = useLang();
function useCounter(targetNumber: number, duration: number = 2000) {
const [count, setCount] = useState(0);
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, margin: '-60px' });
const [display, setDisplay] = useState(0);
useEffect(() => {
if (!inView) return;
let frame = 0;
const total = 60;
const id = window.setInterval(() => {
frame += 1;
const progress = 1 - Math.pow(1 - frame / total, 3);
setDisplay(Math.round(value * progress));
if (frame >= total) window.clearInterval(id);
}, 20);
return () => window.clearInterval(id);
}, [inView, value]);
const element = ref.current;
if (!element) return;
return <span ref={ref}>{formatNumber(display, lang)}</span>;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
observer.disconnect();
let startTime: number | null = null;
const step = (timestamp: number) => {
if (!startTime) startTime = timestamp;
const progress = timestamp - startTime;
// ease-out cubic
const easeOut = progress >= duration ? 1 : 1 - Math.pow(1 - progress / duration, 3);
setCount(Math.floor(easeOut * targetNumber));
if (progress < duration) {
window.requestAnimationFrame(step);
} else {
setCount(targetNumber);
}
};
window.requestAnimationFrame(step);
}
},
{ threshold: 0.1 }
);
observer.observe(element);
return () => observer.disconnect();
}, [targetNumber, duration]);
return { count, ref };
}
function Counter({ value }: { value: string | number }) {
const { lang } = useLang();
const strValue = String(value);
// Match the first sequence of digits
const numMatch = strValue.match(/(\d+)/);
const targetNumber = numMatch ? parseInt(numMatch[0], 10) : 0;
const { count, ref } = useCounter(targetNumber, 1500);
// If there's a number, replace it with the animated (and localized) count, otherwise render string as is.
const formattedDisplay = formatNumber(count, lang);
const renderValue = numMatch ? strValue.replace(numMatch[0], formattedDisplay) : strValue;
return <span ref={ref}>{renderValue}</span>;
}
export function Stats() {
+4 -5
View File
@@ -1,7 +1,7 @@
import React from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button'
import { PremiumButton } from '../PremiumButton'
import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext'
@@ -89,10 +89,9 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
{showLink && (
<Reveal delay={0.22}>
<ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light">
<PremiumButton to={href(story.link_href)} className="mt-10 font-light">
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</PremiumButton>
</Reveal>
)}
+5 -4
View File
@@ -9,8 +9,8 @@ export function Team() {
const { team } = useSite()
const { t } = useLang()
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title" >
<div className="mx-auto max-w-site px-6 lg:px-10 ">
<SectionHeading
title={t('fallbackTeamTitle')}
lead={t('fallbackTeamLead')}
@@ -22,7 +22,8 @@ export function Team() {
{team.map((member, i) => (
<Reveal key={member.id} delay={0.06 * i} className="h-full">
<EditableItem resource="team" item={member} className="h-full">
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
{/* اضافه شدن rounded-2xl برای گرد شدن گوشه‌ها */}
<article className="group flex h-full flex-col items-center rounded-2xl border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
{member.name.trim().charAt(0)}
</div>
@@ -40,4 +41,4 @@ export function Team() {
</div>
</section>
)
}
}
+5 -3
View File
@@ -16,6 +16,7 @@ function Item({ item, index }: { item: Milestone; index: number }) {
return (
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
{/* دایره‌ها: مرکز دایره در موبایل و دسکتاپ دقیقاً روی مرکز خط 2px است */}
<span
aria-hidden="true"
className={`absolute right-[-5px] top-8 z-10 h-3 w-3 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
@@ -35,7 +36,6 @@ function Item({ item, index }: { item: Milestone; index: number }) {
>
<EditableItem resource="timeline" item={item}>
<div className="group relative overflow-hidden rounded-2xl border border-ivory/10 bg-ink/40 p-6 backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-1.5 hover:border-copper/50 hover:bg-ink/60 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] md:p-8">
{/* لایه نوری ظریف که در هاور روشن می‌شود */}
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
<span className="relative z-10 text-sm font-medium tracking-widest text-copper">{item.year}</span>
@@ -72,13 +72,15 @@ export function Timeline() {
</div>
<div ref={ref} className="relative mx-auto mt-20 max-w-4xl">
{/* خط پس‌زمینه ثابت - عرض 2px و همراستایی دقیق با مرکز */}
<span
aria-hidden="true"
className="absolute inset-y-0 right-0 w-px bg-ivory/10 md:right-1/2"
className="absolute inset-y-0 right-0 w-[2px] bg-ivory/10 md:right-1/2 md:translate-x-1/2"
/>
{/* خط متحرک - عرض 2px و همراستایی دقیق با مرکز */}
<motion.span
aria-hidden="true"
className="absolute inset-y-0 right-0 w-px origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2"
className="absolute inset-y-0 right-0 w-[2px] origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2 md:translate-x-1/2"
style={{ scaleY }}
/>
+5
View File
@@ -1,3 +1,8 @@
// آدرس بک‌اند — پیش‌فرض خالی یعنی درخواست‌های هم‌مبدأ (/api از طریق Traefik).
// فقط اگر بک‌اند روی مبدأ دیگری سرو می‌شود VITE_API_URL را تنظیم کنید.
export const API_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? ''
// آدرس پایه کاشی‌های نقشه (MinIO: style.json، iran.pmtiles، فونت‌ها و اسپرایت).
// در صورت تغییر مبدا سرو کاشی‌ها VITE_MAP_URL را تنظیم کنید.
export const MAP_BASE_URL: string =
(import.meta.env.VITE_MAP_URL as string | undefined) ?? 'http://localhost:19000/maps'
+247
View File
@@ -0,0 +1,247 @@
/**
* مختصات پروژه‌های گروه ساختمانی آرامش — برگرفته از شیت «Sheet1.csv».
*
* دو نکته‌ی داده‌ای که در همان‌جا حل می‌شوند:
* ۱) ستون‌های neshan_url و google_maps_url در شیت فقط برچسب بودند
* («Open in Neshan») و نشانی واقعی نداشتند؛ پس پیوندها از روی مختصات
* ساخته می‌شوند که الگوی تثبیت‌شده‌ی هر دو سرویس است.
* ۲) ستون توضیحات خالی است؛ متن پیش‌فرض در لایه‌ی رابط کاربری جایگزین می‌شود.
*/
import type { Project, ProjectCardItem } from './site'
export type MapProject = {
id: number
name: string
/** سال شمسی — ممکن است خالی باشد */
year: string
/** پروژه‌ی در جریان (ستون new شیت) */
isNew: boolean
lat: number
lng: number
description: string
}
/** مرکز پیش‌فرض نقشه — اصفهان */
export const ISFAHAN_CENTER: [number, number] = [51.6675, 32.6539]
type CsvRow = { name: string; year: string; isNew: boolean; lat: number; lng: number; description: string }
/**
* ستون‌های برچسبی شیت حذف شده‌اند (نشانی واقعی نداشتند) و ستون توضیحات به
* description نگاشت شده است. ترتیب: name,year,new,lat,lng,description
*/
const RAW_CSV = `name,year,new,lat,lng,description
پروژه سروش,1381,no,32.6662842,51.701735,
چهرازی-زیتون,1383,no,32.6700428,51.69985,
جعفریان,1384,no,32.6669259,51.701085,
بهار,1385,yes,32.6468963,51.6899119,
حسن مرادی,1385,no,32.6704278,51.6985567,
کوثر,1386,no,32.6522044,51.6856474,
صبا,1387,no,32.6541363,51.6847828,
پورمعالی - بهشت,1387,no,32.6500402,51.6919823,
2 پروژه- فردوس,1388,no,32.6614576,51.706429,
ارم,1388,no,32.6563712,51.710694,
سینا,1388,no,32.6469299,51.6855403,
مسجد سید,1389,yes,32.6669953,51.6557085,
هفده شهریور,1389,yes,32.7249998,51.6543819,
آرامش رفیع زاده,1390,no,32.6467107,51.6873398,
بن بست شریعت,1390,yes,32.6459939,51.6897556,
قصر - یاسین,1390,no,32.6663549,51.7065512,
پردیس,1390,no,32.6518961,51.6973621,
آرامش حکیم,1392,no,32.6426783,51.6813477,
آرامش دخانیات,1393,no,32.6411924,51.6797555,
بهشتی فرشادی,1393,no,32.6531989,51.6790984,
کاکتوس,1394,no,32.6486747,51.6859003,
آرامش - صیاد,1395,no,32.6486798,51.712659,
کوثر,1395,yes,32.6506204,51.6344053,
آرامش - بوعلی 1,1396,no,32.6455156,51.6918424,
میرزا نصیر,1396,no,32.668195,51.7173079,
نیکان,1396,yes,32.6504663,51.6355982,
آرامش-رسا,1397,no,32.6522126,51.6857297,
نگین,1397,yes,32.6507084,51.6313395,
آرامش - بوعلی 2,1398,no,32.645391,51.6917828,
آرامش استاد همایی,1398,no,32.6563505,51.691988,
آرامش صدر,1398,no,32.6469234,51.6826176,
آرامش - سپهسالار,1399,yes,32.6259658,51.7080268,
آرامش- صدر,1399,no,32.6469205,51.6829887,
آرامش - سنایی,1400,yes,32.6973339,51.6194128,
مغازه لباس فروشی,1400,no,32.6516965,51.7020497,
آرامش - آتشگاه,1401,yes,32.6547584,51.6319244,
آرامش - بزرگمهر,1401,no,32.651381,51.6993575,
آرامش - سعدی,1401,yes,32.6970115,51.6196909,
آرامش - صمدیه لباف,1401,yes,32.6716585,51.6359248,
آرامش 2,1401,yes,32.6505704,51.6337863,
آرامش 5 رمضان,1401,yes,32.6674407,51.6568827,
آرامش احمدآباد,1401,no,32.6626101,51.6969828,
تجاری آتشگاه,1401,yes,32.6515087,51.6033278,
آرامش - شهیدان,1402,yes,32.6854968,51.6171964,
آرامش خواجو,1402,no,32.6419187,51.6831873,
آرامش- اداری ابوالحسن,1402,no,32.6453749,51.6915519,
آرامش - انقلاب,1403,yes,32.719769,51.645921,
آرامش - شریف,1404,no,32.6473319,51.6955996,
آرامش - کهندژ,1404,yes,32.6649497,51.630077,
آرامش جی,1404,no,32.6610717,51.7110184,
آرامش - بابک,1405,yes,32.674076,51.6368204,
آرامش - شمس آبادی,1405,yes,32.6560844,51.664771,
آرامش - میرزاطاهر 3,1405,yes,32.6495532,51.6178643,
آرامش - وحدت,1405,yes,32.6537564,51.6197007,
آرامش پروین,1405,no,32.6618223,51.7098152,
خلیفه سلطانی,1405,yes,32.7035398,51.6223296,
آرامش - میرزا طاهر 5,1406,yes,32.6517244,51.6202975,
آرامش - میرزاطاهر 6,1408,yes,32.6505366,51.6267342,
آرامش - میرزا طاهر 4,,yes,32.6510328,51.631436,
آرامش- میرزا طاهر 1,,yes,32.6506236,51.6298238,`
/**
* تجزیه‌ی یک خط CSV — پشتیبانی از فیلدهای گیومه‌دار و گیومه‌ی دوگانه‌ی درونی.
* جداکننده کاما و نویسه‌های مخفی ابتدای فایل (BOM) هم حذف می‌شوند.
*/
function parseCsvLine(line: string): string[] {
const fields: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i += 1) {
const char = line[i]
if (inQuotes) {
if (char === '"') {
if (line[i + 1] === '"') {
current += '"'
i += 1
} else inQuotes = false
} else current += char
} else if (char === '"') inQuotes = true
else if (char === ',') {
fields.push(current.trim())
current = ''
} else current += char
}
fields.push(current.trim())
return fields
}
/** CSV خام → آرایه‌ی تایپ‌شده‌ی JSON */
export function parseProjectsCsv(raw: string): MapProject[] {
const rows: CsvRow[] = []
const lines = raw.replace(/^\ufeff/, '').split(/\r?\n/)
for (const line of lines.slice(1)) {
if (!line.trim()) continue
const [name, year = '', isNew = '', lat = '', lng = '', description = ''] = parseCsvLine(line)
const parsedLat = Number(lat)
const parsedLng = Number(lng)
if (!name || !Number.isFinite(parsedLat) || !Number.isFinite(parsedLng)) continue
rows.push({ name, year, isNew: isNew.toLowerCase() === 'yes', lat: parsedLat, lng: parsedLng, description })
}
return rows.map((row, index) => ({ id: index + 1, ...row }))
}
export const MAP_PROJECTS: MapProject[] = parseProjectsCsv(RAW_CSV)
/** پیوند نشان روی نقشه برای سرویس نشان */
export function buildNeshanUrl(project: Pick<MapProject, 'lat' | 'lng'>, zoom = 17): string {
return `https://neshan.org/maps/@${project.lat},${project.lng},${zoom}z`
}
/** پیوند نقشه گوگل */
export function buildGoogleMapsUrl(project: Pick<MapProject, 'lat' | 'lng'>): string {
return `https://www.google.com/maps?q=${project.lat},${project.lng}`
}
const NAME_NOISE = ['آرامش', 'پروژه', 'مجتمع', 'گروه', 'ساختمانی', 'برج', 'مجموعه']
/**
* نرمال‌سازی نام برای تطبیق شیت با داده‌ی بک‌اند: نویسه‌های نامرئی،
* ی/ک عربی، نشانه‌ها و واژه‌های عمومی حذف می‌شوند تا «آرامش - سنایی» و
* «پروژه سنایی» یک کلید بگیرند.
*/
export function normalizeProjectName(name: string): string {
let result = (name ?? '')
.normalize('NFC')
.replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
.replace(/[يى]/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/[ۀة]/g, 'ه')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim()
for (const noise of NAME_NOISE) result = result.replace(new RegExp(`(^|\\s)${noise}(?=\\s|$)`, 'g'), ' ')
return result.replace(/\s+/g, ' ').trim()
}
/** پروژه‌ی شیت متناظر با یک نام (تطبیق دقیق نرمال‌شده، سپس دربرگیری) */
export function findMapProjectByName(name: string): MapProject | undefined {
const key = normalizeProjectName(name)
if (!key) return undefined
const exact = MAP_PROJECTS.find((project) => normalizeProjectName(project.name) === key)
if (exact) return exact
return MAP_PROJECTS.find((project) => {
const candidate = normalizeProjectName(project.name)
return candidate.length >= 3 && key.length >= 3 && (candidate.includes(key) || key.includes(candidate))
})
}
/**
* محل جایگزین وقتی پروژه‌ای در شیت پیدا نشد — مرکز اصفهان با نام پروژه‌ی
* بک‌اند، تا نمای تک‌پروژه هرگز بدون نقشه نماند.
*/
export function createPlaceholderProject(name: string, description = ''): MapProject {
return {
id: 0,
name,
year: '',
isNew: false,
lat: ISFAHAN_CENTER[1],
lng: ISFAHAN_CENTER[0],
description,
}
}
/**
* کادر محدودهٔ نقشه از مختصات پروژه‌ها — با حاشیه تا لبهٔ نقشه خالی نماند و
* کاربر به بیرون از محدودهٔ پروژه‌ها جابه‌جا نشود.
*/
export function boundsForProjects(
projects: Pick<MapProject, 'lat' | 'lng'>[],
padding = 0.02,
): [[number, number], [number, number]] | undefined {
if (!projects.length) return undefined
const lats = projects.map((project) => project.lat)
const lngs = projects.map((project) => project.lng)
return [
[Math.min(...lngs) - padding, Math.min(...lats) - padding],
[Math.max(...lngs) + padding, Math.max(...lats) + padding],
]
}
/** ردیف شیت → کارت فهرست: عنوان از شیت، وضعیت از ستون new، نشانی از مختصات */
export function csvProjectToCard(project: MapProject, detailHref: string): ProjectCardItem {
return {
id: project.id,
title: project.name,
status: project.isNew ? 'progress' : 'done',
detailHref,
addressUrl: buildNeshanUrl(project),
}
}
/** پروژهٔ بک‌اند → کارت فهرست؛ نشانی نشان از تطبیق نامی با شیت می‌آید */
export function apiProjectToCard(project: Project, detailHref: string): ProjectCardItem {
const matched = findMapProjectByName(project.title)
return {
id: project.id,
title: project.title,
status: project.status,
detailHref,
location: project.location || undefined,
image: project.image || undefined,
area: project.area || undefined,
addressUrl: matched ? buildNeshanUrl(matched) : undefined,
}
}
+74
View File
@@ -0,0 +1,74 @@
export type BilingualText = { en: string; fa: string };
export type StaticProject = {
id: string;
slug: string;
name: BilingualText;
location: BilingualText;
area: BilingualText;
status: 'done' | 'progress';
};
export const projectsData: StaticProject[] = [
{ id: '1', slug: 'project-1', name: { en: 'Project 1', fa: 'پروژه سروش' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '2', slug: 'project-2', name: { en: 'Project 2', fa: 'چهرازی-زیتون' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '3', slug: 'project-3', name: { en: 'Project 3', fa: 'جعفریان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '4', slug: 'project-4', name: { en: 'Project 4', fa: 'بهار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '5', slug: 'project-5', name: { en: 'Project 5', fa: 'حسن مرادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '6', slug: 'project-6', name: { en: 'Project 6', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '7', slug: 'project-7', name: { en: 'Project 7', fa: 'صبا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '8', slug: 'project-8', name: { en: 'Project 8', fa: 'پورمعالی - بهشت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '9', slug: 'project-9', name: { en: 'Project 9', fa: '2 پروژه- فردوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '10', slug: 'project-10', name: { en: 'Project 10', fa: 'ارم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '11', slug: 'project-11', name: { en: 'Project 11', fa: 'سینا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '12', slug: 'project-12', name: { en: 'Project 12', fa: 'مسجد سید' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '13', slug: 'project-13', name: { en: 'Project 13', fa: 'هفده شهریور' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '14', slug: 'project-14', name: { en: 'Project 14', fa: 'آرامش رفیع زاده' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '15', slug: 'project-15', name: { en: 'Project 15', fa: 'بن بست شریعت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '16', slug: 'project-16', name: { en: 'Project 16', fa: 'قصر - یاسین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '17', slug: 'project-17', name: { en: 'Project 17', fa: 'پردیس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '18', slug: 'project-18', name: { en: 'Project 18', fa: 'آرامش حکیم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '19', slug: 'project-19', name: { en: 'Project 19', fa: 'آرامش دخانیات' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '20', slug: 'project-20', name: { en: 'Project 20', fa: 'بهشتی فرشادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '21', slug: 'project-21', name: { en: 'Project 21', fa: 'کاکتوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '22', slug: 'project-22', name: { en: 'Project 22', fa: 'آرامش - صیاد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '23', slug: 'project-23', name: { en: 'Project 23', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '24', slug: 'project-24', name: { en: 'Project 24', fa: 'آرامش - بوعلی 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '25', slug: 'project-25', name: { en: 'Project 25', fa: 'میرزا نصیر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '26', slug: 'project-26', name: { en: 'Project 26', fa: 'نیکان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '27', slug: 'project-27', name: { en: 'Project 27', fa: 'آرامش-رسا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '28', slug: 'project-28', name: { en: 'Project 28', fa: 'نگین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '29', slug: 'project-29', name: { en: 'Project 29', fa: 'آرامش - بوعلی 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '30', slug: 'project-30', name: { en: 'Project 30', fa: 'آرامش استاد همایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '31', slug: 'project-31', name: { en: 'Project 31', fa: 'آرامش صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '32', slug: 'project-32', name: { en: 'Project 32', fa: 'آرامش - سپهسالار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '33', slug: 'project-33', name: { en: 'Project 33', fa: 'آرامش- صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '34', slug: 'project-34', name: { en: 'Project 34', fa: 'آرامش - سنایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '35', slug: 'project-35', name: { en: 'Project 35', fa: 'مغازه لباس فروشی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '36', slug: 'project-36', name: { en: 'Project 36', fa: 'آرامش - آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '37', slug: 'project-37', name: { en: 'Project 37', fa: 'آرامش - بزرگمهر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '38', slug: 'project-38', name: { en: 'Project 38', fa: 'آرامش - سعدی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '39', slug: 'project-39', name: { en: 'Project 39', fa: 'آرامش - صمدیه لباف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '40', slug: 'project-40', name: { en: 'Project 40', fa: 'آرامش 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '41', slug: 'project-41', name: { en: 'Project 41', fa: 'آرامش 5 رمضان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '42', slug: 'project-42', name: { en: 'Project 42', fa: 'آرامش احمدآباد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '43', slug: 'project-43', name: { en: 'Project 43', fa: 'تجاری آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '44', slug: 'project-44', name: { en: 'Project 44', fa: 'آرامش - شهیدان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '45', slug: 'project-45', name: { en: 'Project 45', fa: 'آرامش خواجو' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '46', slug: 'project-46', name: { en: 'Project 46', fa: 'آرامش- اداری ابوالحسن' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '47', slug: 'project-47', name: { en: 'Project 47', fa: 'آرامش - انقلاب' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '48', slug: 'project-48', name: { en: 'Project 48', fa: 'آرامش - شریف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '49', slug: 'project-49', name: { en: 'Project 49', fa: 'آرامش - کهندژ' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '50', slug: 'project-50', name: { en: 'Project 50', fa: 'آرامش جی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '51', slug: 'project-51', name: { en: 'Project 51', fa: 'آرامش - بابک' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '52', slug: 'project-52', name: { en: 'Project 52', fa: 'آرامش - شمس آبادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '53', slug: 'project-53', name: { en: 'Project 53', fa: 'آرامش - میرزاطاهر 3' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '54', slug: 'project-54', name: { en: 'Project 54', fa: 'آرامش - وحدت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '55', slug: 'project-55', name: { en: 'Project 55', fa: 'آرامش پروین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '56', slug: 'project-56', name: { en: 'Project 56', fa: 'خلیفه سلطانی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '57', slug: 'project-57', name: { en: 'Project 57', fa: 'آرامش - میرزا طاهر 5' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '58', slug: 'project-58', name: { en: 'Project 58', fa: 'آرامش - میرزاطاهر 6' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '59', slug: 'project-59', name: { en: 'Project 59', fa: 'آرامش - میرزا طاهر 4' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '60', slug: 'project-60', name: { en: 'Project 60', fa: 'آرامش- میرزا طاهر 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
];
+21
View File
@@ -32,6 +32,10 @@ export type Project = {
units_en?: string;
client: string;
client_en?: string;
/** مختصات جغرافیایی پروژه — عرض جغرافیایی؛ ستون‌های آینده بک‌اند، اختیاری */
lat?: number;
/** مختصات جغرافیایی پروژه — طول جغرافیایی؛ ستون‌های آینده بک‌اند، اختیاری */
lng?: number;
summary: string;
summary_en?: string;
body: string[];
@@ -66,3 +70,20 @@ export type Article = {
/** هشتگ‌ها/برچسب‌های مقاله */
tags?: string[];
};
/**
* شکل پروژه در کارت‌های فهرست — از پروژهٔ بک‌اند یا ردیف شیت پر می‌شود؛
* فیلدهای اختیاری فقط وقتی رندر می‌شوند که داده‌شان موجود باشد.
*/
export type ProjectCardItem = {
id: number | string;
title: string;
status: ProjectStatus;
/** مسیر صفحهٔ جزئیات پروژه */
detailHref: string;
/** نشانی بیرونی پروژه (نشان) — در نبودش دکمهٔ آن رندر نمی‌شود */
addressUrl?: string;
location?: string;
image?: string;
area?: string;
};
+23 -3
View File
@@ -48,9 +48,7 @@ export const fa = {
fallbackFaqLead: 'پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران.',
// کارت پروژه
yearLabel: 'سال:',
sqm: 'متر مربع',
// جزئیات پروژه
projectNotFound: 'این پروژه پیدا نشد',
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید.',
@@ -106,6 +104,18 @@ export const fa = {
nextTestimonial: 'نظر بعدی',
selectTestimonialAria: 'انتخاب نظر',
testimonialByPrefix: 'نظر',
// نقشه پروژه‌ها (مپ‌لیبر)
mapAria: 'نقشه موقعیت پروژه‌ها',
mapLoading: 'در حال بارگذاری نقشه…',
mapErrorText: 'بارگذاری نقشه ناموفق بود — اتصال به سرور کاشی‌ها را بررسی کنید.',
viewProject: 'مشاهده پروژه',
mapFullscreen: 'نمایش تمام‌صفحه',
mapExitFullscreen: 'خروج از تمام‌صفحه',
viewAddress: 'مشاهده آدرس پروژه',
mapNewBadge: 'جدید',
mapProjectDescription: 'پروژه اجراشده در اصفهان',
mapImagePlaceholder: 'تصویر پروژه',
}
export type Dict = typeof fa
@@ -151,7 +161,6 @@ export const en: Dict = {
fallbackFaqTitle: 'Frequently Asked Questions',
fallbackFaqLead: 'Short answers to the questions investors and buyers ask most.',
yearLabel: 'Year:',
sqm: 'm²',
projectNotFound: 'Project not found',
@@ -206,6 +215,17 @@ export const en: Dict = {
nextTestimonial: 'Next testimonial',
selectTestimonialAria: 'Select testimonial',
testimonialByPrefix: 'Testimonial by',
mapAria: 'Projects location map',
mapLoading: 'Loading map…',
mapErrorText: 'The map failed to load — check the tile server connection.',
viewProject: 'View project',
mapFullscreen: 'Fullscreen',
mapExitFullscreen: 'Exit fullscreen',
viewAddress: 'View project location',
mapNewBadge: 'New',
mapProjectDescription: 'Project delivered in Isfahan',
mapImagePlaceholder: 'Project image',
}
export const dict = { fa, en }
+74
View File
@@ -366,4 +366,78 @@ html[lang='en'] .article-body h3 {
}
.aramesh-datepicker .rmdp-action-button {
color: #d5874c;
}
/* ---------- نقشه مپ‌لیبر — کنترل‌ها و پاپ‌آپ هم‌رنگ تم تیره ---------- */
.lux-map-popup .maplibregl-popup-content {
background: transparent;
padding: 0;
border-radius: 1rem;
box-shadow: none;
}
/* دم پاپ‌آپ — هم‌رنگ کارت شیشه‌ای */
.lux-map-popup .maplibregl-popup-tip {
border-top-color: #1e1a16;
}
.maplibregl-ctrl-group {
overflow: hidden;
border: 1px solid rgba(245, 243, 239, 0.14);
border-radius: 0.75rem !important;
background: rgba(30, 26, 22, 0.85) !important;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45) !important;
backdrop-filter: blur(10px);
}
.maplibregl-ctrl-group button {
width: 34px;
height: 34px;
background-color: transparent !important;
}
.maplibregl-ctrl-group button:hover {
background-color: rgba(213, 135, 76, 0.16) !important;
}
.maplibregl-ctrl-group button + button {
border-top: 1px solid rgba(245, 243, 239, 0.12) !important;
}
/* آیکون‌های پیش‌فرض کنترل‌ها تیره‌اند — با اینورتر روشن می‌شوند */
.maplibregl-ctrl-group button .maplibregl-ctrl-icon {
filter: invert(0.92);
}
.maplibregl-ctrl-attrib.maplibregl-compact {
border-radius: 0.75rem;
background: rgba(30, 26, 22, 0.85);
color: rgba(245, 243, 239, 0.55);
font-size: 10px;
}
.maplibregl-ctrl-attrib a {
color: rgba(213, 135, 76, 0.9);
}
.maplibregl-ctrl-attrib-button {
filter: invert(0.92);
}
.maplibregl-ctrl-scale {
border-color: rgba(245, 243, 239, 0.35);
border-radius: 2px;
background: rgba(30, 26, 22, 0.7);
color: rgba(245, 243, 239, 0.6);
font-size: 10px;
}
/* ---------- ورود نرم کارت پاپ‌آپ و حالت تمام‌صفحه ---------- */
@keyframes luxPopupIn {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes luxFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
import { lazy, Suspense } from 'react'
import type { ProjectsMapAllProps } from './ProjectsMapAll'
import type { ProjectsMapSingleProps } from './ProjectsMapSingle'
/**
* بارگیری تنبل نقشه‌ها — بسته‌ی سنگین مپ‌لیبر فقط وقتی دانلود می‌شود که
* واقعاً نقشه‌ای روی صفحه باشد؛ صفحه‌های بدون نقشه (مثل خانه) جریمه‌ی حجم
* نمی‌پردازند. Import زیر فقط نوع است و باندل را سنگین نمی‌کند.
*/
const ProjectsMapAll = lazy(() => import('./ProjectsMapAll').then((module) => ({ default: module.ProjectsMapAll })))
const ProjectsMapSingle = lazy(() =>
import('./ProjectsMapSingle').then((module) => ({ default: module.ProjectsMapSingle })),
)
/** جای‌نگهدار هم‌اندازه‌ی نقشه تا چیدمان هنگام بارگیری نلرزد */
function MapSkeleton({ className = '' }: { className?: string }) {
return <div className={`animate-pulse rounded-3xl border border-ivory/10 bg-char/50 ${className}`} />
}
export function LazyProjectsMapAll(props: ProjectsMapAllProps) {
return (
<Suspense fallback={<MapSkeleton className={props.className} />}>
<ProjectsMapAll {...props} />
</Suspense>
)
}
export function LazyProjectsMapSingle(props: ProjectsMapSingleProps) {
return (
<Suspense fallback={<MapSkeleton className={props.className} />}>
<ProjectsMapSingle {...props} />
</Suspense>
)
}
+133
View File
@@ -0,0 +1,133 @@
import * as maplibregl from 'maplibre-gl'
import { useEffect, useState, type MutableRefObject, type ReactNode } from 'react'
import { Loader2Icon } from 'lucide-react'
import { MdClose, MdFullscreen } from 'react-icons/md'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useLang } from '../i18n/lang'
import type { MapStatus } from './types'
type MapViewportProps = {
containerRef: MutableRefObject<HTMLDivElement | null>
/** نمونه‌ی نقشه — فقط برای هم‌گام‌سازی اندازه؛ مالکیتش با useMapLibre است */
map: maplibregl.Map | null
status: MapStatus
onRetry: () => void
/** برچسب دسترس‌پذیری ناحیه‌ی نقشه */
label: string
/** ارتفاع/فاصله‌ها از بیرون — مثل «h-[520px]» */
className?: string
/** محتوای شناور روی نقشه (پورتال پاپ‌آپ) */
children?: ReactNode
}
/**
* پوسته‌ی نقشه — قاب شیشه‌ای، حالت تمام‌صفحه، کنترل بزرگ‌نمایی و لایه‌های
* بارگذاری/خطا.
*
* ظرف نقشه در جریان عادی و با ارتفاع ۱۰۰٪ والد می‌نشیند (نه absolute)؛
* استایل خود مپ‌لیبر بعد از تیلویند بار می‌شود و position: relative می‌دهد،
* پس کلاس absolute بی‌اثر می‌شد و ظرف صفر می‌ماند.
*
* در تمام‌صفحه فقط شفافیت و گوشه‌ها انیمیت می‌شوند تا بافر بوم هرگز کشیده
* و تار نشود؛ تغییر اندازه در همان فریم اعمال می‌شود.
*/
export function MapViewport({
containerRef,
map,
status,
onRetry,
label,
className = '',
children,
}: MapViewportProps) {
const { t } = useLang()
const [isFullscreen, setIsFullscreen] = useState(false)
/** تمام‌صفحه: Esc برای خروج و قفل اسکرول پس‌زمینه */
useEffect(() => {
if (!isFullscreen) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsFullscreen(false)
}
window.addEventListener('keydown', onKeyDown)
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = previousOverflow
}
}, [isFullscreen])
/** پس از پایان انتقال، بوم را هم‌گام کن (مپ‌لیبر خودش هم ResizeObserver دارد) */
useEffect(() => {
if (!isFullscreen) return
const timer = window.setTimeout(() => map?.resize(), 340)
return () => window.clearTimeout(timer)
}, [isFullscreen, map])
const shellClass = isFullscreen
? 'fixed inset-0 z-[75] h-[100dvh] w-screen animate-[luxFadeIn_0.25s_ease-out] rounded-none border-0'
: `relative h-full w-full rounded-3xl border border-ivory/10 shadow-lift ${className}`
return (
<div
dir="rtl"
aria-label={label}
className={`overflow-hidden bg-char font-pinar transition-all duration-300 ease-lux ${shellClass}`}
>
{/* ظرف نقشه در جریان عادی و با ارتفاع ۱۰۰٪ والد است؛ کلاس relative صریح
است تا هم بستر موقعیت‌یابی فرزندان مپ‌لیبر باشد و هم هشدار
«container has a non-static position» و محاسبه‌ی اشتباه آفست اسکرول
رخ ندهد — ترتیب بار شدن CSS هیچ‌وقت تعیین‌کننده نباشد */}
<div ref={containerRef} className="relative h-full w-full" />
{isFullscreen ? (
<button
type="button"
onClick={() => setIsFullscreen(false)}
aria-label={t('mapExitFullscreen')}
className="fixed left-1/2 top-5 z-[80] flex h-11 -translate-x-1/2 items-center gap-2 rounded-full border border-copper/60 bg-ink/90 px-5 text-xs font-bold text-copper shadow-lift backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper hover:text-char"
>
<MdClose className="h-4 w-4" aria-hidden="true" />
{t('mapExitFullscreen')}
</button>
) : (
<button
type="button"
onClick={() => setIsFullscreen(true)}
aria-label={t('mapFullscreen')}
title={t('mapFullscreen')}
className="absolute left-4 top-4 z-20 flex h-10 w-10 items-center justify-center rounded-xl border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
>
<MdFullscreen className="h-5 w-5" aria-hidden="true" />
</button>
)}
{status !== 'ready' && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-4 bg-char/70 backdrop-blur-sm">
{status === 'loading' ? (
<>
<Loader2Icon className="h-7 w-7 animate-spin text-copper" aria-hidden="true" />
<p className="text-xs font-light tracking-wide text-ivory/60">{t('mapLoading')}</p>
</>
) : (
<>
<p className="max-w-xs text-center text-xs font-light leading-6 text-ivory/60">
{t('mapErrorText')}
</p>
<button
type="button"
onClick={onRetry}
className="border border-copper/50 px-6 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
>
{t('retry')}
</button>
</>
)}
</div>
)}
{children}
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { useState, type MouseEvent, type PointerEvent } from 'react'
import { MdChevronLeft, MdChevronRight } from 'react-icons/md'
import { ProjectImagePlaceholder } from '../components/ProjectImagePlaceholder'
import { useLang } from '../i18n/lang'
import { toPersianDigits } from '../utils/digits'
type ProjectImageSliderProps = {
/** همان آرایهٔ تصاویر صفحهٔ جزئیات پروژه — در نبودش جای‌نگهدار می‌آید */
images: string[]
alt: string
className?: string
}
/**
* اسلایدر تصویر لبه‌تا‌لبهٔ بالای کارت پاپ‌آپ (سبک Zillow): فلش‌های چپ/راست،
* شمارندهٔ اسلاید و گوشه‌های گرد هم‌اندازهٔ کارت. همهٔ رویدادهای اشاره روی
* اسلایدر متوقف می‌شوند تا کلیک/کشیدن آن، نقشه را جابه‌جا یا پاپ‌آپ را نبندد.
*/
export function ProjectImageSlider({ images, alt, className = '' }: ProjectImageSliderProps) {
const { t } = useLang()
const [index, setIndex] = useState(0)
/** هیچ رویدادی از اسلایدر به نقشهٔ زیرین نمی‌رسد */
const stop = (event: MouseEvent | PointerEvent) => event.stopPropagation()
const frame = `relative aspect-[16/10] w-full overflow-hidden rounded-t-2xl bg-char ${className}`
if (!images.length) {
return (
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
<ProjectImagePlaceholder />
</div>
)
}
const current = Math.min(index, images.length - 1)
const step = (delta: number) => setIndex((value) => (value + delta + images.length) % images.length)
const arrow =
'absolute top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-ivory/15 bg-ink/80 text-ivory/85 backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:border-copper hover:bg-copper hover:text-char'
return (
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
<img src={images[current]} alt={alt} draggable={false} className="h-full w-full object-cover" />
{images.length > 1 && (
<>
<button
type="button"
aria-label={t('prevImage')}
title={t('prevImage')}
onClick={(event) => {
event.stopPropagation()
step(-1)
}}
className={`${arrow} left-2`}
>
<MdChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
aria-label={t('nextImage')}
title={t('nextImage')}
onClick={(event) => {
event.stopPropagation()
step(1)
}}
className={`${arrow} right-2`}
>
<MdChevronRight className="h-5 w-5" />
</button>
<span className="absolute bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full bg-ink/75 px-2.5 py-0.5 text-[10px] font-light text-ivory/85 backdrop-blur-md">
{toPersianDigits(current + 1)} / {toPersianDigits(images.length)}
</span>
</>
)}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { MdLocationOn } from 'react-icons/md'
type ProjectPinProps = {
label: string
/** پروژه‌ی انتخاب‌شده — هاله‌ی مسی پایدار */
active?: boolean
onClick: () => void
}
/**
* سنجاق پروژه روی نقشه — آیکون MdLocationOn در رنگ مسی برند با هاله‌ی
* نورانی در هاور، کلیک و حالت انتخاب‌شده. این کامپوننت داخل المان نشانگر
* مپ‌لیبر رندر می‌شود، پس خودش هیچ چیدمان بیرونی ندارد.
*/
export function ProjectPin({ label, active = false, onClick }: ProjectPinProps) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={(event) => {
event.stopPropagation()
onClick()
}}
className="group flex cursor-pointer items-end justify-center bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-copper"
>
<MdLocationOn
className={`h-9 w-9 text-[#d5874c] transition-all duration-300 ease-lux group-hover:scale-125 group-hover:drop-shadow-[0_0_14px_rgba(213,135,76,0.95)] group-active:scale-110 group-active:drop-shadow-[0_0_18px_rgba(213,135,76,1)] ${
active
? 'scale-110 drop-shadow-[0_0_16px_rgba(213,135,76,0.95)]'
: 'drop-shadow-[0_3px_6px_rgba(0,0,0,0.65)]'
}`}
/>
</button>
)
}
+66
View File
@@ -0,0 +1,66 @@
import type { ReactNode } from 'react'
import { MdClose } from 'react-icons/md'
import type { MapProject } from '../data/mapProjects'
import { useLang } from '../i18n/lang'
import { ProjectImageSlider } from './ProjectImageSlider'
/** دکمه‌های کارت — تمام‌عرض، هم‌خانوادهٔ دکمه‌های مسی سایت (در ستون هم درست می‌نشینند) */
export const POPUP_PRIMARY_CLASS =
'flex w-full items-center justify-center gap-1.5 rounded-xl bg-copper px-3 py-2.5 text-[12px] font-bold text-char transition-all duration-300 ease-lux hover:scale-[1.03] hover:shadow-[0_8px_22px_rgba(213,135,76,0.35)] active:scale-95'
export const POPUP_SECONDARY_CLASS =
'flex w-full items-center justify-center gap-1.5 rounded-xl border border-ivory/20 px-3 py-2.5 text-[12px] font-light text-ivory/80 transition-colors duration-300 ease-lux hover:border-copper hover:text-copper'
type ProjectPopupCardProps = {
project: MapProject
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات؛ در نبودش جای‌نگهدار می‌آید */
images?: string[]
onClose: () => void
/** دکمه‌های پایهٔ کارت — هر نما اکشن‌های خودش را می‌دهد (عمودی روی هم) */
children: ReactNode
}
/**
* کارت پاپ‌آپ پروژه به سبک Zillow — اسلایدر لبه‌تا‌لبه در بالاترین بخش، سپس
* عنوان و توضیح، و در پایه دکمه‌های عمودی. راست‌به‌چپ، شیشه‌ای تیره و
* هم‌اندازهٔ کارت‌های سایت. سال پروژه عمداً نمایش داده نمی‌شود.
*/
export function ProjectPopupCard({ project, images = [], onClose, children }: ProjectPopupCardProps) {
const { t } = useLang()
const description = project.description.trim() || t('mapProjectDescription')
return (
<article
dir="rtl"
className="w-[17rem] max-w-[calc(100vw-2.5rem)] animate-[luxPopupIn_0.22s_ease-out] overflow-hidden rounded-2xl border border-ivory/10 bg-[#27221c]/95 font-pinar text-ivory shadow-[0_24px_60px_-15px_rgba(0,0,0,0.9)] backdrop-blur-xl sm:w-72"
>
{/* اسلایدر تصویر: لبه‌تا‌لبه، بدون حاشیه، با فلش‌های چپ/راست */}
<div className="relative">
<ProjectImageSlider images={images} alt={project.name} />
<button
type="button"
onClick={onClose}
aria-label={t('close')}
title={t('close')}
className="absolute left-2.5 top-2.5 z-20 flex h-8 w-8 items-center justify-center rounded-full border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:border-copper hover:bg-copper hover:text-char"
>
<MdClose className="h-4 w-4" />
</button>
{project.isNew && (
<span className="absolute right-2.5 top-2.5 z-20 rounded-full bg-copper px-2.5 py-1 text-[10px] font-bold text-char shadow-[0_4px_14px_rgba(213,135,76,0.4)]">
{t('mapNewBadge')}
</span>
)}
</div>
<div className="px-4 pb-1 pt-3.5">
<h3 className="min-w-0 truncate text-base font-bold text-ivory">{project.name}</h3>
<p className="mt-1.5 line-clamp-2 text-[11px] font-light leading-6 text-ivory/60">{description}</p>
</div>
<div className="flex flex-col gap-2 p-3 pt-2.5">{children}</div>
</article>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { createPortal } from 'react-dom'
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { MdArrowBack, MdOpenInNew } from 'react-icons/md'
import {
ISFAHAN_CENTER,
MAP_PROJECTS,
boundsForProjects,
buildNeshanUrl,
type MapProject,
} from '../data/mapProjects'
import { useLang } from '../i18n/lang'
import { MapViewport } from './MapViewport'
import { POPUP_PRIMARY_CLASS, POPUP_SECONDARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
import { useMapLibre } from './useMapLibre'
import { useProjectMarkers } from './useProjectMarkers'
export type ProjectsMapAllProps = {
/** ارتفاع/فاصله‌ها از بیرون — مثل «h-[520px]» */
className?: string
/** فهرست پروژه‌ها — پیش‌فرض همه‌ی ردیف‌های شیت */
projects?: MapProject[]
/**
* نشانی صفحه‌ی جزئیات هر پروژه. اگر تطبیقی پیدا نشود، دکمه‌ی «مشاهده پروژه»
* به فهرست پروژه‌ها می‌رود تا هیچ لینکی بی‌مقصد نماند.
*/
resolveProjectHref?: (project: MapProject) => string | undefined
/** آرایهٔ تصاویر هر پروژه (همان آرایهٔ صفحهٔ جزئیات) — در نبودش جای‌نگهدار */
imagesFor?: (project: MapProject) => string[]
}
/**
* نمای «همه‌ی پروژه‌ها» — همه‌ی مختصات شیت روی یک نقشه‌ی تمام‌عرض با
* سنجاق‌های مسی، پاپ‌آپ کارتی و دو اکشن عمودی: صفحه‌ی پروژه و آدرس نشان.
* پاپ‌آپ فقط با کلیک کاربر باز می‌شود و جابه‌جایی نقشه به محدودهٔ پروژه‌ها
* بسته است.
*/
export function ProjectsMapAll({
className = '',
projects = MAP_PROJECTS,
resolveProjectHref,
imagesFor,
}: ProjectsMapAllProps) {
const { t, href } = useLang()
const bounds = useMemo(() => boundsForProjects(projects), [projects])
const { containerRef, map, status, retry } = useMapLibre({ center: ISFAHAN_CENTER, zoom: 12, maxBounds: bounds })
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
const projectHref = href((activeProject && resolveProjectHref?.(activeProject)) || '/projects')
const images = activeProject ? (imagesFor?.(activeProject) ?? []) : []
return (
<MapViewport
containerRef={containerRef}
map={map}
status={status}
onRetry={retry}
label={t('mapAria')}
className={className}
>
{popupHost &&
activeProject &&
createPortal(
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
<Link to={projectHref} className={POPUP_PRIMARY_CLASS}>
{t('viewProject')}
<MdArrowBack className="h-4 w-4" aria-hidden="true" />
</Link>
{/* منطق دکمهٔ آدرس دست‌نخورده — فقط در ستون عمودی نشسته است */}
<a
href={buildNeshanUrl(activeProject)}
target="_blank"
rel="noopener noreferrer"
className={POPUP_SECONDARY_CLASS}
>
{t('viewAddress')}
<MdOpenInNew className="h-3.5 w-3.5" aria-hidden="true" />
</a>
</ProjectPopupCard>,
popupHost,
)}
</MapViewport>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { useMemo } from 'react'
import { createPortal } from 'react-dom'
import { MdOpenInNew } from 'react-icons/md'
import { boundsForProjects, buildNeshanUrl, type MapProject } from '../data/mapProjects'
import { useLang } from '../i18n/lang'
import { MapViewport } from './MapViewport'
import { POPUP_PRIMARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
import { useMapLibre } from './useMapLibre'
import { useProjectMarkers } from './useProjectMarkers'
export type ProjectsMapSingleProps = {
/** همان پروژه‌ی شیت — یا محل جایگزین ساخته‌شده با createPlaceholderProject */
location: MapProject
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات (تصویر شاخص + گالری) */
images?: string[]
/** ارتفاع/فاصله‌ها از بیرون — مثل «h-80» */
className?: string
}
/**
* نمای «تک‌پروژه» — یک سنجاق دقیق روی مختصات همان پروژه و پاپ‌آپی که فقط با
* کلیک کاربر باز می‌شود. تنها اکشن کارت، مشاهدهٔ آدرس پروژه در نشان است.
* جابه‌جایی نقشه به محدودهٔ همین پروژه بسته شده تا به بیرون نرود.
*/
export function ProjectsMapSingle({ location, images = [], className = '' }: ProjectsMapSingleProps) {
const { t } = useLang()
const projects = useMemo(() => [location], [location])
const bounds = useMemo(() => boundsForProjects(projects, 0.008), [projects])
const { containerRef, map, status, retry } = useMapLibre({
center: [location.lng, location.lat],
zoom: 15,
maxBounds: bounds,
})
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
return (
<MapViewport
containerRef={containerRef}
map={map}
status={status}
onRetry={retry}
label={t('mapAria')}
className={className}
>
{popupHost &&
activeProject &&
createPortal(
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
{/* منطق دکمهٔ آدرس دست‌نخورده است — تنها اکشن این کارت */}
<a
href={buildNeshanUrl(activeProject)}
target="_blank"
rel="noopener noreferrer"
className={POPUP_PRIMARY_CLASS}
>
{t('viewAddress')}
<MdOpenInNew className="h-4 w-4" aria-hidden="true" />
</a>
</ProjectPopupCard>,
popupHost,
)}
</MapViewport>
)
}
+35
View File
@@ -0,0 +1,35 @@
import * as maplibregl from 'maplibre-gl'
import { Protocol } from 'pmtiles'
import maplibreWorkerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
let setup: Promise<void> | null = null
/**
* آماده‌سازی وب‌ورکر نقشه — یک‌بار برای همه نقشه‌ها.
*
* ۱) نشانی وب‌ورکر مپ‌لیبر صریح تعیین می‌شود: مپ‌لیبر به‌صورت پیش‌فرض
* «./maplibre-gl-worker.mjs» را کنار باندل جست‌وجو می‌کند که در خروجی ویت
* وجود ندارد و ورکر ۴۰۴ می‌شود؛ نسخه‌ی ساخته‌شده‌ی ویت را با setWorkerUrl
* می‌دهیم (نشانی مطلق، چون پایه‌ی ورکر می‌تواند Blob باشد).
* ۲) پروتکل pmtiles در نخ اصلی ثبت می‌شود.
* ۳) همان پروتکل با importScriptInWorkers داخل وب‌ورکر هم ثبت می‌شود —
* کاشی‌های برداری آنجا بارگیری می‌شوند و رجیستری پروتکل در هر کانتکست
* جداست. اسکریپت وب‌ورکر، خروجی خودبسنده‌ی public/map/pmtiles-worker.mjs
* است (ساخته‌شده از src/map/pmtiles.worker.ts با npm run build:map-worker).
*
* تا پایان بارگیری اسکریپت صبر می‌کنیم تا نخستین درخواست‌های کاشی در وب‌ورکر
* بی‌پاسخ نمانند.
*/
export function ensureMapWorker(): Promise<void> {
if (!setup) {
maplibregl.setWorkerUrl(new URL(maplibreWorkerUrl, window.location.href).href)
maplibregl.addProtocol('pmtiles', new Protocol().tile)
const protocolScript = new URL(
`${import.meta.env.BASE_URL}map/pmtiles-worker.mjs`,
window.location.href,
).href
setup = maplibregl.importScriptInWorkers(protocolScript)
}
return setup
}
+15
View File
@@ -0,0 +1,15 @@
import { Protocol } from 'pmtiles'
/**
* اسکریپت سمت وبورکر مپ‌لیبر — با importScriptInWorkers داخل وبورکر اجرا
* می‌شود و مپ‌لیبر تابع addProtocol را روی self وبورکر گذاشته است.
*
* کاشی‌های برداری در وبورکر بارگیری می‌شوند و رجیستری پروتکل در هر کانتکست
* جداست؛ بدون این ثبت، درخواست‌های pmtiles در وبورکر بی‌پاسخ می‌مانند و
* نقشه فقط پس‌زمینه را نشان می‌دهد.
*/
const workerScope = self as unknown as {
addProtocol: (name: string, loadFn: unknown) => void
}
workerScope.addProtocol('pmtiles', new Protocol().tile)
+141
View File
@@ -0,0 +1,141 @@
import type { SourceSpecification, StyleSpecification } from 'maplibre-gl'
import { MAP_BASE_URL } from '../config'
const PMTILES_SCHEME = 'pmtiles://'
/** نشانی‌های مطلق — دست‌نخورده می‌مانند */
const EXTERNAL = /^(https?:|data:|blob:)/i
/** تنها آرشیو برداری باکت — منبع برداری سبک همیشه به همین جا می‌رسد */
const PMTILES_ARCHIVE = 'iran.pmtiles'
/** نام منبع برداری در سبک‌های استاندارد OpenMapTiles */
const OPENMAPTILES_KEY = 'openmaptiles'
/** پوشه فونت‌های گلیف در باکت MinIO — سبک خام فقط «{fontstack}/{range}.pbf» دارد */
const GLYPHS_FOLDER = 'v2.0'
function joinUrl(base: string, path: string): string {
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
}
/** نشانی آرشیو برداری روی MinIO — تنها منبع کاشی این پروژه */
function archiveUrl(base: string): string {
return `${PMTILES_SCHEME}${joinUrl(base, PMTILES_ARCHIVE)}`
}
/**
* جای‌گذاری جانگهدارهای سبک — «{styleJsonFolder}» به آدرس پایه MinIO تبدیل
* می‌شود. جانگهدارهای {fontstack} و {range} قانونی خود مپ‌لیبرند و دست‌نخورده
* می‌مانند. جانگهدار تازه‌ای در سبک‌های بعدی دیده شد، به همین نگاشت اضافه شود.
*/
function resolvePlaceholders(url: string, base: string): string {
return url.replace(/\{styleJsonFolder\}/g, base)
}
/** مطلق‌سازی نشانی‌های عمومی (گلیف، اسپرایت، منابع غیربرداری) */
function absolutize(resource: string, base: string): string {
const url = resolvePlaceholders(resource.trim(), base)
if (EXTERNAL.test(url)) return url
if (url.startsWith(PMTILES_SCHEME)) {
const target = url.slice(PMTILES_SCHEME.length).trim()
return EXTERNAL.test(target) ? url : PMTILES_SCHEME + joinUrl(base, target)
}
return joinUrl(base, url)
}
/**
* نشانی گلیف‌ها — سبک خام فقط الگوی «{fontstack}/{range}.pbf» را دارد و پوشه
* فونت‌ها را نمی‌آورد، اما فونت‌های باکت زیر پوشه v2.0 هستند؛ پس الگو همیشه
* به همان پوشه می‌رسد و جانگهدارهای {fontstack} و {range} دست‌نخورده می‌مانند.
*/
function resolveGlyphsUrl(url: string, base: string): string {
const resolved = resolvePlaceholders(url.trim(), base)
if (EXTERNAL.test(resolved)) return resolved
if (resolved.includes('{fontstack}')) {
const template = resolved.slice(resolved.indexOf('{fontstack}'))
return joinUrl(base, joinUrl(GLYPHS_FOLDER, template))
}
return joinUrl(base, resolved)
}
/** منبع برداری سبک — با نوع vector یا نام شناخته‌شده openmaptiles */
function isVectorSource(key: string, source: SourceSpecification): boolean {
return source.type === 'vector' || key === OPENMAPTILES_KEY
}
/**
* بازنویسی نشانی منبع برداری. سبک‌های ساخته‌شده برای سرور MBTiles (مثل
* «mbtiles://{v3}» در tileserver-gl) کاشی برداری را با طرح mbtiles آدرس
* می‌دهند که مپ‌لیبر نمی‌شناسد؛ هر شکلی از نشانی اینجا به آرشیو iran.pmtiles
* روی MinIO هدایت می‌شود تا با پروتکل pmtiles رندر شود.
*/
function resolveVectorUrl(url: string, base: string): string {
const resolved = resolvePlaceholders(url.trim(), base)
if (resolved.startsWith(PMTILES_SCHEME)) {
const target = resolved.slice(PMTILES_SCHEME.length).trim()
return EXTERNAL.test(target) ? resolved : PMTILES_SCHEME + joinUrl(base, target)
}
return archiveUrl(base)
}
/**
* پالایش سبک نقشه پیش از تحویل به مپ‌لیبر — style.json خام MinIO با
* جانگهدار ({styleJsonFolder}) و نشانی MBTiles نوشته شده است؛ این تابع با یک
* پاس، گلیف/اسپرایت را به آدرس مطلق MinIO و منبع برداری را به آرشیو
* iran.pmtiles تبدیل می‌کند تا مستقیم به «new Map({ style })» داده شود.
*/
export function rewriteStyleUrls(
style: StyleSpecification,
base: string = MAP_BASE_URL,
): StyleSpecification {
const next: StyleSpecification = { ...style }
if (typeof next.glyphs === 'string') {
next.glyphs = resolveGlyphsUrl(next.glyphs, base)
}
// اسپرایت در نسخه ۸ مشخصات سبک، رشته یا فهرست {id, url} است
if (typeof next.sprite === 'string') {
next.sprite = absolutize(next.sprite, base)
} else if (Array.isArray(next.sprite)) {
next.sprite = next.sprite.map((sprite) => ({ ...sprite, url: absolutize(sprite.url, base) }))
}
const sources: StyleSpecification['sources'] = {}
for (const [key, source] of Object.entries(next.sources ?? {})) {
const rewritten = { ...source } as SourceSpecification & { url?: unknown; tiles?: unknown }
if (typeof rewritten.url === 'string') {
const raw = rewritten.url.trim()
if (isVectorSource(key, source)) {
rewritten.url = resolveVectorUrl(raw, base)
} else {
// رندر سخت‌گیرانه‌ی برداری: منابع غیربرداری (مثل raster) هرگز به آرشیو
// برداری هدایت نمی‌شوند — کاشی MVT زیر منبع raster یعنی خروجی خراب.
if (import.meta.env.DEV && source.type === 'raster') {
console.warn(`[map] منبع غیربرداری «${key}» در سبک هست؛ افزایش رزولوشن برداری برای آن اعمال نمی‌شود`)
}
rewritten.url = absolutize(raw, base)
}
}
if (Array.isArray(rewritten.tiles)) {
rewritten.tiles = rewritten.tiles.map((tile) =>
typeof tile === 'string' ? absolutize(tile, base) : tile,
)
}
sources[key] = rewritten
}
next.sources = sources
return next
}
/** بارگیری style.json از MinIO و بازگرداندن سبک پالایش‌شده برای «new Map({ style })» */
export async function loadMapStyle(signal?: AbortSignal): Promise<StyleSpecification> {
const res = await fetch(joinUrl(MAP_BASE_URL, 'style.json'), { signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return rewriteStyleUrls((await res.json()) as StyleSpecification)
}
+41
View File
@@ -0,0 +1,41 @@
import type { RequestTransformFunction } from 'maplibre-gl'
/** دو بخش آخر مسیر گلیف: پوشه‌ی پشته‌ی فونت و فایل بازه */
const GLYPH_PATH = /\/([^/?#]+)\/([^/?#]+\.pbf)$/
/**
* نشانی گلیف با تنها فونت نخستِ پشته.
*
* مپ‌لیبر برای هر لایه، تمام `text-font` را با کاما در {fontstack} می‌گذارد
* (مثل «Metropolis Regular,Noto Sans Regular»)؛ سرورهای عمومی فونت، پوشه‌ی
* همین پشته‌ی ترکیبی را دارند اما MinIO این پروژه فونت‌ها را در پوشه‌های
* تک‌فونتی نگه می‌دارد. نتیجه‌ی آن درخواست‌ها ۴۰۴ بود، متن‌ها رندر نمی‌شدند و
* سیل خطا اپ را قفل می‌کرد. با این بازنویسی فقط فونت نخست — همان ترجیح اول
* سبک — درخواست می‌شود و بقیه‌ی مسیر (پوشه‌ی v2.0 و فایل بازه) دست‌نخورده
* می‌ماند. نشانی بدون پشته‌ی ترکیبی یا ناشناس، بدون تغییر برمی‌گردد.
*/
export function singleFontGlyphUrl(url: string): string {
const queryIndex = url.search(/[?#]/)
const path = queryIndex === -1 ? url : url.slice(0, queryIndex)
const suffix = queryIndex === -1 ? '' : url.slice(queryIndex)
const match = path.match(GLYPH_PATH)
if (!match) return url
let fonts: string[]
try {
fonts = match[1].split(',').map((font) => decodeURIComponent(font).trim())
} catch {
return url
}
if (fonts.length < 2 || !fonts[0]) return url
return `${path.slice(0, match.index)}/${encodeURIComponent(fonts[0])}/${match[2]}${suffix}`
}
/**
* قلاب تبدیل درخواست‌های نقشه — فقط گلیف‌ها بازنویسی می‌شوند و هر درخواست
* دیگری دست‌نخورده می‌ماند تا رفتار پیش‌فرض مپ‌لیبر حفظ شود.
*/
export const mapTransformRequest: RequestTransformFunction = (url, resourceType) =>
resourceType === 'Glyphs' ? { url: singleFontGlyphUrl(url) } : { url }
+2
View File
@@ -0,0 +1,2 @@
/** وضعیت چرخه‌ی حیات نقشه — بارگذاری سبک، آماده یا خطای قابل تلاش دوباره */
export type MapStatus = 'loading' | 'ready' | 'error'
+192
View File
@@ -0,0 +1,192 @@
import * as maplibregl from 'maplibre-gl'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ensureMapWorker } from './mapWorker'
import { loadMapStyle } from './styleLoader'
import { mapTransformRequest } from './transformRequest'
import type { MapStatus } from './types'
type UseMapLibreOptions = {
center: [number, number]
zoom: number
/** بزرگ‌نمایی با چرخ ماوس و پینچ لمسی — پیش‌فرض فعال */
scrollZoom?: boolean
/** محدودهٔ مجاز جابه‌جایی نقشه — جلوگیری از رفتن به بیرون محدودهٔ پروژه‌ها */
maxBounds?: maplibregl.LngLatBoundsLike
}
/** سقف انتظار سبک نقشه — سرور کاشی بی‌پاسخ یعنی خطای قابل تلاش دوباره، نه اسپینر بی‌پایان */
const STYLE_TIMEOUT_MS = 10_000
/** اگر رویداد style.load دیر برسد، وضعیت آماده با این مهلت نمایش داده می‌شود */
const READY_FALLBACK_MS = 2_000
/**
* سقف بزرگ‌نمایی = maxzoom آرشیو + همین مقدار سطح overzoom.
*
* سنجش روی تایل‌های واقعی z14 اصفهان (۲۶٫۸۷۴ پاره‌خط جاده): میانه‌ی وتر ۲۰
* متر و صدک ۷۵ برابر ۳۶ متر است. با سه سطح overzoom (زوم ۱۷، ۰٫۵ متر بر
* پیکسل) میانه ۴۰ و صدک ۷۵ حدود ۷۲ پیکسل می‌شود که خم‌ها را هنوز صاف نشان
* می‌دهد؛ از ۱۷٫۵ به بعد وترها به ۱۰۰ پیکسل و بیشتر می‌رسند و چندضلعی‌شدن
* خم‌ها دیده می‌شود. متن و آیکون هرگز تار نمی‌شوند (متن SDF و آیکون اسپرایت
* است)؛ افت کیفیت فقط از هندسه‌ی ساده‌شده می‌آید. با بازتولید آرشیو در z16
* این سقف خودکار به ۱۹ می‌رسد.
*/
const OVERZOOM_LEVELS = 3
/** سقف مطلق زوم — بالاتر از آن حتی با داده‌ی دقیق‌تر هم نمایش مرورگر بی‌معناست */
const ABSOLUTE_MAX_ZOOM = 22
/** سقف جایگزین وقتی maxzoom منبع در دسترس نیست — برابر آرشیو فعلی (z14 + ۳) */
const FALLBACK_MAX_ZOOM = 17
export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseMapLibreOptions) {
const containerRef = useRef<HTMLDivElement | null>(null)
const [map, setMap] = useState<maplibregl.Map | null>(null)
const [status, setStatus] = useState<MapStatus>('loading')
const [reloadKey, setReloadKey] = useState(0)
useEffect(() => {
const container = containerRef.current
if (!container) return
let disposed = false
let instance: maplibregl.Map | null = null
let readyFallback: number | undefined
let dprQuery: MediaQueryList | null = null
let handleDprChange: (() => void) | null = null
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), STYLE_TIMEOUT_MS)
setStatus('loading')
setMap(null)
// وب‌ورکر نقشه و پروتکل pmtiles باید پیش از ساخت نقشه آماده باشند؛
// وگرنه نخستین درخواست‌های کاشی در وب‌ورکر بی‌پاسخ می‌مانند
Promise.all([loadMapStyle(controller.signal), ensureMapWorker()])
.then(([style]) => {
if (disposed) return
instance = new maplibregl.Map({
container,
style,
center,
zoom,
maxBounds,
// سقف اولیه تا رسیدن maxzoom واقعی منبع؛ سپس applyZoomLimit دقیق می‌کند
maxZoom: FALLBACK_MAX_ZOOM,
renderWorldCopies: false,
// لبه‌های نرم‌تر خطوط و چندضلعی‌ها — حس لوکس رندر برداری
canvasContextAttributes: { antialias: true },
// کیفیت حداکثری در بزرگ‌نمایی‌های عمیق: هیچ سطحی با مقیاس‌کردن سطح
// بالاتر رندر نمی‌شود و همه‌ی سطح‌ها تا maxZoom از تقسیم هندسه
// می‌آیند (پیش‌فرض ۴ سطح آخر را overscale می‌کند). مبادله: کار
// بیشتر پردازنده در زوم‌های عمیق.
zoomLevelsToOverscale: 0,
// چرخ ماوس، پینچ لمسی و دکمه‌های کنترل — مطابق درخواست کاربر
scrollZoom,
attributionControl: { compact: true },
// MinIO فونت‌ها را در پوشه‌های تک‌فونتی دارد، پس پشته‌ی ترکیبی
// text-font («Metropolis Regular,Noto Sans Regular») به فونت نخست
// بازنویسی می‌شود — توضیح کامل در transformRequest.ts
transformRequest: mapTransformRequest,
})
/**
* سقف بزرگ‌نمایی را از maxzoom واقعی آرشیو می‌سازد؛ با داده‌ی دقیق‌تر
* (بازتولید آرشیو در z بالاتر) خودکار بالاتر می‌رود و کیفیت روی صفحه
* ثابت می‌ماند. زوم جاری هم اگر از سقف بیشتر باشد به آن محدود می‌شود.
*/
const applyZoomLimit = () => {
if (!instance) return
const source = instance.getSource('openmaptiles') as { maxzoom?: number } | undefined
const sourceMaxZoom = typeof source?.maxzoom === 'number' ? source.maxzoom : null
instance.setMaxZoom(
sourceMaxZoom === null
? FALLBACK_MAX_ZOOM
: Math.min(sourceMaxZoom + OVERZOOM_LEVELS, ABSOLUTE_MAX_ZOOM),
)
}
instance.on('style.load', () => {
if (disposed) return
setStatus('ready')
window.setTimeout(() => instance?.resize(), 150)
})
/**
* کنترل بزرگ‌نمایی همراه خود نقشه ساخته می‌شود و با map.remove() پایین
* می‌آید — map.remove به‌تنهایی onRemove همه‌ی کنترل‌ها را صدا می‌زند،
* پس removeControl دستی پس از آن، onRemove را دوباره اجرا می‌کند و با
* «reading 'off' of undefined» کرش می‌کند. مالکیت کنترل همین‌جا، کنار
* مالکیت نقشه می‌ماند تا ترتیب پاک‌سازی هیچ‌وقت به React وابسته نباشد.
*/
instance.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), 'top-right')
// maxzoom منبع همراه متادیتای TileJSON می‌رسد — همان لحظه سقف را قطعی کن
instance.on('sourcedata', (e) => {
if (e.sourceDataType === 'metadata' && e.sourceId === 'openmaptiles') applyZoomLimit()
})
instance.on('load', applyZoomLimit)
applyZoomLimit()
// تایمر فالبک امنیتی
readyFallback = window.setTimeout(() => {
if (disposed) return
setStatus('ready')
instance?.resize()
}, READY_FALLBACK_MS)
instance.on('error', (e) => {
console.warn('MapLibre minor error:', e)
})
/**
* نسبت پیکسل دستگاه منبع حقیقت بافر بوم است؛ با زوم مرورگر
* (Ctrl+zoom) یا جابه‌جایی پنجره روی مانیتور با چگالی دیگر عوض
* می‌شود. اگر بافر با نسبت تازه هم‌گام نشود، نقشه تا رفرش بعدی تار
* می‌ماند. matchMedia هر تغییر را دقیقاً همان لحظه گزارش می‌دهد و
* پس از هر تغییر دوباره مسلح می‌شود.
*/
function syncPixelRatio() {
if (!instance) return
if (instance.getPixelRatio() !== window.devicePixelRatio) {
instance.setPixelRatio(window.devicePixelRatio)
}
instance.resize()
}
function watchPixelRatio() {
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
handleDprChange = () => {
syncPixelRatio()
watchPixelRatio()
}
dprQuery.addEventListener('change', handleDprChange)
}
watchPixelRatio()
// دسترسی اشکال‌زدایی در محیط توسعه — در بیلد تولیدی وجود ندارد
if (import.meta.env.DEV) {
(window as unknown as { __map?: maplibregl.Map }).__map = instance
}
setMap(instance)
})
.catch(() => {
if (!disposed) setStatus('error')
})
return () => {
disposed = true
window.clearTimeout(timeoutId)
if (readyFallback !== undefined) window.clearTimeout(readyFallback)
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
controller.abort()
instance?.remove()
}
// مرکز و بزرگ‌نمایی فقط موقعیت اولیه نقشه‌اند؛ فهرست وابستگی عمداً فقط reloadKey است
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reloadKey])
const retry = useCallback(() => setReloadKey((key) => key + 1), [])
return { containerRef, map, status, retry }
}
+156
View File
@@ -0,0 +1,156 @@
import * as maplibregl from 'maplibre-gl'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { MapProject } from '../data/mapProjects'
import { ProjectPin } from './ProjectPin'
type PinHandle = { project: MapProject; marker: maplibregl.Marker; root: Root }
/** حاشیهٔ امن پاپ‌آپ از لبه‌های نقشه — بالای صفحه بازتر، برای نوار برچسب و کنترل‌ها */
const AUTO_PAN_PADDING = { top: 150, bottom: 20, left: 20, right: 20 }
/**
* نگه‌داشتن پاپ‌آپ داخل کادر دید — معادل رفتار autoPan که مپ‌لیبر (برخلاف
* مپ‌باکس) گزینه‌اش را ندارد: اگر کارت از حاشیهٔ امن بیرون بزند، نقشه نرم
* جابه‌جا می‌شود تا کارت کامل دیده شود.
*/
function ensurePopupVisible(instance: maplibregl.Map, popup: maplibregl.Popup): void {
const element = popup.getElement()
const container = instance.getContainer()
if (!element || !container) return
// چیدمان پاپ‌آپ در همان فریم addTo کامل می‌شود، پس اندازه‌گیری به فریم بعد موکول است
window.requestAnimationFrame(() => {
const popupRect = element.getBoundingClientRect()
const viewRect = container.getBoundingClientRect()
const excessTop = viewRect.top + AUTO_PAN_PADDING.top - popupRect.top
const excessBottom = popupRect.bottom - (viewRect.bottom - AUTO_PAN_PADDING.bottom)
const excessLeft = viewRect.left + AUTO_PAN_PADDING.left - popupRect.left
const excessRight = popupRect.right - (viewRect.right - AUTO_PAN_PADDING.right)
// کمبود افقی و عمودی؛ دوربین به اندازهٔ بیرون‌زدگی جابه‌جا می‌شود
const dx = Math.max(0, excessLeft) - Math.max(0, excessRight)
const dy = Math.max(0, excessTop) - Math.max(0, excessBottom)
if (!dx && !dy) return
instance.panBy([-dx, -dy], { duration: 320 })
})
}
/**
* لایه‌ی نشانگرهای پروژه — ساخت نشانگرها با React (هر نشانگر یک ریشه‌ی
* کوچک)، یک پاپ‌آپ مشترک برای همه، و وضعیت پروژه‌ی فعال.
*
* پاپ‌آپ هیچ‌وقت خودکار باز نمی‌شود؛ فقط با کلیک کاربر روی سنجاق. پاپ‌آپ یک
* المان میزبان دارد که بیرون از React ساخته می‌شود؛ مصرف‌کننده محتوای React را
* با createPortal داخل همان میزبان می‌ریزد تا Link روتر، حالت هاور و
* انیمیشن‌ها همه در درخت React بمانند.
*/
export function useProjectMarkers(map: maplibregl.Map | null, projects: MapProject[]) {
const [popupHost, setPopupHost] = useState<HTMLDivElement | null>(null)
const [activeProject, setActiveProject] = useState<MapProject | null>(null)
const popupRef = useRef<maplibregl.Popup | null>(null)
const pinsRef = useRef<PinHandle[]>([])
const activeRef = useRef<MapProject | null>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
mapRef.current = map
const openProject = useCallback((project: MapProject) => {
const popup = popupRef.current
const instance = mapRef.current
if (!popup || !instance) return
activeRef.current = project
popup.setLngLat([project.lng, project.lat]).addTo(instance)
ensurePopupVisible(instance, popup)
setActiveProject(project)
}, [])
const closePopup = useCallback(() => {
activeRef.current = null
const popup = popupRef.current
// remove() فقط روی پاپ‌آپ باز صدا زده می‌شود؛ پاپ‌آپ بسته مالک مرورگر است
if (popup?.isOpen()) popup.remove()
setActiveProject(null)
}, [])
/** پاپ‌آپ مشترک + المان میزبان محتوا — یک‌بار برای هر نمونه نقشه */
useEffect(() => {
if (!map) return
const host = document.createElement('div')
const popup = new maplibregl.Popup({
closeButton: false,
closeOnClick: true,
offset: 46,
maxWidth: 'none',
className: 'lux-map-popup',
}).setDOMContent(host)
// بستن با کلیک روی نقشه یا دکمه‌ی کارت، وضعیت فعال را هم پاک می‌کند
popup.on('close', () => {
activeRef.current = null
setActiveProject(null)
})
popupRef.current = popup
setPopupHost(host)
return () => {
// فقط پاپ‌آپِ باز پایین آورده می‌شود؛ popup.remove خودش هم به رویداد
// remove نقشه گوش می‌دهد، پس این پاک‌سازی تکراری و بی‌خطر است
if (popup.isOpen()) popup.remove()
popupRef.current = null
setPopupHost(null)
}
}, [map])
/** نشانگرها — با تغییر فهرست پروژه‌ها یا نمونه‌ی نقشه بازساخته می‌شوند */
useEffect(() => {
if (!map) return
const pins: PinHandle[] = []
for (const project of projects) {
const element = document.createElement('div')
const marker = new maplibregl.Marker({ element, anchor: 'bottom' })
.setLngLat([project.lng, project.lat])
.addTo(map)
const root = createRoot(element)
root.render(<ProjectPin label={project.name} active={false} onClick={() => openProject(project)} />)
pins.push({ project, marker, root })
}
pinsRef.current = pins
return () => {
for (const pin of pins) {
// نشانگر خودش پاک‌سازی امن دارد (حذف شنونده‌ها + برداشتن المان)
pin.marker.remove()
/**
* unmount ریشه‌ی React هنگام جدا شدن والد، وسط چرخه‌ی رندر رخ می‌دهد و
* React 18 برای unmount همگام ریشه‌ی تودرتو هشدار می‌دهد
* («Attempted to synchronously unmount a root while React was already
* rendering»). پس به میکروتسک بعد از پایان کامیت موکول می‌شود؛ المان
* نشانگر تا آن لحظه از DOM جدا شده و unmount روی ظرف جدا امن است.
*/
queueMicrotask(() => pin.root.unmount())
}
pinsRef.current = []
}
}, [map, projects, openProject])
/** با تغییر پروژه‌ی فعال، فقط هاله‌ی سنجاق‌ها به‌روز می‌شود (بازسازی نمی‌شوند) */
useEffect(() => {
for (const pin of pinsRef.current) {
pin.root.render(
<ProjectPin
label={pin.project.name}
active={activeProject?.id === pin.project.id}
onClick={() => openProject(pin.project)}
/>,
)
}
}, [activeProject, openProject])
return { popupHost, activeProject, openProject, closePopup }
}
+4 -5
View File
@@ -1,9 +1,9 @@
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { renderToHTMLString } from '@tiptap/static-renderer';
import { Reveal } from '../components/Reveal';
import { ButtonLink } from '../components/Button';
import { PremiumButton } from '../components/PremiumButton';
import { ArticleCard } from '../components/ArticleCard';
import { ArticleMeta } from '../components/ArticleMeta';
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
@@ -74,10 +74,9 @@ export function ArticleDetail() {
<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('articleNotFound')}</h1>
<p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p>
<ButtonLink to={href('/blog')} variant="copper" className="mt-8">
<PremiumButton to={href('/blog')} className="mt-8">
{t('backToBlog')}
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</PremiumButton>
</section>);
}
+2 -19
View File
@@ -14,25 +14,8 @@ export function ContactPage() {
<>
<PageHero pageKey="contact" />
<section className="bg-ink py-24" aria-label={t('officesAria')}>
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
{offices.map((office, i) => (
<Reveal key={office.id} delay={0.06 * i} className="h-full">
<EditableItem resource="offices" item={office} className="h-full">
<article className="flex h-full flex-col border border-ivory/10 bg-char px-8 py-12 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
<h2 className="text-2xl font-bold text-copper">{office.city}</h2>
<p className="mt-6 text-sm font-light leading-9 text-ivory/70">{office.address}</p>
<div className="mt-auto pt-10">
<p className="border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
{office.hours}
</p>
</div>
</article>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="offices" />
</div>
<section className="bg-ink py-2" aria-label={t('officesAria')}>
</section>
<ContactSection />
+1 -1
View File
@@ -19,8 +19,8 @@ export function Home() {
return (
<>
<Hero />
<Story />
<Stats />
<Story />
<Services />
<BeforeAfter />
<FeaturedProjects />
+145 -109
View File
@@ -1,74 +1,98 @@
import React, { useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'
import { ChevronLeftIcon } from 'lucide-react'
import { Reveal } from '../components/Reveal'
import { ButtonLink } from '../components/Button'
import { PremiumButton } from '../components/PremiumButton'
import { ProjectCard } from '../components/ProjectCard'
import { ContactSection } from '../components/ContactSection'
import { Lightbox } from '../components/Lightbox'
import { useEffect } from 'react';
import { LazyProjectsMapSingle } from '../map/LazyMaps';
import { createPlaceholderProject, findMapProjectByName } from '../data/mapProjects'
import { useSite, useStatusLabel } from '../api';
import { useLang } from '../i18n/lang';
import { applySeo } from '../seo';
import { projectsData } from '../data/projectsData';
export function ProjectDetail() {
const { slug } = useParams<{ slug: string }>()
const { projects, brand } = useSite()
const { t, href, lang } = useLang()
const project = projects.find((p) => p.slug === slug)
const statusLabel = useStatusLabel(project?.status ?? '')
const currentProject = projectsData.find((p) => p.slug === slug)
const [lightbox, setLightbox] = useState<number | null>(null)
// عنوان تب، توضیح متا و OG از خود پروژه
useEffect(() => {
if (!project) return;
applySeo({
title: `${project.title} | ${brand.name}`,
description: project.summary,
path: href(`/projects/${project.slug}`),
image: project.image,
lang,
});
}, [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>
)
if (!currentProject) {
return <div className="text-center py-20 text-ivory">Project not found</div>
}
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
const backendProject = projects.find((p) => p.slug === slug)
// Dynamic bilingual text from projectsData
const displayTitle = currentProject.name[lang as 'en' | 'fa'] || currentProject.name.en;
const displayLocation = currentProject.location[lang as 'en' | 'fa'] || currentProject.location.en;
const displayArea = currentProject.area[lang as 'en' | 'fa'] || currentProject.area.en;
const displayStatus = currentProject.status;
const statusLabel = useStatusLabel(displayStatus)
// Fallbacks for layout structure fields missing in projectsData
const summary = backendProject?.summary || (lang === 'en' ? 'A modern and luxurious architectural project built with high-quality materials and smart design.' : 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ.');
const body = backendProject?.body || (lang === 'en'
? ['Located in the heart of the city, this project offers unparalleled access to major urban hubs.', 'It features state-of-the-art facilities, green spaces, and a sustainable approach to modern living.']
: ['لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است.', 'چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است.']);
const gallery = backendProject?.gallery?.length ? backendProject.gallery : [
'/seed-media/2026/09/1.png',
'/seed-media/2026/09/2.png',
'/seed-media/2026/09/3.png',
'/seed-media/2026/09/4.png'
];
const image = backendProject?.image || '/seed-media/2026/09/1.png';
const year = backendProject?.year || (lang === 'en' ? '2024' : '1402');
const floors = backendProject?.floors || '5';
const units = backendProject?.units || '20';
const client = backendProject?.client || (lang === 'en' ? 'Aramesh Group' : 'گروه آرامش');
// تصاویر پاپ‌آپ نقشه
const mapImages = useMemo(
() => [image, ...gallery].filter(Boolean),
[image, gallery]
)
// مختصات همین پروژه
const mapLocation = useMemo(
() => findMapProjectByName(displayTitle) ?? createPlaceholderProject(displayTitle, displayLocation),
[displayTitle, displayLocation]
)
// SEO
useEffect(() => {
applySeo({
title: `${displayTitle} | ${brand.name}`,
description: summary,
path: href(`/projects/${currentProject.slug}`),
image: image,
lang,
});
}, [currentProject.slug, lang, href, displayTitle, summary, image, brand.name]);
const related = projectsData.filter((p) => p.slug !== currentProject.slug).slice(0, 3)
const facts = [
{ label: t('specLocation'), value: project.location },
{ label: t('specYear'), value: project.year },
{ label: t('specArea'), value: project.area },
{ label: t('specFloors'), value: project.floors },
{ label: t('specUnits'), value: project.units },
{ label: t('specClient'), value: project.client },
{ label: t('specLocation'), value: displayLocation },
{ label: t('specYear'), value: year },
{ label: t('specArea'), value: displayArea },
{ label: t('specFloors'), value: floors },
{ label: t('specUnits'), value: units },
{ label: t('specClient'), value: client },
]
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" />
<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">
<section className="bg-char pt-24 pb-12 lg:pt-32 lg:pb-16" aria-label={t('projectDetailsAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10">
<nav aria-label={t('breadcrumbAria')} className="mb-6 flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to={href('/')} className="transition-colors hover:text-copper">
{t('home')}
</Link>
@@ -77,75 +101,80 @@ export function ProjectDetail() {
{t('projects')}
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
<span className="text-copper">{project.title}</span>
<span className="text-copper">{displayTitle}</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>
</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>
<div className="mb-8">
<h1 className="text-4xl lg:text-5xl font-bold text-ivory">{displayTitle}</h1>
</div>
{/* Gallery Desktop: Grid. Mobile: Grid */}
<div className="grid grid-cols-2 gap-2 md:gap-3 rounded-xl lg:rounded-2xl overflow-hidden lg:h-[500px] xl:h-[600px]">
{project.body.map((para) => (
<p key={para} className="mt-5 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
{/* Main large image */}
<button
type="button"
className="col-span-2 lg:col-span-1 h-[250px] md:h-[400px] lg:h-full relative overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-copper group block"
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"
/>
<img src={mapImages[0]} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" />
</button>
<div className="mt-4 grid grid-cols-3 gap-4">
{project.gallery.slice(1).map((img, i) => (
<button
key={img}
{/* 2x2 Small images */}
<div className="col-span-2 lg:col-span-1 grid grid-cols-2 gap-2 md:gap-3 lg:grid-rows-2 lg:h-full">
{mapImages.slice(1, 5).map((img, i) => (
<button
key={img}
type="button"
className={`relative overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-copper group block h-[120px] md:h-[200px] lg:h-full ${i >= 2 ? 'hidden md:block' : ''}`}
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"
/>
<img src={img} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" />
</button>
))}
</div>
</Reveal>
</div>
{/* Smart Content Layout */}
<div className="max-w-4xl mx-auto mt-16 lg:mt-24 flex flex-col gap-16 lg:gap-20">
{/* Description */}
<Reveal>
<span className="flex items-center gap-3 text-sm font-light text-copper mb-6">
<span aria-hidden="true" className="h-px w-8 bg-copper" />
{statusLabel}
</span>
<h2 className="text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
<p className="mt-8 text-xl font-medium leading-9 text-ivory/90">{summary}</p>
{body.map((para) => (
<p key={para} className="mt-6 text-base font-light leading-9 text-ivory/70">
{para}
</p>
))}
</Reveal>
{/* Specifications Table - Perfectly center-aligned */}
<Reveal>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-px border border-ivory/10 bg-ivory/10 rounded-xl overflow-hidden">
{facts.map((f) => (
<div key={f.label} className="bg-ink p-6 md:p-8 flex flex-col items-center justify-center text-center">
<dt className="text-xs font-light text-ivory/50 mb-2">{f.label}</dt>
<dd className="text-base font-bold text-ivory">{f.value}</dd>
</div>
))}
</dl>
</Reveal>
{/* Map (بدون Reveal) */}
{mapLocation && (
<div className="h-80 md:h-[400px] rounded-xl overflow-hidden border border-white/10 shadow-2xl">
<LazyProjectsMapSingle location={mapLocation} images={mapImages} className="h-full w-full" />
</div>
)}
</div>
</div>
</section>
@@ -154,17 +183,24 @@ export function ProjectDetail() {
<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>
))}
{related.map((item, i) => {
const dummyImageName = `${(i % 6) + 1}.png`;
return (
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
<ProjectCard
project={item}
imageUrl={`/seed-media/2026/09/${dummyImageName}`}
detailHref={href(`/projects/${item.slug}`)}
/>
</Reveal>
);
})}
</div>
</div>
</section>
<Lightbox
images={project.gallery}
images={gallery}
index={lightbox}
onClose={() => setLightbox(null)}
onChange={setLightbox}
+93 -20
View File
@@ -1,18 +1,20 @@
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import { PageHero } from '../components/PageHero';
import { Reveal } from '../components/Reveal';
import { ProjectCard } from '../components/ProjectCard';
import { PremiumButton } from '../components/PremiumButton';
import { Gallery } from '../components/home/Gallery';
import { ContactSection } from '../components/ContactSection';
import type { ProjectStatus } from '../data/site';
import { useSite, useStatusLabel } from '../api';
import { useLang } from '../i18n/lang';
import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from '../components/admin/InlineEdit';
import { AddItemButton, EditableItem } from '../admin/Editable';
import { projectsData } from '../data/projectsData';
import { LazyProjectsMapAll } from '../map/LazyMaps';
import { MAP_PROJECTS, type MapProject } from '../data/mapProjects';
export function Projects() {
const { projects, status_labels } = useSite();
const { status_labels } = useSite();
const { t } = useLang();
const save = useFieldSaver();
@@ -27,27 +29,79 @@ export function Projects() {
const doneLabel = useStatusLabel('done');
const progressLabel = useStatusLabel('progress');
const FILTERS: {key: ProjectStatus;label: string;}[] = [
const FILTERS: { key: 'done' | 'progress'; label: string; }[] = [
{ key: 'done', label: doneLabel },
{ key: 'progress', label: progressLabel }
];
const [status, setStatus] = useState<ProjectStatus>('done');
const list = projects.filter((p) => p.status === status);
const [status, setStatus] = useState<'done' | 'progress'>('done');
// Filtering and Pagination State
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const itemsPerPage = 12;
// Filter projects by status
const filteredProjects = useMemo(() => {
return projectsData.filter((p) => p.status === status);
}, [status]);
// Currently displayed projects
const displayedProjects = useMemo(() => {
return filteredProjects.slice(0, page * itemsPerPage);
}, [filteredProjects, page]);
// Handle Tab Switch
const handleTabSwitch = (newStatus: 'done' | 'progress') => {
setStatus(newStatus);
setPage(1); // Reset pagination on tab switch
};
// Handle Load More
const handleLoadMore = () => {
setIsLoading(true);
// Brief loading state
setTimeout(() => {
setPage((prev) => prev + 1);
setIsLoading(false);
}, 400);
};
const BASE_IMG_URL = "/seed-media/2026/09/";
// Resolve a project href from a MapProject entry by matching against projectsData slugs
const resolveProjectHref = (mapProject: MapProject): string | undefined => {
const match = projectsData.find(
(p) => p.name.fa === mapProject.name || p.name.en === mapProject.name
);
return match ? `/projects/${match.slug}` : undefined;
};
return (
<>
<PageHero pageKey="projects" />
{/* نقشهٔ محدود‌شده — همهٔ پروژه‌ها با سنجاق مسی */}
<section className="bg-char py-10" 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">
<LazyProjectsMapAll
projects={MAP_PROJECTS}
resolveProjectHref={resolveProjectHref}
className="h-[350px] md:h-[450px] lg:h-[55vh] max-h-[600px] w-full"
/>
</div>
</div>
</section>
<section className="bg-ink py-24 lg:py-32" aria-label={t('projectsListAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10">
{/* دکمه‌های فیلتر مینیمال و شیشه‌ای (وسط‌چین و بدون کادر) */}
{/* دکمه‌های فیلتر */}
<div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
{FILTERS.map((filter, index) => {
const isSelected = status === filter.key;
return (
<React.Fragment key={filter.key}>
{/* خط جداکننده بین دکمه‌ها */}
{index > 0 && (
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
)}
@@ -56,7 +110,7 @@ export function Projects() {
type="button"
role="tab"
aria-selected={isSelected}
onClick={() => setStatus(filter.key)}
onClick={() => handleTabSwitch(filter.key)}
className={`group relative rounded-lg px-6 py-2.5 text-sm font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md ${
isSelected
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
@@ -76,15 +130,34 @@ export function Projects() {
</div>
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{list.map((project, i) => (
<Reveal key={project.id} delay={0.05 * i} className="h-full">
<EditableItem resource="projects" item={project} className="h-full">
<ProjectCard project={project} imageClass="h-80" />
</EditableItem>
</Reveal>
))}
<AddItemButton resource="projects" />
{displayedProjects.map((project, index) => {
const dummyImageName = `${(index % 6) + 1}.png`;
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
return (
<Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full">
<ProjectCard
project={project}
imageUrl={imageUrl}
detailHref={`/projects/${project.slug}`}
/>
</Reveal>
);
})}
</div>
{/* Load More Button */}
{displayedProjects.length < filteredProjects.length && (
<div className="mt-16 flex justify-center">
<PremiumButton
onClick={handleLoadMore}
disabled={isLoading}
className="px-10 py-4 w-48"
>
{isLoading ? 'در حال بارگذاری...' : 'مشاهده بیشتر'}
</PremiumButton>
</div>
)}
</div>
</section>
@@ -92,4 +165,4 @@ export function Projects() {
<ContactSection />
</>
);
}
}