فرانتاند: نسخه انگلیسی با مسیر /en/ — مسیریابی دوزبانه، دیکشنری رابط، بومیسازی داده، تغییر زبان در هدر، ویرایش انگلیسی در پنل و پشتیبانی LTR
This commit is contained in:
@@ -2,22 +2,24 @@ import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
export function ArticleCard({ article }: { article: Article }) {
|
||||
const { t, href } = useLang()
|
||||
return (
|
||||
<Link
|
||||
to={`/blog/${article.slug}`}
|
||||
to={href(`/blog/${article.slug}`)}
|
||||
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/80 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.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`خواندن مقاله ${article.title}`}
|
||||
aria-label={`${t('readArticlePrefix')} ${article.title}`}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<img
|
||||
src={article.image}
|
||||
alt={`تصویر مقاله ${article.title}`}
|
||||
alt={`${t('articleImagePrefix')} ${article.title}`}
|
||||
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
||||
<div className="absolute right-4 top-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
||||
<div className="absolute top-4 ltr:left-4 rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
||||
{article.category}
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,8 +44,8 @@ export function ArticleCard({ article }: { article: Article }) {
|
||||
|
||||
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
||||
<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)]">
|
||||
ادامه مطلب
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-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" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Swal from 'sweetalert2'
|
||||
import { Reveal } from './Reveal'
|
||||
import { Button } from './Button'
|
||||
import { useSite } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
@@ -11,6 +12,7 @@ import { API_URL } from '../config'
|
||||
|
||||
export function ContactSection() {
|
||||
const { brand, contact_section: content } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
||||
const [sent, setSent] = useState(false)
|
||||
@@ -34,7 +36,7 @@ export function ContactSection() {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
confirmButtonText: t('understood'),
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
@@ -62,13 +64,13 @@ export function ContactSection() {
|
||||
const phoneValue = form.phone.trim()
|
||||
|
||||
if (nameValue.length < 3) {
|
||||
showErrorAlert('اطلاعات ناقص است', 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).')
|
||||
showErrorAlert(t('incompleteInfo'), t('nameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/
|
||||
if (!phoneValue || !phoneRegex.test(phoneValue)) {
|
||||
showErrorAlert('شماره تلفن نامعتبر است', 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.')
|
||||
showErrorAlert(t('phoneInvalidTitle'), t('phoneInvalidText'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,14 +85,14 @@ export function ContactSection() {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
const detail =
|
||||
typeof data?.detail === 'string' ? data.detail : 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.'
|
||||
showErrorAlert('خطا در ثبت درخواست', detail)
|
||||
typeof data?.detail === 'string' ? data.detail : t('formCheck')
|
||||
showErrorAlert(t('submitErrorTitle'), detail)
|
||||
return
|
||||
}
|
||||
|
||||
setSent(true)
|
||||
} catch {
|
||||
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
|
||||
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -232,7 +234,7 @@ export function ContactSection() {
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder={content.phone_placeholder}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-right text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light ltr:text-left rtl:text-right text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -262,7 +264,7 @@ export function ContactSection() {
|
||||
) : (
|
||||
<>
|
||||
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React from 'react';
|
||||
import { PhoneIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
|
||||
export function FloatingContact() {
|
||||
const { brand } = useSite();
|
||||
const { t } = useLang();
|
||||
|
||||
return (
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
aria-label="تماس تلفنی با گروه ساختمانی آرامش"
|
||||
className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
|
||||
aria-label={t('callBrand')}
|
||||
className="group fixed bottom-8 ltr:right-8 rtl:left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
|
||||
>
|
||||
{/* پالس هاله نوری در پسزمینه مانند رادار */}
|
||||
<span
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { useFieldSaver } from '../admin/AdminContext';
|
||||
import { InlineEdit } from './admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
|
||||
@@ -14,6 +15,7 @@ const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>>
|
||||
|
||||
export function Footer() {
|
||||
const { brand, services, nav, footer } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveFooter = (field: string, value: string) =>
|
||||
@@ -38,7 +40,7 @@ export function Footer() {
|
||||
<div className="lg:col-span-4">
|
||||
<div className="relative w-fit">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||
</div>
|
||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
|
||||
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
|
||||
@@ -70,7 +72,7 @@ export function Footer() {
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<Link
|
||||
to={item.href}
|
||||
to={href(item.href)}
|
||||
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<InlineEdit
|
||||
@@ -136,7 +138,7 @@ export function Footer() {
|
||||
onClick={scrollToTop}
|
||||
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
|
||||
>
|
||||
<span>بازگشت به بالا</span>
|
||||
<span>{t('backToTop')}</span>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
</span>
|
||||
|
||||
@@ -2,12 +2,15 @@ import React, { useEffect, useState } from "react";
|
||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
|
||||
import { useSite } from "../api";
|
||||
import { useLang } from "../i18n/lang";
|
||||
import { useFieldSaver } from "../admin/AdminContext";
|
||||
import { InlineEdit } from "./admin/InlineEdit";
|
||||
import { EditableItem, SingletonPill } from "../admin/Editable";
|
||||
import { LangToggle } from "./LangToggle";
|
||||
|
||||
export function Header() {
|
||||
const { brand, nav } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveNav = (id: number, changes: Record<string, unknown>) =>
|
||||
@@ -40,7 +43,7 @@ export function Header() {
|
||||
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
|
||||
<div className="relative flex items-center">
|
||||
<Link
|
||||
to="/"
|
||||
to={href("/")}
|
||||
className="flex items-center gap-3"
|
||||
aria-label={brand.name}
|
||||
>
|
||||
@@ -52,16 +55,16 @@ export function Header() {
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||
</div>
|
||||
|
||||
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
|
||||
<nav aria-label={t("mainNav")} className="hidden xl:block">
|
||||
<ul className="flex items-center gap-7">
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
to={href(item.href)}
|
||||
className={({ isActive }) =>
|
||||
`group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${
|
||||
isActive ? "text-copper font-normal" : "text-ivory/80"
|
||||
@@ -102,12 +105,15 @@ export function Header() {
|
||||
<span dir="ltr" className="tracking-wide">{brand.phone}</span>
|
||||
</a>
|
||||
|
||||
{/* تغییر زبان — فارسی/انگلیسی */}
|
||||
<LangToggle />
|
||||
|
||||
{/* دکمه منوی موبایل دایرهای شیشهای */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/5 text-ivory 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 xl:hidden"
|
||||
aria-label={open ? "بستن منو" : "باز کردن منو"}
|
||||
aria-label={open ? t("closeMenu") : t("openMenu")}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? (
|
||||
@@ -121,7 +127,7 @@ export function Header() {
|
||||
|
||||
{open && (
|
||||
<nav
|
||||
aria-label="ناوبری موبایل"
|
||||
aria-label={t("mobileNav")}
|
||||
className="border-t border-white/10 bg-ink/95 backdrop-blur-2xl px-6 py-4 xl:hidden shadow-2xl"
|
||||
>
|
||||
<ul className="grid gap-1">
|
||||
@@ -129,7 +135,7 @@ export function Header() {
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
to={href(item.href)}
|
||||
className={({ isActive }) =>
|
||||
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
|
||||
isActive ? "text-copper" : "text-ivory/80"
|
||||
@@ -149,4 +155,4 @@ export function Header() {
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { LanguagesIcon } from 'lucide-react'
|
||||
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
||||
|
||||
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابهجا میشود */
|
||||
export function LangToggle({ className = '' }: { className?: string }) {
|
||||
const { lang, t } = useLang()
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const target = counterpartPath(pathname, lang)
|
||||
|
||||
const switchLang = () => {
|
||||
writeLangCookie(lang === 'fa' ? 'en' : 'fa')
|
||||
// پرش خودکار بازدید بعدی با انتخاب دستی همینجا گره نمیخورد
|
||||
sessionStorage.setItem('langAutoRedirect', '1')
|
||||
navigate(target)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
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}`}
|
||||
>
|
||||
<LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
|
||||
<span>{t('switchLang')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronLeftIcon } from 'lucide-react'
|
||||
import { usePageHero } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
@@ -15,6 +16,7 @@ type PageHeroProps = {
|
||||
|
||||
export function PageHero({ pageKey }: PageHeroProps) {
|
||||
const hero = usePageHero(pageKey)
|
||||
const { t, href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveField = (field: 'title' | 'lead' | 'image', value: string) =>
|
||||
@@ -36,11 +38,11 @@ export function PageHero({ pageKey }: PageHeroProps) {
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to={href('/')} className="transition-colors hover:text-copper">
|
||||
{t('home')}
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
||||
<span className="text-copper">{hero.title}</span>
|
||||
</nav>
|
||||
<motion.h1
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useSiteData } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
// برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری دادهها از دیتابیس استفاده میشود
|
||||
const FALLBACK_LOGO = '/images/logo.png'
|
||||
const FALLBACK_NAME = 'گروه ساختمانی آرامش'
|
||||
|
||||
export function Preloader() {
|
||||
const { data } = useSiteData()
|
||||
const { t } = useLang()
|
||||
const [done, setDone] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const logo = data?.brand.logo || FALLBACK_LOGO
|
||||
const name = data?.brand.name || FALLBACK_NAME
|
||||
const name = data?.brand.name || t('brandFallback')
|
||||
|
||||
useEffect(() => {
|
||||
const start = Date.now()
|
||||
@@ -43,7 +44,7 @@ export function Preloader() {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
aria-live="polite"
|
||||
aria-label="در حال بارگذاری"
|
||||
aria-label={t('loading')}
|
||||
>
|
||||
<div className="relative h-36 w-36">
|
||||
<img
|
||||
|
||||
@@ -2,17 +2,19 @@ import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { useStatusLabel } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
export function ProjectCard({ project }: any) {
|
||||
const isOngoing = project.status === 'progress'
|
||||
const statusText = useStatusLabel(project.status)
|
||||
const { t, href, lang } = useLang()
|
||||
|
||||
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={`/projects/${project.slug || '#'}`} className="flex h-full flex-col focus:outline-none">
|
||||
<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">
|
||||
@@ -23,14 +25,14 @@ export function ProjectCard({ project }: any) {
|
||||
/>
|
||||
|
||||
{/* نشانگر وضعیت کپسولی شیشهای */}
|
||||
<div className={`absolute right-4 top-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 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 transition-transform duration-300 group-hover:-translate-x-0.5" />
|
||||
<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>
|
||||
|
||||
@@ -47,8 +49,9 @@ export function ProjectCard({ project }: any) {
|
||||
<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>سال: {project.year}</span>
|
||||
<span>{project.area} متر مربع</span>
|
||||
<span>{t('yearLabel')} {project.year}</span>
|
||||
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه میشود */}
|
||||
<span>{lang === 'fa' ? `${project.area} ${t('sqm')}` : project.area}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function SectionHeading({
|
||||
})
|
||||
|
||||
return (
|
||||
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
|
||||
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl ltr:text-left rtl:text-right'}>
|
||||
<InlineEdit
|
||||
as="h2"
|
||||
value={currentTitle}
|
||||
|
||||
@@ -4,12 +4,14 @@ import { Reveal } from '../Reveal';
|
||||
import { ButtonLink } from '../Button';
|
||||
import { ArticleCard } from '../ArticleCard';
|
||||
import { useSectionHeading, useSite } from '../../api';
|
||||
import { useLang } from '../../i18n/lang';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||
|
||||
export function Articles() {
|
||||
const { articles } = useSite();
|
||||
const { href } = useLang();
|
||||
const heading = useSectionHeading('articles');
|
||||
const save = useFieldSaver();
|
||||
|
||||
@@ -25,7 +27,7 @@ export function Articles() {
|
||||
return (
|
||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<Reveal className="max-w-2xl text-right">
|
||||
<Reveal className="max-w-2xl ltr:text-left rtl:text-right">
|
||||
<p className="text-sm font-light text-copper">
|
||||
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
|
||||
</p>
|
||||
@@ -51,9 +53,9 @@ export function Articles() {
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
|
||||
<ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light">
|
||||
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
@@ -3,10 +3,12 @@ import { MoveHorizontalIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
export function BeforeAfter() {
|
||||
const { before_after } = useSite()
|
||||
const { t } = useLang()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [position, setPosition] = useState(52)
|
||||
const dragging = useRef(false)
|
||||
@@ -102,7 +104,7 @@ export function BeforeAfter() {
|
||||
<button
|
||||
type="button"
|
||||
role="slider"
|
||||
aria-label="مقایسه کانسپت و پروژه ساختهشده"
|
||||
aria-label={t('compareAria')}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(position)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PlusIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
type FaqProps = {
|
||||
@@ -12,6 +13,7 @@ type FaqProps = {
|
||||
|
||||
export function Faq({ hideHeading = false }: FaqProps) {
|
||||
const { faqs } = useSite()
|
||||
const { t } = useLang()
|
||||
const [open, setOpen] = useState<number | null>(0)
|
||||
|
||||
return (
|
||||
@@ -22,8 +24,8 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
{!hideHeading && (
|
||||
<div className="lg:col-span-4">
|
||||
<SectionHeading
|
||||
title="سوالات متداول"
|
||||
lead="پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران."
|
||||
title={t('fallbackFaqTitle')}
|
||||
lead={t('fallbackFaqLead')}
|
||||
headingKey="faq"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,12 +7,14 @@ 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'
|
||||
|
||||
export function FeaturedProjects() {
|
||||
const { projects, status_labels } = useSite()
|
||||
const { t, href } = useLang()
|
||||
const heading = useSectionHeading('featured_projects')
|
||||
const save = useFieldSaver()
|
||||
const saveStatusLabel = (key: string, value: string) => {
|
||||
@@ -37,7 +39,7 @@ export function FeaturedProjects() {
|
||||
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
|
||||
|
||||
{/* دکمههای فیلتر مینیمال و شیشهای (بدون کادر) */}
|
||||
<div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
||||
<div className="mt-10 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 (
|
||||
@@ -102,7 +104,7 @@ export function FeaturedProjects() {
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink
|
||||
to={heading.cta_href}
|
||||
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)]"
|
||||
>
|
||||
@@ -111,12 +113,12 @@ export function FeaturedProjects() {
|
||||
onSave={(v) =>
|
||||
save('section-headings', heading.id, { cta_label: v }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||
if (h) h.cta_label = v
|
||||
if (h) (h as unknown as Record<string, unknown>)["cta_label"] = v
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 group-hover:-translate-x-1 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,12 @@ import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang, formatNumber } from '../../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
export function Gallery() {
|
||||
const { gallery } = useSite()
|
||||
const { t, lang } = useLang()
|
||||
const [active, setActive] = useState<number | null>(null)
|
||||
|
||||
const close = useCallback(() => setActive(null), [])
|
||||
@@ -30,8 +32,8 @@ export function Gallery() {
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="gallery-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading
|
||||
title="گالری تصاویر"
|
||||
lead="قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."
|
||||
title={t('fallbackGalleryTitle')}
|
||||
lead={t('fallbackGalleryLead')}
|
||||
headingKey="gallery"
|
||||
align="center"
|
||||
/>
|
||||
@@ -44,7 +46,7 @@ export function Gallery() {
|
||||
type="button"
|
||||
onClick={() => setActive(i)}
|
||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`بزرگنمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
||||
aria-label={`${t('zoomImagePrefix')} ${formatNumber(i + 1, lang)}`}
|
||||
>
|
||||
<img
|
||||
src={image.src}
|
||||
@@ -69,14 +71,14 @@ export function Gallery() {
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="نمایش تصویر"
|
||||
aria-label={t('viewImage')}
|
||||
onClick={close}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="absolute left-8 top-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"
|
||||
aria-label="بستن"
|
||||
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"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -87,10 +89,10 @@ export function Gallery() {
|
||||
e.stopPropagation()
|
||||
step(-1)
|
||||
}}
|
||||
className="absolute right-6 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:right-10"
|
||||
aria-label="تصویر قبلی"
|
||||
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" />
|
||||
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -98,10 +100,10 @@ export function Gallery() {
|
||||
e.stopPropagation()
|
||||
step(1)
|
||||
}}
|
||||
className="absolute left-6 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"
|
||||
aria-label="تصویر بعدی"
|
||||
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" />
|
||||
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
|
||||
@@ -3,12 +3,14 @@ import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
export function Hero() {
|
||||
const { brand, hero } = useSite()
|
||||
const { t, href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const { scrollY } = useScroll()
|
||||
const imageY = useTransform(scrollY, [0, 700], [0, 110])
|
||||
@@ -21,11 +23,11 @@ export function Hero() {
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label="معرفی گروه ساختمانی آرامش">
|
||||
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label={t('heroAria')}>
|
||||
{/* تصویر پسزمینه */}
|
||||
<motion.img
|
||||
src={brand.hero}
|
||||
alt="نمای شبانه برج مسکونی ساختهشده توسط گروه ساختمانی آرامش"
|
||||
alt={t('heroImageAlt')}
|
||||
style={{ y: imageY }}
|
||||
className="absolute inset-0 h-[114%] w-full object-cover"
|
||||
/>
|
||||
@@ -62,7 +64,7 @@ export function Hero() {
|
||||
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-6 text-hero font-bold text-ivory leading-[1.3]"
|
||||
>
|
||||
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label="تیتر اصلی صفحه" />
|
||||
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} />
|
||||
</motion.h1>
|
||||
|
||||
{/* زیرتیتر */}
|
||||
@@ -84,21 +86,21 @@ export function Hero() {
|
||||
>
|
||||
{/* دکمه اصلی: کپسولی شیشهای دودی با دایره کهربایی اختصاصی برای آیکون */}
|
||||
<ButtonLink
|
||||
to={hero.cta_primary_href}
|
||||
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 group-hover:-translate-x-1">
|
||||
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5]" />
|
||||
<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>
|
||||
|
||||
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
|
||||
<ButtonLink
|
||||
to={hero.cta_secondary_href}
|
||||
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"
|
||||
>
|
||||
@@ -112,7 +114,7 @@ export function Hero() {
|
||||
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 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:left-10"
|
||||
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)} />
|
||||
@@ -126,7 +128,7 @@ export function Hero() {
|
||||
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
||||
<a
|
||||
href="#story"
|
||||
aria-label="رفتن به بخش بعدی"
|
||||
aria-label={t('scrollDown')}
|
||||
className="group absolute bottom-7 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/60 transition-colors duration-300 hover:text-copper"
|
||||
>
|
||||
<span className="text-[11px] font-light tracking-widest opacity-80 group-hover:opacity-100">
|
||||
|
||||
@@ -4,6 +4,7 @@ import Swal from 'sweetalert2'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { Button } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
@@ -11,6 +12,7 @@ import { API_URL } from '../../config'
|
||||
|
||||
export function Newsletter() {
|
||||
const { newsletter } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
||||
@@ -29,7 +31,7 @@ export function Newsletter() {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
confirmButtonText: t('understood'),
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
@@ -58,13 +60,13 @@ export function Newsletter() {
|
||||
|
||||
if (!emailValue) {
|
||||
setStatus('error')
|
||||
showErrorAlert('اطلاعات ناقص است', 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.')
|
||||
showErrorAlert(t('incompleteInfo'), t('emailRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!emailRegex.test(emailValue)) {
|
||||
setStatus('error')
|
||||
showErrorAlert('فرمت ایمیل نامعتبر است', 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).')
|
||||
showErrorAlert(t('emailInvalidTitle'), t('emailInvalidText'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,8 +82,8 @@ export function Newsletter() {
|
||||
setStatus('error')
|
||||
const data = await res.json().catch(() => null)
|
||||
const detail =
|
||||
typeof data?.detail === 'string' ? data.detail : 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.'
|
||||
showErrorAlert('خطا در عضویت', detail)
|
||||
typeof data?.detail === 'string' ? data.detail : t('emailCheck')
|
||||
showErrorAlert(t('subscribeErrorTitle'), detail)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -89,7 +91,7 @@ export function Newsletter() {
|
||||
setEmail('')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
|
||||
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -146,7 +148,7 @@ export function Newsletter() {
|
||||
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"
|
||||
>
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
<span>
|
||||
{sending ? newsletter.sending_label : (
|
||||
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
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 toFa(n: number) {
|
||||
return n.toLocaleString('fa-IR');
|
||||
}
|
||||
|
||||
function Counter({ value }: {value: number;}) {
|
||||
const { lang } = useLang();
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const inView = useInView(ref, { once: true, margin: '-60px' });
|
||||
const [display, setDisplay] = useState(0);
|
||||
@@ -27,15 +25,16 @@ function Counter({ value }: {value: number;}) {
|
||||
return () => window.clearInterval(id);
|
||||
}, [inView, value]);
|
||||
|
||||
return <span ref={ref}>{toFa(display)}</span>;
|
||||
return <span ref={ref}>{formatNumber(display, lang)}</span>;
|
||||
}
|
||||
|
||||
export function Stats() {
|
||||
const { stats } = useSite();
|
||||
const { t } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
return (
|
||||
<section className="bg-char py-16" aria-label="آمار و ارقام">
|
||||
<section className="bg-char py-16" aria-label={t('statsAria')}>
|
||||
<div className="mx-auto grid max-w-site grid-cols-2 gap-y-12 px-6 lg:grid-cols-4 lg:px-10">
|
||||
{stats.map((stat) =>
|
||||
<EditableItem key={stat.id} resource="stats" item={stat}>
|
||||
@@ -48,7 +47,7 @@ export function Stats() {
|
||||
const s = d.stats.find((x) => x.id === stat.id);
|
||||
if (s) s.suffix = v;
|
||||
})}
|
||||
className="mr-1 text-2xl"
|
||||
className="ltr:ml-1 rtl:mr-1 text-2xl"
|
||||
/>
|
||||
</p>
|
||||
<p className="mt-3 text-sm font-light text-ivory/55">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
@@ -17,6 +18,7 @@ type StoryProps = {
|
||||
|
||||
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) {
|
||||
const { story } = useSite()
|
||||
const { href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveStory = (field: string, value: unknown) =>
|
||||
@@ -87,9 +89,9 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
||||
|
||||
{showLink && (
|
||||
<Reveal delay={0.22}>
|
||||
<ButtonLink to={story.link_href} variant="outline" className="mt-10 font-light">
|
||||
<ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light">
|
||||
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</Reveal>
|
||||
)}
|
||||
@@ -102,7 +104,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
||||
</div>
|
||||
|
||||
<div className="relative grid grid-cols-2 gap-4">
|
||||
<SingletonPill resource="story" label="تصاویر و نقلقول بخش درباره ما" className="absolute -top-10 right-0" />
|
||||
<SingletonPill resource="story" label="تصاویر و نقلقول بخش درباره ما" className="absolute -top-10 ltr:left-0 rtl:right-0" />
|
||||
{story.bento.map((item, i) => (
|
||||
<Reveal key={item.src + i} delay={0.06 * i}>
|
||||
<div className="overflow-hidden">
|
||||
|
||||
@@ -2,16 +2,18 @@ import React from 'react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
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">
|
||||
<SectionHeading
|
||||
title="تیم ما"
|
||||
lead="کسانی که تصمیمهای اصلی هر پروژه را میگیرند و پاسخگوی آن هستند."
|
||||
title={t('fallbackTeamTitle')}
|
||||
lead={t('fallbackTeamLead')}
|
||||
headingKey="team"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
@@ -3,10 +3,12 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
|
||||
import { SectionHeading } from '../SectionHeading';
|
||||
import { useSite } from '../../api';
|
||||
import { useLang } from '../../i18n/lang';
|
||||
import { EditableItem } from '../../admin/Editable';
|
||||
|
||||
export function Testimonials() {
|
||||
const { testimonials } = useSite();
|
||||
const { t } = useLang();
|
||||
const [index, setIndex] = useState(0);
|
||||
const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))];
|
||||
if (!current) return null;
|
||||
@@ -44,20 +46,20 @@ export function Testimonials() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(1)}
|
||||
aria-label="نظر قبلی"
|
||||
aria-label={t('prevTestimonial')}
|
||||
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5 transition-transform duration-300 ease-lux group-hover:translate-x-0.5" />
|
||||
<ChevronRightIcon className="h-5 w-5 ltr:rotate-180 transition-transform duration-300 ease-lux rtl:group-hover:translate-x-0.5 ltr:group-hover:-translate-x-0.5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2" role="tablist" aria-label="انتخاب نظر">
|
||||
{testimonials.map((t, i) => (
|
||||
<div className="flex items-center gap-2" role="tablist" aria-label={t('selectTestimonialAria')}>
|
||||
{testimonials.map((item, i) => (
|
||||
<button
|
||||
key={t.id}
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === index}
|
||||
aria-label={`نظر ${t.name}`}
|
||||
aria-label={`${t("testimonialByPrefix")} ${item.name}`}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`h-2 rounded-full transition-all duration-300 ease-lux ${
|
||||
i === index
|
||||
@@ -71,10 +73,10 @@ export function Testimonials() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(-1)}
|
||||
aria-label="نظر بعدی"
|
||||
aria-label={t('nextTestimonial')}
|
||||
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5 transition-transform duration-300 ease-lux group-hover:-translate-x-0.5" />
|
||||
<ChevronLeftIcon className="h-5 w-5 ltr:rotate-180 transition-transform duration-300 ease-lux rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
||||
import { useSite, type TimelineItem } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
@@ -49,6 +50,7 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
||||
|
||||
export function Timeline() {
|
||||
const { timeline } = useSite()
|
||||
const { t } = useLang()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
@@ -62,8 +64,8 @@ export function Timeline() {
|
||||
|
||||
<div className="mb-16">
|
||||
<SectionHeading
|
||||
title="مسیر ما"
|
||||
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژههای ما را ببینید."
|
||||
title={t('fallbackTimelineTitle')}
|
||||
lead={t('fallbackTimelineLead')}
|
||||
headingKey="timeline"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useSectionHeading, useSite } from '../../api';
|
||||
import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { useLang, formatNumber } from '../../i18n/lang';
|
||||
|
||||
export function WhyUs() {
|
||||
const { why_us } = useSite();
|
||||
const { lang } = useLang();
|
||||
const heading = useSectionHeading('why_us');
|
||||
const save = useFieldSaver();
|
||||
|
||||
@@ -43,7 +45,7 @@ export function WhyUs() {
|
||||
<Reveal key={item.id} delay={0.05 * i}>
|
||||
<EditableItem resource="why_us" item={item}>
|
||||
<div className="flex h-full flex-col border-t border-ivory/10 py-8">
|
||||
<span className="text-xs font-light text-copper">۰{(i + 1).toLocaleString('fa-IR')}</span>
|
||||
<span className="text-xs font-light text-copper">{lang === 'fa' ? `۰${formatNumber(i + 1, lang)}` : formatNumber(i + 1, lang).padStart(2, '0')}</span>
|
||||
<h3 className="mt-4 text-lg font-bold text-ivory">
|
||||
<InlineEdit
|
||||
value={item.title}
|
||||
|
||||
Reference in New Issue
Block a user