فرانت‌اند: ویرایش زنده درجا — دوقلوی دیجیتال سایت برای ادمین

لایه ویرایش بصری روی سایت واقعی (بدون داشبورد جدا):

- ورود ادمین از مسیر پنهان /admin/login با توکن JWT (localStorage) و
  AdminContext شامل isEditMode و وضعیت ذخیره (همگام/در حال ذخیره/خطا)
- AdminBar شناور پایین صفحه (فقط برای کاربر واردشده): کلید حالت ویرایش،
  وضعیت ذخیره، خروج — حالت ویرایش در sessionStorage می‌ماند
- InlineEdit: در حالت عادی دقیقاً همان HTML قبلی (بدون تغییر چیدمان)؛
  در حالت ویرایش کادرخط‌چین + contentEditable با ذخیره خودکار در blur
  (Enter ذخیره، Esc لغو) — وایرایش‌شده روی همه متن‌های سایت: هیرو،
  تیتر بخش‌ها، هیرو صفحات، داستان، فرم تماس، خبرنامه، فوتر، منو،
  برچسب‌های وضعیت پروژه و...
- کشوی عمومی EntityDrawer با پیکربندی متمرکز برای ۱۴ مجموعه (خدمات،
  پروژه‌ها، مقالات، نظرات، سوالات، تیم، تایم‌لاین، آمار، چرا آرامش،
  مدل‌های سرمایه‌گذاری، مراحل کار، دفاتر، گالری، منو): ویرایش چندفیلدی،
  افزودن، حذف و جابه‌جایی ترتیب (▲▼) با ذخیره خودکار هر فیلد
- دکمه‌های شناور ویرایش/حذف/ترتیب فقط در حالت ویرایش روی آیتم‌ها
- کشوهای یگانه: برند و لوگو، تنظیمات هیرو، فرم تماس، خبرنامه، فوتر،
  قبل/بعد و نقل‌قول داستان — آپلود تصویر به MinIO از همان‌جا
- به‌روزرسانی خوش‌بینانه SiteContext (updateSite) پس از هر PATCH؛
  مسیرها در حالت ویرایش کلیک نمی‌شوند؛ breadcrumb محافظ beforeunload
