فرانتاند: ویرایش زنده درجا — دوقلوی دیجیتال سایت برای ادمین
لایه ویرایش بصری روی سایت واقعی (بدون داشبورد جدا): - ورود ادمین از مسیر پنهان /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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user