پروژه کامل: فرانت متصل به API + داکرایز

- اتصال کامل فرانت به API: همه محتوای سایت (پروژه‌ها، مقالات، خدمات، برند و...) از بک‌اند خوانده می‌شود (SiteProvider + useSite)
- فرم تماس و خبرنامه متصل به بک‌اند و تست‌شده در مرورگر
- ساختار مونوریپو: frontend/ + backend/
- Dockerfile برای هر دو بخش + docker-compose (PostgreSQL + FastAPI + nginx)
- nginx با پروکسی /api — بدون نیاز به CORS در حالت داکر
- README کامل فارسی
This commit is contained in:
Behrad
2026-08-29 13:26:58 +03:30
parent ed14435485
commit ad75f7a7bd
78 changed files with 8242 additions and 133 deletions
+48
View File
@@ -0,0 +1,48 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
export function ArticleCard({ article }: { article: Article }) {
return (
<Link
to={`/blog/${article.slug}`}
className="group flex h-full flex-col border border-ivory/10 bg-char transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
aria-label={`خواندن مقاله ${article.title}`}
>
<div className="overflow-hidden">
<img
src={article.image}
alt={`تصویر مقاله ${article.title}`}
className="h-52 w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105 transform-gpu"
/>
</div>
<div className="flex flex-1 flex-col p-8">
<p className="text-xs font-light text-copper">{article.category}</p>
<h3 className="mt-4 text-lg font-bold leading-9 text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
{article.title}
</h3>
<p className="mt-4 mb-8 text-sm font-light leading-[2.2rem] text-ivory/55">
{article.excerpt}
</p>
<div className="mt-auto flex items-center justify-between border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
<span className="flex items-center gap-4">
<span>{article.date}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5" />
{article.read}
</span>
</span>
<span className="flex items-center gap-2 text-copper">
ادامه مطلب
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 ease-out group-hover:-translate-x-1.5" />
</span>
</div>
</div>
</Link>
)
}
+93
View File
@@ -0,0 +1,93 @@
import React from 'react'
import { Link } from 'react-router-dom'
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
type BaseProps = {
children: React.ReactNode
variant?: Variant
className?: string
}
const STYLES: Record<Variant, { base: string; fill: string; text: string }> = {
copper: {
base: 'bg-copper text-char',
fill: 'bg-char',
text: 'group-hover:text-ivory',
},
outline: {
base: 'border border-ivory/30 text-ivory',
fill: 'bg-copper',
text: 'group-hover:text-char',
},
dark: {
base: 'bg-char text-ivory',
fill: 'bg-copper',
text: 'group-hover:text-char',
},
'dark-ivory': {
base: 'bg-char text-ivory',
fill: 'bg-ivory',
text: 'group-hover:text-char',
},
}
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
const style = STYLES[variant]
return (
<>
<span
aria-hidden="true"
className={`absolute inset-0 translate-y-full transition-transform duration-[600ms] ease-lux group-hover:translate-y-0 ${style.fill}`}
/>
<span
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-[600ms] ease-lux ${style.text}`}
>
{children}
</span>
</>
)
}
const SHELL =
'group relative inline-flex items-center justify-center overflow-hidden px-8 py-4 text-sm font-bold focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink'
export function ButtonLink({
to,
children,
variant = 'copper',
className = '',
}: BaseProps & { to: string }) {
return (
<Link to={to} className={`${SHELL} ${STYLES[variant].base} ${className}`}>
<Inner variant={variant}>{children}</Inner>
</Link>
)
}
export function ButtonAnchor({
href,
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
return (
<a href={href} className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</a>
)
}
export function Button({
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</button>
)
}
+225
View File
@@ -0,0 +1,225 @@
import React, { useState } from 'react'
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { Reveal } from './Reveal'
import { Button } from './Button'
import { useSite } from '../api'
import { API_URL } from '../config'
export function ContactSection() {
const { brand } = useSite()
const [form, setForm] = useState({ name: '', phone: '', note: '' })
const [sent, setSent] = useState(false)
const [sending, setSending] = useState(false)
const showErrorAlert = (title: string, text: string) => {
Swal.fire({
icon: 'warning',
title: title,
text: text,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: 'متوجه شدم',
buttonsStyling: false,
customClass: {
container: 'backdrop-blur-[8px] bg-ink/60',
popup: 'font-pinar border border-ivory/10 rounded-none shadow-lift',
title: 'font-pinar font-bold text-lg text-ivory pt-2',
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
confirmButton: 'mt-6 bg-copper text-char font-bold px-10 py-3 transition-transform duration-300 ease-lux hover:scale-105',
},
showClass: {
popup: 'swal2-show',
backdrop: 'swal2-backdrop-show',
},
hideClass: {
popup: 'swal2-hide',
backdrop: 'swal2-backdrop-hide',
}
})
}
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (sending) return
const nameValue = form.name.trim()
const phoneValue = form.phone.trim()
if (nameValue.length < 3) {
showErrorAlert('اطلاعات ناقص است', 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).')
return
}
const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/
if (!phoneValue || !phoneRegex.test(phoneValue)) {
showErrorAlert('شماره تلفن نامعتبر است', 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.')
return
}
setSending(true)
try {
const res = await fetch(`${API_URL}/api/contact`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim() }),
})
if (!res.ok) {
const data = await res.json().catch(() => null)
const detail =
typeof data?.detail === 'string' ? data.detail : 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.'
showErrorAlert('خطا در ثبت درخواست', detail)
return
}
setSent(true)
} catch {
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
} finally {
setSending(false)
}
}
return (
<section id="contact" className="relative overflow-hidden py-24 lg:py-32" aria-labelledby="contact-title">
<img
src={brand.contact_bg}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="relative mx-auto max-w-site px-6 lg:px-10">
<div className="mx-auto grid max-w-6xl items-stretch gap-6 lg:grid-cols-12">
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[8px] md:p-10 lg:col-span-5">
<Reveal>
<p className="text-sm font-light text-copper">تماس با ما</p>
<h2 id="contact-title" className="mt-5 text-section font-bold text-ivory">
پروژه‌تان را با ما در میان بگذارید
</h2>
<p className="mt-6 text-base font-light leading-9 text-ivory/75">
شماره‌تان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس می‌گیرد.
</p>
</Reveal>
<Reveal delay={0.1}>
<ul className="mt-10 space-y-6">
<li className="flex items-start gap-4">
<MapPinIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div>
<p className="text-sm font-bold text-ivory">دفتر مرکزی</p>
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">{brand.address}</p>
</div>
</li>
<li className="flex items-start gap-4">
<PhoneIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div>
<p className="text-sm font-bold text-ivory">تلفن</p>
<a
href={brand.phone_href}
dir="ltr"
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
>
{brand.phone}
</a>
</div>
</li>
<li className="flex items-start gap-4">
<MailIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div>
<p className="text-sm font-bold text-ivory">ایمیل</p>
<a
href={`mailto:${brand.email}`}
dir="ltr"
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
>
{brand.email}
</a>
</div>
</li>
</ul>
</Reveal>
</div>
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[9px] md:p-10 lg:col-span-7">
{sent ? (
<div className="flex h-full flex-col items-center justify-center py-14 text-center">
<CheckCircle2Icon className="h-12 w-12 text-copper" />
<h3 className="mt-6 text-xl font-bold text-ivory">درخواست شما ثبت شد</h3>
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
از اعتماد شما سپاسگزاریم. کارشناسان ما به‌زودی با شما تماس می‌گیرند.
</p>
<Button
type="button"
variant="outline"
className="mt-8 font-light"
onClick={() => {
setSent(false)
setForm({ name: '', phone: '', note: '' })
}}
>
ثبت درخواست جدید
</Button>
</div>
) : (
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
<div>
<label htmlFor="contact-name" className="block text-sm font-bold text-ivory/90">
نام
</label>
<input
id="contact-name"
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="نام و نام خانوادگی"
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
/>
</div>
<div>
<label htmlFor="contact-phone" className="block text-sm font-bold text-ivory/90">
شماره تلفن <span className="text-copper">*</span>
</label>
<input
id="contact-phone"
type="tel"
dir="ltr"
required
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder="09xx xxx xxxx"
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-right text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
/>
</div>
<div className="md:col-span-2">
<label htmlFor="contact-note" className="block text-sm font-bold text-ivory/90">
توضیحات <span className="font-light text-ivory/50">(اختیاری)</span>
</label>
<textarea
id="contact-note"
rows={6}
value={form.note}
onChange={(e) => setForm({ ...form, note: e.target.value })}
placeholder="درباره پروژه یا درخواست خود بنویسید…"
className="mt-3 w-full resize-none border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light leading-8 text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
/>
</div>
<div className="md:col-span-2">
<Button type="submit" variant="copper" className="w-full md:w-auto md:px-12" disabled={sending}>
{sending ? 'در حال ارسال…' : 'ارسال درخواست مشاوره'}
</Button>
</div>
</form>
)}
</div>
</div>
</div>
</section>
)
}
@@ -0,0 +1,17 @@
import React from 'react';
import { PhoneIcon } from 'lucide-react';
import { useSite } from '../api';
export function FloatingContact() {
const { brand } = useSite();
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 bg-copper text-char shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
<span className="absolute inset-0 rounded-full border border-copper/50 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
<PhoneIcon className="h-5 w-5" />
</a>);
}
+94
View File
@@ -0,0 +1,94 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
import { NAV } from '../data/site';
import { useSite } from '../api';
export function Footer() {
const { brand, services } = useSite();
return (
<footer className="border-t border-ivory/10 bg-char">
<div className="mx-auto max-w-site px-6 py-16 lg:px-10">
<div className="grid gap-12 lg:grid-cols-12">
<div className="lg:col-span-4">
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/55">
آرامش پلازا از سال ۱۳۸۳ ساختمان‌هایی می‌سازد که قرار است چند نسل در آن‌ها زندگی کنند؛ معماری آرام،
اجرای دقیق و سرمایه‌گذاری شفاف.
</p>
<div className="mt-6 flex gap-3">
{[
{ Icon: InstagramIcon, label: 'اینستاگرام' },
{ Icon: LinkedinIcon, label: 'لینکدین' },
{ Icon: SendIcon, label: 'تلگرام' }].
map(({ Icon, label }) =>
<a
key={label}
href="#"
aria-label={label}
className="flex h-10 w-10 items-center justify-center border border-ivory/15 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
<Icon className="h-4 w-4" />
</a>
)}
</div>
</div>
<div className="lg:col-span-2">
<h3 className="text-sm font-bold text-copper">صفحات</h3>
<ul className="mt-5 space-y-3">
{NAV.map((item) =>
<li key={item.to}>
<Link
to={item.to}
className="text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper">
{item.label}
</Link>
</li>
)}
</ul>
</div>
<div className="lg:col-span-3">
<h3 className="text-sm font-bold text-copper">خدمات</h3>
<ul className="mt-5 space-y-3">
{services.map((service) =>
<li key={service.title} className="text-sm font-light text-ivory/60">
{service.title}
</li>
)}
</ul>
</div>
<div className="lg:col-span-3">
<h3 className="text-sm font-bold text-copper">ارتباط با ما</h3>
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
<li className="flex items-start gap-3">
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
<span className="leading-7">{brand.address}</span>
</li>
<li className="flex items-center gap-3">
<PhoneIcon className="h-4 w-4 shrink-0 text-copper" />
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
{brand.phone}
</a>
</li>
<li className="flex items-center gap-3">
<MailIcon className="h-4 w-4 shrink-0 text-copper" />
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
{brand.email}
</a>
</li>
</ul>
</div>
</div>
<div className="mt-14 flex flex-col items-center justify-between gap-4 border-t border-ivory/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
<p>© ۱۴۰۳ آرامش پلازا — تمامی حقوق محفوظ است.</p>
<p>طراحی و ساخت با دقتِ معماری</p>
</div>
</div>
</footer>);
}
+132
View File
@@ -0,0 +1,132 @@
import React, { useEffect, useState } from "react";
import { Link, NavLink, useLocation } from "react-router-dom";
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
import { NAV } from "../data/site";
import { useSite } from "../api";
import { use } from "framer-motion/m";
export function Header() {
const { brand } = useSite();
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
const { pathname } = useLocation();
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
});
useEffect(() => {
setOpen(false);
}, [pathname]);
return (
<header
className={`fixed inset-x-0 top-0 z-50 transition-[background-color,border-color,backdrop-filter] duration-300 ease-lux ${
scrolled
? "border-b border-ivory/10 bg-ink/90 backdrop-blur-md"
: "border-b border-transparent bg-transparent"
}`}
>
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
<Link
to="/"
className="flex items-center gap-3"
aria-label={brand.name}
>
<img
src={brand.logo}
alt={brand.name}
className={`w-auto object-contain transition-[height] duration-300 ease-lux ${
scrolled ? "h-14" : "h-20"
}`}
/>
</Link>
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
<ul className="flex items-center gap-7">
{NAV.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
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" : "text-ivory/80"
}`
}
>
{({ isActive }) => (
<>
{item.label}
<span
className={`absolute -bottom-0.5 left-0 right-0 h-px w-full origin-center bg-copper transition-transform duration-300 ease-lux ${
isActive
? "scale-x-100"
: "scale-x-0 group-hover:scale-x-100"
}`}
/>
</>
)}
</NavLink>
</li>
))}
</ul>
</nav>
<div className="flex items-center gap-3">
<a
href={brand.phone_href}
className="group relative hidden items-center gap-2 overflow-hidden border border-copper/50 px-5 py-2.5 text-sm font-medium text-copper [text-shadow:_0_1px_2px_rgba(0,0,0,0.5)] lg:flex"
>
<span
aria-hidden="true"
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-lux group-hover:translate-y-0"
/>
<span className="relative z-10 flex items-center gap-2 transition-colors duration-300 ease-lux group-hover:text-char">
<PhoneIcon className="h-4 w-4" />
<span dir="ltr">{brand.phone}</span>
</span>
</a>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper xl:hidden"
aria-label={open ? "بستن منو" : "باز کردن منو"}
aria-expanded={open}
>
{open ? (
<XIcon className="h-5 w-5" />
) : (
<MenuIcon className="h-5 w-5" />
)}
</button>
</div>
</div>
{open && (
<nav
aria-label="ناوبری موبایل"
className="border-t border-ivory/10 bg-ink/98 px-6 py-4 xl:hidden"
>
<ul className="grid gap-1">
{NAV.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
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"
}`
}
>
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
)}
</header>
);
}
+100
View File
@@ -0,0 +1,100 @@
import React, { useCallback, useEffect } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
type Props = {
images: string[]
index: number | null
onClose: () => void
onChange: (index: number) => void
}
export function Lightbox({ images, index, onClose, onChange }: Props) {
const isOpen = index !== null
const next = useCallback(() => {
if (index === null) return
onChange((index + 1) % images.length)
}, [index, images.length, onChange])
const prev = useCallback(() => {
if (index === null) return
onChange((index - 1 + images.length) % images.length)
}, [index, images.length, onChange])
useEffect(() => {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowRight') prev()
if (e.key === 'ArrowLeft') next()
}
window.addEventListener('keydown', onKey)
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKey)
document.body.style.overflow = ''
}
}, [isOpen, onClose, next, prev])
return (
<AnimatePresence>
{isOpen && (
<motion.div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-[90] flex items-center justify-center p-5 md:p-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
>
<div
className="absolute inset-0 bg-ink/90 backdrop-blur-md"
onClick={onClose}
aria-hidden="true"
/>
<button
type="button"
onClick={onClose}
className="absolute left-6 top-6 z-10 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10 md:top-10"
>
<XIcon className="h-5 w-5" />
</button>
<motion.img
key={index}
src={images[index as number]}
alt=""
className="relative max-h-[80vh] w-auto max-w-full object-contain"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
/>
<div className="absolute bottom-6 z-10 flex items-center gap-4 md:bottom-10">
<button
type="button"
onClick={prev}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
>
<ChevronRightIcon className="h-5 w-5" />
</button>
<span className="text-sm font-light text-ivory/70" dir="ltr">
{(index as number) + 1} / {images.length}
</span>
<button
type="button"
onClick={next}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
>
<ChevronLeftIcon className="h-5 w-5" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
+53
View File
@@ -0,0 +1,53 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronLeftIcon } from 'lucide-react'
type PageHeroProps = {
title: string
lead: string
image: string
}
export function PageHero({ title, lead, image }: PageHeroProps) {
return (
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
<img src={image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" />
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
aria-hidden="true"
/>
<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">
صفحه اصلی
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5" />
<span className="text-copper">{title}</span>
</nav>
<motion.h1
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
className="mt-5 max-w-[700px] text-hero font-bold text-ivory"
>
{title}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.1, ease: [0.23, 1, 0.32, 1] }}
className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/65"
>
{lead}
</motion.p>
<div className="mt-8 h-px w-28 bg-copper" />
</div>
</section>
)
}
+73
View File
@@ -0,0 +1,73 @@
import React, { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { BRAND } from '../data/site'
export function Preloader() {
const [done, setDone] = useState(false)
const [progress, setProgress] = useState(0)
useEffect(() => {
const start = Date.now()
const total = 1800
const id = window.setInterval(() => {
const p = Math.min(100, ((Date.now() - start) / total) * 100)
setProgress(p)
if (p >= 100) {
window.clearInterval(id)
window.setTimeout(() => setDone(true), 180)
}
}, 25)
return () => window.clearInterval(id)
}, [])
useEffect(() => {
document.body.style.overflow = done ? '' : 'hidden'
return () => {
document.body.style.overflow = ''
}
}, [done])
return (
<AnimatePresence>
{!done && (
<motion.div
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-ink"
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
aria-live="polite"
aria-label="در حال بارگذاری"
>
<div className="relative h-36 w-36">
<img
src={BRAND.logo}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-contain opacity-[0.12]"
/>
<div className="absolute inset-0 overflow-hidden" style={{ clipPath: `inset(${100 - progress}% 0 0 0)` }}>
<img src={BRAND.logo} alt="" aria-hidden="true" className="h-full w-full object-contain" />
</div>
<div
className="absolute inset-x-0 h-px bg-copper"
style={{ bottom: `${progress}%` }}
aria-hidden="true"
/>
</div>
<div className="mt-10 h-[2px] w-48 overflow-hidden bg-ivory/10">
<div className="h-full bg-copper" style={{ width: `${progress}%`, transition: 'width 120ms linear' }} />
</div>
<motion.p
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="mt-4 text-sm font-light tracking-widest text-copper font-pinar"
>
{BRAND.name}
</motion.p>
</motion.div>
)}
</AnimatePresence>
)
}
+50
View File
@@ -0,0 +1,50 @@
import React from 'react'
import { Link } from 'react-router-dom'
export function ProjectCard({ project }: any) {
const isOngoing = project.status === 'progress'
const statusText = isOngoing ? 'در حال انجام' : 'انجام شده'
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 border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift 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">
<div className="relative aspect-[4/3] w-full overflow-hidden">
<img
src={project.image}
alt={project.title}
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
/>
<div className={`absolute right-4 top-4 flex items-center gap-2 bg-ink/85 px-4 py-2 text-xs font-light backdrop-blur-md ${textColor}`}>
<span className={`h-1.5 w-1.5 rounded-full ${dotColor}`} />
{statusText}
</div>
</div>
<div className="flex flex-1 flex-col p-8">
<div className="text-center">
<h3 className="font-pinar text-xl font-bold text-ivory">
{project.title}
</h3>
<p className="mt-7 text-sm font-light text-ivory/60">
{project.location}
</p>
</div>
<div className="my-7 h-px w-full bg-ivory/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>
</div>
</div>
</Link>
</article>
)
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { motion } from 'framer-motion';
type RevealProps = {
children: React.ReactNode;
delay?: number;
y?: number;
className?: string;
};
export function Reveal({ children, delay = 0, y = 28, className }: RevealProps) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.7, delay, ease: [0.23, 1, 0.32, 1] }}>
{children}
</motion.div>);
}
@@ -0,0 +1,29 @@
import React from 'react'
import { Reveal } from './Reveal'
type SectionHeadingProps = {
title: string
lead?: string
align?: 'right' | 'center'
tone?: 'copper' | 'ivory'
}
export function SectionHeading({
title,
lead,
align = 'right',
tone = 'ivory',
}: SectionHeadingProps) {
const isCenter = align === 'center'
return (
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
<h2 className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}>
{title}
</h2>
{lead && <p className="mt-5 text-base font-light leading-9 text-ivory/60">{lead}</p>}
<div className={`mt-6 h-px w-24 bg-copper/70 ${isCenter ? 'mx-auto' : ''}`} />
</Reveal>
)
}
+83
View File
@@ -0,0 +1,83 @@
import React from 'react';
import type { Service } from '../data/site';
type ServiceIconProps = {
name: Service['icon'];
className?: string;
};
const stroke = {
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.2,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const
};
export function ServiceIcon({ name, className = 'h-12 w-12' }: ServiceIconProps) {
return (
<svg viewBox="0 0 48 48" className={className} aria-hidden="true" role="presentation">
{name === 'draft' &&
<g {...stroke}>
<path d="M6 40h36" />
<path d="M10 40V14l14-8 14 8v26" />
<path d="M10 22h28M24 6v34" />
<circle cx="24" cy="22" r="3" />
</g>
}
{name === 'tower' &&
<g {...stroke}>
<path d="M8 42h32" />
<path d="M15 42V10l9-5 9 5v32" />
<path d="M19 15h10M19 21h10M19 27h10M19 33h10" />
</g>
}
{name === 'mall' &&
<g {...stroke}>
<path d="M6 42h36" />
<path d="M9 42V20h30v22" />
<path d="M9 20l6-10h18l6 10" />
<path d="M19 42V30h10v12" />
</g>
}
{name === 'villa' &&
<g {...stroke}>
<path d="M5 42h38" />
<path d="M9 42V22l15-10 15 10v20" />
<path d="M18 42V30h12v12" />
<path d="M9 26h30" />
</g>
}
{name === 'renew' &&
<g {...stroke}>
<path d="M10 42h28V20L24 10 10 20z" />
<path d="M31 6a9 9 0 1 0 6 15" />
<path d="M38 5v7h-7" />
</g>
}
{name === 'manage' &&
<g {...stroke}>
<rect x="8" y="8" width="32" height="32" rx="2" />
<path d="M8 17h32" />
<path d="M14 25h8M14 32h14M27 24l4 4 7-8" />
</g>
}
{name === 'invest' &&
<g {...stroke}>
<path d="M7 40h34" />
<path d="M12 40V26M22 40V18M32 40V30" />
<path d="M9 16l10-6 8 5 12-9" />
<path d="M39 6h-7M39 6v7" />
</g>
}
{name === 'landscape' &&
<g {...stroke}>
<path d="M5 40h38" />
<path d="M14 40V24M14 24c-4 0-7-3-7-6s3-5 7-5 7 2 7 5-3 6-7 6z" />
<path d="M31 40V22l7-7 4 5v20" />
<path d="M24 40c2-4 5-6 9-6" />
</g>
}
</svg>);
}
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
import { ArrowLeftIcon } from 'lucide-react';
import { Reveal } from '../Reveal';
import { ButtonLink } from '../Button';
import { ArticleCard } from '../ArticleCard';
import { useSite } from '../../api';
export function Articles() {
const { articles } = useSite();
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">وبلاگ</p>
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory">
آخرین مقالات
</h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
یادداشت‌های تیم معماری، اجرا و سرمایه‌گذاری آرامش پلازا درباره آنچه در کارگاه و بازار می‌گذرد.
</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>
)}
</div>
<Reveal delay={0.1}>
<div className="mt-12 flex justify-center">
<ButtonLink to="/blog" variant="outline" className="font-light">
همه مقالات
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</div>
</Reveal>
</div>
</section>);
}
@@ -0,0 +1,121 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { MoveHorizontalIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
export function BeforeAfter() {
const { before_after } = useSite()
const containerRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState(52)
const dragging = useRef(false)
const setFromClientX = useCallback((clientX: number) => {
const el = containerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const ratio = ((clientX - rect.left) / rect.width) * 100
setPosition(Math.min(98, Math.max(2, ratio)))
}, [])
useEffect(() => {
const move = (e: MouseEvent | TouchEvent) => {
if (!dragging.current) return
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
setFromClientX(clientX)
}
const stop = () => {
dragging.current = false
}
window.addEventListener('mousemove', move)
window.addEventListener('touchmove', move)
window.addEventListener('mouseup', stop)
window.addEventListener('touchend', stop)
return () => {
window.removeEventListener('mousemove', move)
window.removeEventListener('touchmove', move)
window.removeEventListener('mouseup', stop)
window.removeEventListener('touchend', stop)
}
}, [setFromClientX])
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowLeft') setPosition((p) => Math.max(2, p - 4))
if (e.key === 'ArrowRight') setPosition((p) => Math.min(98, p + 4))
}
return (
<section className="bg-char py-24 lg:py-32" aria-labelledby="ba-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading
title={before_after.title}
lead={before_after.lead}
align="center"
/>
<Reveal delay={0.1}>
<div
ref={containerRef}
className="relative mx-auto mt-14 aspect-[4/3] w-full max-w-4xl select-none overflow-hidden rounded-xl border border-ivory/10 shadow-2xl md:aspect-[16/10]"
onMouseDown={(e) => {
dragging.current = true
setFromClientX(e.clientX)
}}
onTouchStart={(e) => {
dragging.current = true
setFromClientX(e.touches[0].clientX)
}}
>
<img
src={before_after.before}
alt={before_after.before_label}
className="absolute inset-0 h-full w-full object-cover object-center"
draggable={false}
/>
<div className="absolute inset-0 overflow-hidden" style={{ clipPath: `inset(0 0 0 ${position}%)` }} aria-hidden="true">
<img
src={before_after.after}
alt=""
className="absolute inset-0 h-full w-full object-cover object-center"
draggable={false}
/>
</div>
<span className="pointer-events-none absolute left-6 top-6 z-30 rounded-md bg-ink/85 px-4 py-2 text-xs font-light text-ivory backdrop-blur-md border border-ivory/10 shadow-lg">
{before_after.before_label}
</span>
<span className="pointer-events-none absolute right-6 top-6 z-30 rounded-md bg-ink/85 px-4 py-2 text-xs font-light text-copper backdrop-blur-md border border-copper/30 shadow-lg">
{before_after.after_label}
</span>
<div className="pointer-events-none absolute inset-y-0 z-30" style={{ left: `${position}%` }}>
<div className="h-full w-[2px] bg-copper shadow-[0_0_12px_rgba(213,135,76,0.9)]" />
</div>
<button
type="button"
role="slider"
aria-label="مقایسه کانسپت و پروژه ساخته‌شده"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(position)}
onKeyDown={onKeyDown}
onMouseDown={() => {
dragging.current = true
}}
onTouchStart={() => {
dragging.current = true
}}
className="absolute top-1/2 z-40 flex h-14 w-14 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize items-center justify-center rounded-full bg-copper text-char shadow-2xl transition-transform duration-200 ease-lux hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
style={{ left: `${position}%` }}
>
<MoveHorizontalIcon className="h-5 w-5" />
</button>
</div>
</Reveal>
</div>
</section>
)
}
+74
View File
@@ -0,0 +1,74 @@
import React, { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { PlusIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
type FaqProps = {
hideHeading?: boolean;
}
export function Faq({ hideHeading = false }: FaqProps) {
const { faqs } = useSite()
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="سوالات متداول"
lead="پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران."
/>
</div>
)}
<div className={hideHeading ? "" : "lg:col-span-8"}>
<ul>
{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>
)
})}
</ul>
</div>
</div>
</section>
)
}
@@ -0,0 +1,92 @@
import React, { useState } from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion'
import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button'
import { ProjectCard } from '../ProjectCard'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
import type { ProjectStatus } from '../../data/site'
const FILTERS: { key: ProjectStatus; label: string }[] = [
{ key: 'done', label: 'انجام شده' },
{ key: 'progress', label: 'در حال انجام' },
]
export function FeaturedProjects() {
const { projects } = useSite()
const [status, setStatus] = useState<ProjectStatus>('done')
const list = projects.filter((p) => p.status === status).slice(0, 3)
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="پروژه‌های شاخص"
lead="کارهایی که بیشترین چیز را درباره استاندارد ما می‌گویند؛ چه آن‌هایی که تحویل شده‌اند و چه آن‌هایی که همین امروز در حال ساخت‌اند."
/>
<div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
{FILTERS.map((filter) => (
<button
key={filter.key}
type="button"
role="tab"
aria-selected={status === filter.key}
onClick={() => setStatus(filter.key)}
className={`group relative overflow-hidden border px-7 py-3 text-sm transition-colors duration-300 ease-out ${
status === filter.key
? 'border-copper bg-copper font-bold text-char'
: 'border-ivory/15 font-light text-ivory/65'
}`}
>
{status !== filter.key && (
<span
aria-hidden="true"
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-out group-hover:translate-y-0"
/>
)}
<span className="relative z-10 transition-colors duration-300 ease-out group-hover:text-char">
{filter.label}
</span>
</button>
))}
</div>
<div className="mt-10 min-h-[420px]">
<AnimatePresence mode="wait">
<motion.div
key={status}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="grid gap-6 lg:grid-cols-3"
>
{list.map((project, i) => (
<motion.div
key={project.slug}
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} />
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
<Reveal delay={0.1}>
<div className="mt-12 flex justify-center">
<ButtonLink to="/projects" variant="outline" className="font-light">
همه پروژه‌ها
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</div>
</Reveal>
</div>
</section>
)
}
+119
View File
@@ -0,0 +1,119 @@
import React, { useCallback, useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { IMAGES } from '../../data/site'
const GRID = [IMAGES[0], IMAGES[1], IMAGES[2], IMAGES[3], IMAGES[4], IMAGES[1]]
export function Gallery() {
const [active, setActive] = useState<number | null>(null)
const close = useCallback(() => setActive(null), [])
const step = useCallback((dir: number) => {
setActive((prev) => (prev === null ? prev : (prev + dir + GRID.length) % GRID.length))
}, [])
useEffect(() => {
if (active === null) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close()
if (e.key === 'ArrowRight') step(-1)
if (e.key === 'ArrowLeft') step(1)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [active, close, step])
return (
<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="قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده آرامش پلازا."
align="center"
/>
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
{GRID.map((src, i) => (
<Reveal key={`${src}-${i}`} 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={src}
alt={`تصویری از پروژه‌های آرامش پلازا، شماره ${(i + 1).toLocaleString('fa-IR')}`}
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</button>
</Reveal>
))}
</div>
</div>
<AnimatePresence>
{active !== null && (
<motion.div
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
role="dialog"
aria-modal="true"
aria-label="نمایش تصویر"
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="بستن"
>
<XIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={(e) => {
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="تصویر قبلی"
>
<ChevronRightIcon className="h-6 w-6" />
</button>
<button
type="button"
onClick={(e) => {
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="تصویر بعدی"
>
<ChevronLeftIcon className="h-6 w-6" />
</button>
<motion.img
key={active}
src={GRID[active]}
alt=""
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
className="max-h-[85vh] w-auto max-w-[85vw] object-contain shadow-[0_30px_80px_-20px_rgba(0,0,0,0.9)]"
onClick={(e) => e.stopPropagation()}
/>
</motion.div>
)}
</AnimatePresence>
</section>
)
}
+106
View File
@@ -0,0 +1,106 @@
import React from 'react'
import { motion, useScroll, useTransform } from 'framer-motion'
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
import { ButtonLink } from '../Button'
import { useSite } from '../../api'
export function Hero() {
const { brand } = useSite()
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])
return (
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label="معرفی آرامش پلازا">
<motion.img
src={brand.hero}
alt="نمای شبانه برج مسکونی ساخته‌شده توسط آرامش پلازا"
style={{ y: imageY }}
className="absolute inset-0 h-[114%] w-full object-cover"
/>
<motion.div className="absolute inset-0 bg-ink" style={{ opacity: overlay }} aria-hidden="true" />
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(to top, #27221c 5%, rgba(39,34,28,0.2) 60%, rgba(39,34,28,0.6) 100%)',
}}
aria-hidden="true"
/>
<motion.div
style={{ y: contentY }}
className="relative mx-auto flex h-full max-w-site flex-col justify-center px-6 lg:px-10"
>
<div className="w-full max-w-[700px] lg:max-w-[58%]">
<motion.p
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }}
className="text-sm font-light text-copper"
>
از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال
</motion.p>
<motion.h1
initial={{ opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
className="mt-6 text-hero font-bold text-ivory"
>
ساختمانی که آرامش را
<span className="text-copper"> معماری </span>
می‌کند
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
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"
>
طراحی، ساخت و سرمایه‌گذاری در پروژه‌های مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده می‌شود و در
سال‌ها دوام می‌آورد.
</motion.p>
<motion.div
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
className="mt-10 flex flex-wrap items-center gap-4"
>
<ButtonLink to="/projects" variant="copper">
مشاهده پروژه‌ها
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
<ButtonLink to="/contact" variant="outline" className="font-light">
مشاوره سرمایه‌گذاری
</ButtonLink>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, x: -24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
className="absolute bottom-28 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">۴۷</p>
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
پروژه تحویل‌شده در بیست‌ویک سال فعالیت حرفه‌ای
</p>
</motion.div>
</motion.div>
<a
href="#story"
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">اسکرول کنید</span>
<ChevronDownIcon className="h-4 w-4 animate-bounce" />
</a>
</section>
)
}
+135
View File
@@ -0,0 +1,135 @@
import React, { useState } from 'react'
import { CheckIcon, SendIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { Reveal } from '../Reveal'
import { Button } from '../Button'
import { API_URL } from '../../config'
export function Newsletter() {
const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
const [sending, setSending] = useState(false)
const showErrorAlert = (title: string, text: string) => {
Swal.fire({
icon: 'warning',
title: title,
text: text,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: 'متوجه شدم',
buttonsStyling: false,
customClass: {
container: 'backdrop-blur-[8px] bg-ink/60',
popup: 'font-pinar border border-ivory/10 rounded-none shadow-lift',
title: 'font-pinar font-bold text-lg text-ivory pt-2',
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
confirmButton: 'mt-6 bg-copper text-char font-bold px-10 py-3 transition-transform duration-300 ease-lux hover:scale-105',
},
showClass: {
popup: 'swal2-show',
backdrop: 'swal2-backdrop-show',
},
hideClass: {
popup: 'swal2-hide',
backdrop: 'swal2-backdrop-hide',
}
})
}
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (sending) return
const emailValue = email.trim()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailValue) {
setStatus('error')
showErrorAlert('اطلاعات ناقص است', 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.')
return
}
if (!emailRegex.test(emailValue)) {
setStatus('error')
showErrorAlert('فرمت ایمیل نامعتبر است', 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).')
return
}
setSending(true)
try {
const res = await fetch(`${API_URL}/api/newsletter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailValue }),
})
if (!res.ok) {
setStatus('error')
const data = await res.json().catch(() => null)
const detail =
typeof data?.detail === 'string' ? data.detail : 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.'
showErrorAlert('خطا در عضویت', detail)
return
}
setStatus('done')
setEmail('')
} catch {
setStatus('error')
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
} finally {
setSending(false)
}
}
return (
<section className="bg-copper py-16" aria-labelledby="newsletter-title">
<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">
خبرنامه آرامش پلازا
</h2>
<p className="mt-3 max-w-md text-sm font-light leading-8 text-char/75">
هر ماه یک ایمیل: پروژه‌های جدید، فرصت‌های سرمایه‌گذاری و یادداشت‌های تیم معماری.
</p>
</Reveal>
<Reveal delay={0.08}>
<form onSubmit={submit} className="flex flex-col gap-3 sm:flex-row" noValidate>
<div className="flex-1">
<label htmlFor="newsletter-email" className="sr-only">
نشانی ایمیل
</label>
<input
id="newsletter-email"
type="email"
dir="ltr"
value={email}
onChange={(e) => {
setEmail(e.target.value)
if (status !== 'idle') setStatus('idle')
}}
placeholder="نشانی ایمیل شما"
className="w-full border border-transparent bg-char px-5 py-4 text-left text-sm font-light text-ivory placeholder:text-ivory/40 transition-all duration-300 ease-lux focus:border-ivory focus:outline-none"
aria-invalid={status === 'error'}
/>
{status === 'done' && (
<p className="mt-2 flex items-center gap-1.5 text-xs font-light text-char" role="status">
<CheckIcon className="h-3.5 w-3.5" />
عضویت شما ثبت شد.
</p>
)}
</div>
<Button type="submit" variant="dark-ivory" disabled={sending}>
<SendIcon className="h-4 w-4" />
{sending ? 'در حال ارسال…' : 'عضویت'}
</Button>
</form>
</Reveal>
</div>
</section>
)
}
+42
View File
@@ -0,0 +1,42 @@
import React from 'react'
import { Reveal } from '../Reveal'
import { ServiceIcon } from '../ServiceIcon'
import { useSite } from '../../api'
export function Services() {
const { services } = useSite()
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">
خدمات
</h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
از نخستین خط روی کاغذ تا تحویل کلید، مدیریت سرمایه و طراحی محوطه؛ همه مراحل در یک مجموعه و زیر یک نظام
کنترل کیفیت.
</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>
))}
</div>
</div>
</section>
)
}
+47
View File
@@ -0,0 +1,47 @@
import React, { useEffect, useRef, useState } from 'react';
import { useInView } from 'framer-motion';
import { useSite } from '../../api';
function toFa(n: number) {
return n.toLocaleString('fa-IR');
}
function Counter({ value }: {value: number;}) {
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, margin: '-60px' });
const [display, setDisplay] = useState(0);
useEffect(() => {
if (!inView) return;
let frame = 0;
const total = 60;
const id = window.setInterval(() => {
frame += 1;
const progress = 1 - Math.pow(1 - frame / total, 3);
setDisplay(Math.round(value * progress));
if (frame >= total) window.clearInterval(id);
}, 20);
return () => window.clearInterval(id);
}, [inView, value]);
return <span ref={ref}>{toFa(display)}</span>;
}
export function Stats() {
const { stats } = useSite();
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">
<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>}
</p>
<p className="mt-3 text-sm font-light text-ivory/55">{stat.label}</p>
</div>
)}
</div>
</section>);
}
+91
View File
@@ -0,0 +1,91 @@
import React from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button'
import { IMAGES } from '../../data/site'
const BENTO = [
{ src: IMAGES[0], alt: 'نمای برج مسکونی آرامش در تهران' },
{ src: IMAGES[1], alt: 'آتریوم مرکزی مجتمع تجاری پارسه' },
{ src: IMAGES[2], alt: 'ویلاهای ساخته‌شده در سواحل شمال' },
{ src: IMAGES[3], alt: 'نمای شبانه برج دوقلوی نیاوران' },
]
type StoryProps = {
showLink?: boolean;
showLabel?: boolean;
alignTop?: boolean;
children?: React.ReactNode;
}
export function Story({ showLink = true, showLabel = true, alignTop = false, children }: StoryProps) {
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'}`}>
<div>
{showLabel && (
<Reveal>
<h2 id="story-title" className="text-section font-bold text-ivory">
درباره ی ما
</h2>
<div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal>
)}
<Reveal delay={0.08}>
<p className="mt-8 text-base font-light leading-9 text-ivory/65">
آرامش پلازا با یک دفتر کوچک معماری آغاز شد و امروز یکی از مجموعه‌های شناخته‌شده ساخت‌وساز لوکس در ایران
است. در تمام این سال‌ها یک اصل تغییر نکرده است: ساختمان باید برای زندگی ساخته شود، نه برای فروش سریع.
</p>
<p className="mt-5 text-base font-light leading-9 text-ivory/65">
هر پروژه با مطالعه زمین، نور، اقلیم و الگوی زندگی ساکنان آغاز می‌شود؛ سپس تیم طراحی، سازه و اجرا در یک
فرآیند واحد آن را تا تحویل کلید پیش می‌برند.
</p>
</Reveal>
<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">۹۴٪</p>
<p className="mt-2 text-sm font-light text-ivory/55">تحویل به‌موقع پروژه‌ها</p>
</div>
<div>
<p className="text-2xl font-bold text-copper">۵ سال</p>
<p className="mt-2 text-sm font-light text-ivory/55">پشتیبانی پس از تحویل</p>
</div>
</div>
</Reveal>
{showLink && (
<Reveal delay={0.22}>
<ButtonLink to="/about" variant="outline" className="mt-10 font-light">
داستان کامل ما
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</Reveal>
)}
{children && (
<Reveal delay={0.24}>
{children}
</Reveal>
)}
</div>
<div className="grid grid-cols-2 gap-4">
{BENTO.map((item, i) => (
<Reveal key={item.src + i} delay={0.06 * i}>
<div className="overflow-hidden">
<img
src={item.src}
alt={item.alt}
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux hover:scale-105"
/>
</div>
</Reveal>
))}
</div>
</div>
</section>
)
}
+36
View File
@@ -0,0 +1,36 @@
import React from 'react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
export function Team() {
const { team } = useSite()
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="کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند."
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>
))}
</div>
</div>
</section>
)
}
@@ -0,0 +1,76 @@
import React, { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
import { SectionHeading } from '../SectionHeading';
import { useSite } from '../../api';
export function Testimonials() {
const { testimonials } = useSite();
const [index, setIndex] = useState(0);
const item = testimonials[index];
const go = (dir: number) => {
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
};
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="نظرات مشتریان" 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}
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>
</motion.blockquote>
</AnimatePresence>
<div className="mt-10 flex items-center justify-center gap-4">
<button
type="button"
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}
type="button"
role="tab"
aria-selected={i === index}
aria-label={`نظر ${t.name}`}
onClick={() => setIndex(i)}
className={`h-1.5 transition-[width,background-color] duration-200 ease-lux ${
i === index ? 'w-8 bg-copper' : 'w-3 bg-ivory/25'}`
} />
)}
</div>
<button
type="button"
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>
</div>
</div>
</section>);
}
+85
View File
@@ -0,0 +1,85 @@
import React, { useRef } from 'react'
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
import { useSite, type TimelineItem } from '../../api'
import { SectionHeading } from '../SectionHeading'
const ease = [0.23, 1, 0.32, 1] as const
type Milestone = TimelineItem
function Item({ item, index }: { item: Milestone; index: number }) {
const ref = useRef<HTMLLIElement>(null)
const active = useInView(ref, { once: true, amount: 0.6 })
const onRight = index % 2 === 0
return (
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
<span
aria-hidden="true"
className={`absolute right-[-5px] top-6 z-10 h-2.5 w-2.5 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
active
? 'bg-copper shadow-[0_0_0_5px_rgba(213,135,76,0.22)]'
: 'bg-ivory/25'
}`}
/>
<motion.div
className={`pr-8 md:pr-0 ${
onRight ? 'md:col-start-1 md:pl-16 md:text-left' : 'md:col-start-2 md:pr-16'
}`}
initial={{ opacity: 0, y: 24 }}
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>
</motion.div>
</li>
)
}
export function Timeline() {
const { timeline } = useSite()
const ref = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start 70%', 'end 60%'],
})
const scaleY = useTransform(scrollYProgress, [0, 1], [0, 1])
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="مسیر ما"
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید."
align="center"
/>
</div>
<div ref={ref} className="relative mx-auto max-w-4xl">
<span
aria-hidden="true"
className="absolute inset-y-0 right-0 w-px bg-ivory/12 md:right-1/2"
/>
<motion.span
aria-hidden="true"
className="absolute inset-y-0 right-0 w-px origin-top bg-copper md:right-1/2"
style={{ scaleY }}
/>
<ol className="flex flex-col gap-12 md:gap-16">
{timeline.map((item, i) => (
<Item key={item.year} item={item} index={i} />
))}
</ol>
</div>
</div>
</section>
)
}
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
import { Reveal } from '../Reveal';
import { SectionHeading } from '../SectionHeading';
import { IMAGES } from '../../data/site';
import { useSite } from '../../api';
export function WhyUs() {
const { why_us } = useSite();
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="چرا آرامش پلازا"
lead="شش دلیلی که کارفرمایان و سرمایه‌گذاران بار دوم هم به ما برمی‌گردند." />
<Reveal delay={0.12}>
<img
src={IMAGES[3]}
alt="نمای شبانه برج دوقلوی نیاوران"
className="mt-10 h-80 w-full object-cover" />
</Reveal>
</div>
<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}>
<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>
</div>
</Reveal>
)}
</div>
</div>
</div>
</section>);
}