This commit is contained in:
MMD
2026-09-06 21:57:46 +03:30
parent 6e422ec3dc
commit 5ed082631e
39 changed files with 1882 additions and 260 deletions
+25 -7
View File
@@ -4,28 +4,43 @@ import { Reveal } from '../Reveal';
import { ButtonLink } from '../Button';
import { ArticleCard } from '../ArticleCard';
import { useSectionHeading, useSite } from '../../api';
import { useFieldSaver } from '../../admin/AdminContext';
import { InlineEdit } from '../admin/InlineEdit';
import { AddItemButton, EditableItem } from '../../admin/Editable';
export function Articles() {
const { articles } = useSite();
const heading = useSectionHeading('articles');
const save = useFieldSaver();
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
save('section-headings', heading.id, { [field]: value }, (d) => {
const h = d.section_headings.find((s) => s.id === heading.id);
if (h) (h as Record<string, unknown>)[field] = value;
});
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">
<p className="text-sm font-light text-copper">{heading.label}</p>
<p className="text-sm font-light text-copper">
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
</p>
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory">
{heading.title}
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
</h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
{heading.lead}
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
</p>
<div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal>
<div className="mt-14 grid gap-6 lg:grid-cols-3">
{articles.slice(0, 3).map((article, i) =>
<Reveal key={article.slug} delay={0.06 * i} className="h-full">
<ArticleCard article={article} />
<Reveal key={article.id} delay={0.06 * i} className="h-full">
<EditableItem resource="articles" item={article} className="h-full">
<ArticleCard article={article} />
</EditableItem>
</Reveal>
)}
</div>
@@ -34,13 +49,16 @@ export function Articles() {
<Reveal delay={0.1}>
<div className="mt-12 flex justify-center">
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
{heading.cta_label}
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</div>
</Reveal>
)}
<div className="mt-6">
<AddItemButton resource="articles" />
</div>
</div>
</section>);
}
}
@@ -3,6 +3,7 @@ import { MoveHorizontalIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
import { SingletonPill } from '../../admin/Editable'
export function BeforeAfter() {
const { before_after } = useSite()
@@ -54,6 +55,10 @@ export function BeforeAfter() {
align="center"
/>
<div className="flex justify-start">
<SingletonPill resource="before_after" label="ویرایش تصاویر و برچسب‌های قبل/بعد" />
</div>
<Reveal delay={0.1}>
<div
ref={containerRef}
+44 -38
View File
@@ -3,7 +3,8 @@ import { AnimatePresence, motion } from 'framer-motion'
import { PlusIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite } from '../../api'
import { useSite } from '../../api'
import { AddItemButton, EditableItem } from '../../admin/Editable'
type FaqProps = {
hideHeading?: boolean;
@@ -11,19 +12,19 @@ type FaqProps = {
export function Faq({ hideHeading = false }: FaqProps) {
const { faqs } = useSite()
const heading = useSectionHeading('faq')
const [open, setOpen] = useState<number | null>(0)
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
<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"}>
{!hideHeading && (
<div className="lg:col-span-4">
<SectionHeading
title={heading.title}
lead={heading.lead}
title="سوالات متداول"
lead="پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران."
headingKey="faq"
/>
</div>
)}
@@ -33,43 +34,48 @@ export function Faq({ hideHeading = false }: FaqProps) {
{faqs.map((faq, i) => {
const isOpen = open === i
return (
<Reveal key={faq.question} delay={0.04 * i}>
<li className="border-b border-ivory/10">
<button
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"
>
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
{faq.question}
</span>
<PlusIcon
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
isOpen ? 'rotate-45' : ''
}`}
/>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
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>
</motion.div>
)}
</AnimatePresence>
</li>
<Reveal key={faq.id} delay={0.04 * i}>
<EditableItem resource="faqs" item={faq}>
<li className="border-b border-ivory/10">
<button
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"
>
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
{faq.question}
</span>
<PlusIcon
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
isOpen ? 'rotate-45' : ''
}`}
/>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
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>
</motion.div>
)}
</AnimatePresence>
</li>
</EditableItem>
</Reveal>
)
})}
</ul>
<div className="mt-8">
<AddItemButton resource="faqs" />
</div>
</div>
</div>
</section>
)
}
}
@@ -6,16 +6,27 @@ import { ButtonLink } from '../Button'
import { ProjectCard } from '../ProjectCard'
import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { AddItemButton, EditableItem } from '../../admin/Editable'
import type { ProjectStatus } from '../../data/site'
export function FeaturedProjects() {
const { projects } = useSite()
const { projects, status_labels } = useSite()
const heading = useSectionHeading('featured_projects')
const doneLabel = useStatusLabel('done')
const progressLabel = useStatusLabel('progress')
const save = useFieldSaver()
const saveStatusLabel = (key: string, value: string) => {
const label = status_labels.find((s) => s.key === key)
if (!label) return
void save('status-labels', label.id, { label: value }, (d) => {
const s = d.status_labels.find((x) => x.id === label.id)
if (s) s.label = value
})
}
const FILTERS: { key: ProjectStatus; label: string }[] = [
{ key: 'done', label: doneLabel },
{ key: 'progress', label: progressLabel },
{ 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)
@@ -23,7 +34,7 @@ export function FeaturedProjects() {
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading title={heading.title} lead={heading.lead} />
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
<div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
{FILTERS.map((filter) => (
@@ -46,7 +57,10 @@ export function FeaturedProjects() {
/>
)}
<span className="relative z-10 transition-colors duration-300 ease-out group-hover:text-char">
{filter.label}
<InlineEdit
value={filter.label}
onSave={(v) => saveStatusLabel(filter.key, v)}
/>
</span>
</button>
))}
@@ -55,7 +69,7 @@ export function FeaturedProjects() {
<div className="mt-10 min-h-[420px]">
<AnimatePresence mode="wait">
<motion.div
key={status}
key={status}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
@@ -64,13 +78,15 @@ export function FeaturedProjects() {
>
{list.map((project, i) => (
<motion.div
key={project.slug}
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} />
<EditableItem resource="projects" item={project} className="h-full">
<ProjectCard project={project} />
</EditableItem>
</motion.div>
))}
</motion.div>
@@ -81,7 +97,15 @@ export function FeaturedProjects() {
<Reveal delay={0.1}>
<div className="mt-12 flex justify-center">
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
{heading.cta_label}
<InlineEdit
value={heading.cta_label}
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
})
}
/>
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</div>
@@ -90,4 +114,4 @@ export function FeaturedProjects() {
</div>
</section>
)
}
}
+22 -18
View File
@@ -3,11 +3,11 @@ import { AnimatePresence, motion } from 'framer-motion'
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite } from '../../api'
import { useSite } from '../../api'
import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Gallery() {
const { gallery } = useSite()
const heading = useSectionHeading('gallery')
const [active, setActive] = useState<number | null>(null)
const close = useCallback(() => setActive(null), [])
@@ -30,33 +30,37 @@ 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={heading.title}
lead={heading.lead}
title="گالری تصاویر"
lead="قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده گروه ساختمانی آرامش."
headingKey="gallery"
align="center"
/>
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
{gallery.map((image, i) => (
<Reveal key={image.id} delay={0.04 * i}>
<button
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')}`}
>
<img
src={image.src}
alt={image.alt}
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</button>
<EditableItem resource="gallery" item={image}>
<button
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')}`}
>
<img
src={image.src}
alt={image.alt}
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</button>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="gallery" />
</div>
</div>
<AnimatePresence>
{active !== null && (
{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"
initial={{ opacity: 0 }}
@@ -116,4 +120,4 @@ export function Gallery() {
</AnimatePresence>
</section>
)
}
}
+29 -8
View File
@@ -3,14 +3,23 @@ import { motion, useScroll, useTransform } from 'framer-motion'
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
import { ButtonLink } from '../Button'
import { useSite } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable'
export function Hero() {
const { brand, hero } = useSite()
const save = useFieldSaver()
const { scrollY } = useScroll()
const imageY = useTransform(scrollY, [0, 700], [0, 110])
const contentY = useTransform(scrollY, [0, 700], [0, -55])
const overlay = useTransform(scrollY, [0, 600], [0.5, 0.78])
const saveHero = (field: string, value: unknown) =>
save('hero', hero.id, { [field]: value }, (d) => {
;(d.hero as unknown as Record<string, unknown>)[field] = value
})
// واژه highlight در میانه تیتر با رنگ مسی رندر می‌شود
const renderTitle = () => {
if (hero.title_highlight && hero.title.includes(hero.title_highlight)) {
@@ -55,7 +64,7 @@ export function Hero() {
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }}
className="text-sm font-light text-copper"
>
{hero.kicker}
<InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} />
</motion.p>
<motion.h1
@@ -64,7 +73,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"
>
{renderTitle()}
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label="تیتر اصلی صفحه" />
</motion.h1>
<motion.p
@@ -73,7 +82,7 @@ export function Hero() {
transition={{ duration: 0.85, delay: 2.32, ease: [0.23, 1, 0.32, 1] }}
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/70"
>
{hero.subtitle}
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} />
</motion.p>
<motion.div
@@ -83,11 +92,11 @@ export function Hero() {
className="mt-10 flex flex-wrap items-center gap-4"
>
<ButtonLink to={hero.cta_primary_href} variant="copper">
{hero.cta_primary_label}
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
<ButtonLink to={hero.cta_secondary_href} variant="outline" className="font-light">
{hero.cta_secondary_label}
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
</ButtonLink>
</motion.div>
</div>
@@ -98,8 +107,12 @@ export function Hero() {
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
className="absolute bottom-28 left-6 hidden w-60 border border-ivory/15 bg-char/60 p-6 backdrop-blur-md lg:block lg:left-10"
>
<p className="text-3xl font-bold text-copper">{hero.badge_value}</p>
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">{hero.badge_label}</p>
<p className="text-3xl font-bold text-copper">
<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>
@@ -108,9 +121,17 @@ export function Hero() {
aria-label="رفتن به بخش بعدی"
className="absolute bottom-8 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/50 transition-colors duration-200 ease-lux hover:text-copper"
>
<span className="text-xs font-light">{hero.scroll_label}</span>
<span className="text-xs font-light">
<InlineEdit value={hero.scroll_label} onSave={(v) => saveHero('scroll_label', v)} />
</span>
<ChevronDownIcon className="h-4 w-4 animate-bounce" />
</a>
<SingletonPill
resource="hero"
label="تنظیمات هیرو (واژه مسی، دکمه‌ها)"
className="absolute left-6 top-24 z-20 lg:left-10"
/>
</section>
)
}
+17 -5
View File
@@ -4,14 +4,23 @@ import Swal from 'sweetalert2'
import { Reveal } from '../Reveal'
import { Button } from '../Button'
import { useSite } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable'
import { API_URL } from '../../config'
export function Newsletter() {
const { newsletter } = useSite()
const save = useFieldSaver()
const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
const [sending, setSending] = useState(false)
const saveContent = (field: string, value: string) =>
save('newsletter', newsletter.id, { [field]: value }, (d) => {
;(d.newsletter as unknown as Record<string, unknown>)[field] = value
})
const showErrorAlert = (title: string, text: string) => {
Swal.fire({
icon: 'warning',
@@ -87,14 +96,15 @@ export function Newsletter() {
}
return (
<section className="bg-copper py-16" aria-labelledby="newsletter-title">
<section className="relative bg-copper py-16" aria-labelledby="newsletter-title">
<SingletonPill resource="newsletter" label="ویرایش متن‌های خبرنامه" className="absolute left-4 top-4" />
<div className="mx-auto grid max-w-site items-center gap-8 px-6 lg:grid-cols-2 lg:px-10">
<Reveal>
<h2 id="newsletter-title" className="text-2xl font-bold leading-relaxed text-char md:text-3xl">
{newsletter.title}
<InlineEdit value={newsletter.title} onSave={(v) => saveContent('title', v)} />
</h2>
<p className="mt-3 max-w-md text-sm font-light leading-8 text-char/75">
{newsletter.lead}
<InlineEdit multiline value={newsletter.lead} onSave={(v) => saveContent('lead', v)} />
</p>
</Reveal>
@@ -127,11 +137,13 @@ export function Newsletter() {
<Button type="submit" variant="dark-ivory" disabled={sending}>
<SendIcon className="h-4 w-4" />
{sending ? newsletter.sending_label : newsletter.button_label}
{sending ? newsletter.sending_label : (
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
)}
</Button>
</form>
</Reveal>
</div>
</section>
)
}
}
+28 -14
View File
@@ -2,41 +2,55 @@ import React from 'react'
import { Reveal } from '../Reveal'
import { ServiceIcon } from '../ServiceIcon'
import { useSectionHeading, useSite } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Services() {
const { services } = useSite()
const heading = useSectionHeading('services')
const save = useFieldSaver()
const saveHeading = (field: 'title' | 'lead', value: string) =>
save('section-headings', heading.id, { [field]: value }, (d) => {
const h = d.section_headings.find((s) => s.id === heading.id)
if (h) h[field] = value
})
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal className="max-w-2xl text-right">
<h2 id="services-title" className="text-section font-bold text-ivory">
{heading.title}
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
</h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
{heading.lead}
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
</p>
<div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal>
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{services.map((service, i) => (
<Reveal key={service.title} delay={0.04 * i} className="h-full">
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-8 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
<ServiceIcon
name={service.icon}
className="h-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper"
/>
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
{service.title}
</h3>
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
</article>
<Reveal key={service.id} delay={0.04 * i} className="h-full">
<EditableItem resource="services" item={service} className="h-full">
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-8 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
<ServiceIcon
name={service.icon}
className="h-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper"
/>
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
{service.title}
</h3>
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
</article>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="services" />
</div>
</div>
</section>
)
}
}
+27 -4
View File
@@ -1,6 +1,9 @@
import React, { useEffect, useRef, useState } from 'react';
import { useInView } from 'framer-motion';
import { useSite } from '../../api';
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');
@@ -29,19 +32,39 @@ function Counter({ value }: {value: number;}) {
export function Stats() {
const { stats } = useSite();
const save = useFieldSaver();
return (
<section className="bg-char py-16" aria-label="آمار و ارقام">
<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) =>
<div key={stat.label} className="text-center">
<EditableItem key={stat.id} resource="stats" item={stat}>
<div className="text-center">
<p className="text-4xl font-bold text-copper md:text-5xl">
<Counter value={stat.value} />
{stat.suffix && <span className="mr-1 text-2xl">{stat.suffix}</span>}
<InlineEdit
value={stat.suffix}
onSave={(v) => save('stats', stat.id, { suffix: v }, (d) => {
const s = d.stats.find((x) => x.id === stat.id);
if (s) s.suffix = v;
})}
className="mr-1 text-2xl"
/>
</p>
<p className="mt-3 text-sm font-light text-ivory/55">
<InlineEdit
value={stat.label}
onSave={(v) => save('stats', stat.id, { label: v }, (d) => {
const s = d.stats.find((x) => x.id === stat.id);
if (s) s.label = v;
})}
/>
</p>
<p className="mt-3 text-sm font-light text-ivory/55">{stat.label}</p>
</div>
</EditableItem>
)}
<AddItemButton resource="stats" />
</div>
</section>);
}
}
+39 -10
View File
@@ -3,6 +3,9 @@ import { ArrowLeftIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button'
import { useSite } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable'
type StoryProps = {
showLink?: boolean;
@@ -14,6 +17,19 @@ type StoryProps = {
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) {
const { story } = useSite()
const save = useFieldSaver()
const saveStory = (field: string, value: unknown) =>
save('story', story.id, { [field]: value }, (d) => {
;(d.story as unknown as Record<string, unknown>)[field] = value
})
const saveParagraph = (index: number, value: string) => {
const paragraphs = [...story.paragraphs]
paragraphs[index] = value
void saveStory('paragraphs', paragraphs)
}
return (
<section id="story" className="bg-ink py-24 lg:py-32" aria-labelledby="story-title">
<div className={`mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-2 lg:px-10 ${alignTop ? 'items-start' : 'items-center'}`}>
@@ -21,7 +37,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
{showLabel && (
<Reveal>
<h2 id="story-title" className="text-section font-bold text-ivory">
{story.title}
<InlineEdit value={story.title} onSave={(v) => saveStory('title', v)} />
</h2>
<div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal>
@@ -30,7 +46,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
<Reveal delay={0.08}>
{story.paragraphs.map((paragraph, i) => (
<p key={i} className={`text-base font-light leading-9 text-ivory/65 ${i === 0 ? 'mt-8' : 'mt-5'}`}>
{paragraph}
<InlineEdit multiline value={paragraph} onSave={(v) => saveParagraph(i, v)} />
</p>
))}
</Reveal>
@@ -38,12 +54,20 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
<Reveal delay={0.16}>
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
<div>
<p className="text-2xl font-bold text-copper">{story.stat1_value}</p>
<p className="mt-2 text-sm font-light text-ivory/55">{story.stat1_label}</p>
<p className="text-2xl font-bold text-copper">
<InlineEdit value={story.stat1_value} onSave={(v) => saveStory('stat1_value', v)} />
</p>
<p className="mt-2 text-sm font-light text-ivory/55">
<InlineEdit value={story.stat1_label} onSave={(v) => saveStory('stat1_label', v)} />
</p>
</div>
<div>
<p className="text-2xl font-bold text-copper">{story.stat2_value}</p>
<p className="mt-2 text-sm font-light text-ivory/55">{story.stat2_label}</p>
<p className="text-2xl font-bold text-copper">
<InlineEdit value={story.stat2_value} onSave={(v) => saveStory('stat2_value', v)} />
</p>
<p className="mt-2 text-sm font-light text-ivory/55">
<InlineEdit value={story.stat2_label} onSave={(v) => saveStory('stat2_label', v)} />
</p>
</div>
</div>
</Reveal>
@@ -51,8 +75,12 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
{showQuote && story.quote && (
<Reveal delay={0.2}>
<div className="mt-12 border-r-2 border-copper bg-char/40 p-6">
<p className="text-sm font-light leading-8 text-ivory/80">{story.quote}</p>
<p className="mt-5 text-sm font-bold text-copper">{story.quote_author}</p>
<p className="text-sm font-light leading-8 text-ivory/80">
<InlineEdit multiline value={story.quote} onSave={(v) => saveStory('quote', v)} />
</p>
<p className="mt-5 text-sm font-bold text-copper">
<InlineEdit value={story.quote_author} onSave={(v) => saveStory('quote_author', v)} />
</p>
</div>
</Reveal>
)}
@@ -60,7 +88,7 @@ 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">
{story.link_label}
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</Reveal>
@@ -73,7 +101,8 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="relative grid grid-cols-2 gap-4">
<SingletonPill resource="story" label="تصاویر و نقل‌قول بخش درباره ما" className="absolute -top-10 right-0" />
{story.bento.map((item, i) => (
<Reveal key={item.src + i} delay={0.06 * i}>
<div className="overflow-hidden">
+20 -16
View File
@@ -1,37 +1,41 @@
import React from 'react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite } from '../../api'
import { useSite } from '../../api'
import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Team() {
const { team } = useSite()
const heading = useSectionHeading('team')
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={heading.title}
lead={heading.lead}
title="تیم ما"
lead="کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند."
headingKey="team"
align="center"
/>
<div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
{team.map((member, i) => (
<Reveal key={member.name} delay={0.06 * i} 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">
<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>
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
{member.bio}
</p>
</article>
<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">
<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>
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
{member.bio}
</p>
</article>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="team" />
</div>
</div>
</section>
)
}
}
+18 -15
View File
@@ -2,13 +2,14 @@ import React, { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
import { SectionHeading } from '../SectionHeading';
import { useSectionHeading, useSite } from '../../api';
import { useSite } from '../../api';
import { EditableItem } from '../../admin/Editable';
export function Testimonials() {
const { testimonials } = useSite();
const heading = useSectionHeading('testimonials');
const [index, setIndex] = useState(0);
const item = testimonials[index];
const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))];
if (!current) return null;
const go = (dir: number) => {
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
@@ -17,23 +18,25 @@ export function Testimonials() {
return (
<section className="border-y border-ivory/10 bg-char py-24 lg:py-32" aria-labelledby="testimonials-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading title={heading.title} align="center" />
<SectionHeading headingKey="testimonials" align="center" />
<div className="relative mx-auto mt-14 max-w-3xl text-center">
<QuoteIcon className="mx-auto h-8 w-8 text-copper/60" aria-hidden="true" />
<AnimatePresence mode="wait">
<motion.blockquote
key={item.name}
key={current.id}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{item.quote}»</p>
<footer className="mt-8">
<p className="text-base font-bold text-copper">{item.name}</p>
<p className="mt-1 text-sm font-light text-ivory/45">{item.role}</p>
</footer>
<EditableItem resource="testimonials" item={current}>
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{current.quote}»</p>
<footer className="mt-8">
<p className="text-base font-bold text-copper">{current.name}</p>
<p className="mt-1 text-sm font-light text-ivory/45">{current.role}</p>
</footer>
</EditableItem>
</motion.blockquote>
</AnimatePresence>
@@ -43,13 +46,13 @@ export function Testimonials() {
onClick={() => go(1)}
aria-label="نظر قبلی"
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
<ChevronRightIcon className="h-5 w-5" />
</button>
<div className="flex gap-2" role="tablist" aria-label="انتخاب نظر">
{testimonials.map((t, i) =>
<button
key={t.name}
key={t.id}
type="button"
role="tab"
aria-selected={i === index}
@@ -66,7 +69,7 @@ export function Testimonials() {
onClick={() => go(-1)}
aria-label="نظر بعدی"
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
<ChevronLeftIcon className="h-5 w-5" />
</button>
</div>
@@ -74,4 +77,4 @@ export function Testimonials() {
</div>
</section>);
}
}
+17 -11
View File
@@ -1,7 +1,8 @@
import React, { useRef } from 'react'
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
import { useSectionHeading, useSite, type TimelineItem } from '../../api'
import { useSite, type TimelineItem } from '../../api'
import { SectionHeading } from '../SectionHeading'
import { AddItemButton, EditableItem } from '../../admin/Editable'
const ease = [0.23, 1, 0.32, 1] as const
@@ -31,11 +32,13 @@ function Item({ item, index }: { item: Milestone; index: number }) {
animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
transition={{ duration: 0.55, ease }}
>
<div className="border border-ivory/10 bg-ink p-6 md:p-7">
<span className="text-sm font-light text-copper">{item.year}</span>
<h3 className="mt-3 text-lg font-bold text-ivory">{item.title}</h3>
<p className="mt-3 text-sm font-light leading-8 text-ivory/60">{item.desc}</p>
</div>
<EditableItem resource="timeline" item={item}>
<div className="border border-ivory/10 bg-ink p-6 md:p-7">
<span className="text-sm font-light text-copper">{item.year}</span>
<h3 className="mt-3 text-lg font-bold text-ivory">{item.title}</h3>
<p className="mt-3 text-sm font-light leading-8 text-ivory/60">{item.desc}</p>
</div>
</EditableItem>
</motion.div>
</li>
)
@@ -43,7 +46,6 @@ function Item({ item, index }: { item: Milestone; index: number }) {
export function Timeline() {
const { timeline } = useSite()
const heading = useSectionHeading('timeline')
const ref = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({
target: ref,
@@ -54,11 +56,12 @@ export function Timeline() {
return (
<section className="bg-char py-24 md:py-32" aria-labelledby="timeline-title">
<div className="mx-auto w-full max-w-site px-6 lg:px-10">
<div className="mb-16">
<SectionHeading
title={heading.title}
lead={heading.lead}
title="مسیر ما"
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید."
headingKey="timeline"
align="center"
/>
</div>
@@ -76,9 +79,12 @@ export function Timeline() {
<ol className="flex flex-col gap-12 md:gap-16">
{timeline.map((item, i) => (
<Item key={item.year} item={item} index={i} />
<Item key={item.id} item={item} index={i} />
))}
</ol>
<div className="mt-12">
<AddItemButton resource="timeline" />
</div>
</div>
</div>
</section>
+33 -5
View File
@@ -2,17 +2,29 @@ import React from 'react';
import { Reveal } from '../Reveal';
import { SectionHeading } from '../SectionHeading';
import { useSectionHeading, useSite } from '../../api';
import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable';
import { InlineEdit } from '../admin/InlineEdit';
import { useFieldSaver } from '../../admin/AdminContext';
export function WhyUs() {
const { why_us } = useSite();
const heading = useSectionHeading('why_us');
const save = useFieldSaver();
const saveItem = (id: number, changes: Record<string, unknown>) =>
save('why-us', id, changes, (d) => {
const it = d.why_us.find((x) => x.id === id);
if (it) Object.assign(it, changes);
});
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="why-title">
<div className="mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10">
<div className="lg:col-span-5">
<SectionHeading
title={heading.title}
lead={heading.lead} />
lead={heading.lead}
headingKey="why_us" />
{heading.image && (
<Reveal delay={0.12}>
@@ -28,17 +40,33 @@ export function WhyUs() {
<div className="lg:col-span-7">
<div className="grid gap-x-10 gap-y-px sm:grid-cols-2">
{why_us.map((item, i) =>
<Reveal key={item.title} delay={0.05 * i}>
<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>
<h3 className="mt-4 text-lg font-bold text-ivory">{item.title}</h3>
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{item.desc}</p>
<h3 className="mt-4 text-lg font-bold text-ivory">
<InlineEdit
value={item.title}
onSave={(v) => saveItem(item.id, { title: v })}
/>
</h3>
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">
<InlineEdit
multiline
value={item.desc}
onSave={(v) => saveItem(item.id, { desc: v })}
/>
</p>
</div>
</EditableItem>
</Reveal>
)}
</div>
<div className="mt-8">
<AddItemButton resource="why_us" />
</div>
</div>
</div>
</section>);
}
}