feat(ui): update UI v1
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
|
||||
import { Reveal } from '../Reveal';
|
||||
import { ButtonLink } from '../Button';
|
||||
import { PremiumButton } from '../PremiumButton';
|
||||
import { ArticleCard } from '../ArticleCard';
|
||||
import { useSectionHeading, useSite } from '../../api';
|
||||
import { useLang } from '../../i18n/lang';
|
||||
@@ -53,10 +53,9 @@ export function Articles() {
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light">
|
||||
<PremiumButton to={href(heading.cta_href)} className="font-light">
|
||||
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</PremiumButton>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
@@ -18,20 +18,22 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
|
||||
<div className="mx-auto max-w-site px-4 md:px-8 lg:px-10">
|
||||
|
||||
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
|
||||
|
||||
{/* Header — centered, full width */}
|
||||
{!hideHeading && (
|
||||
<div className="lg:col-span-4">
|
||||
<div className="mb-8 md:mb-14 flex flex-col items-center w-full text-center">
|
||||
<SectionHeading
|
||||
title={t('fallbackFaqTitle')}
|
||||
lead={t('fallbackFaqLead')}
|
||||
headingKey="faq"
|
||||
align="center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={hideHeading ? "" : "lg:col-span-8"}>
|
||||
{/* Accordion list — constrained width, direction-aware */}
|
||||
<div className="w-full max-w-3xl mx-auto">
|
||||
<ul>
|
||||
{faqs.map((faq, i) => {
|
||||
const isOpen = open === i
|
||||
@@ -43,9 +45,9 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||
<span className={`text-base font-bold md:text-lg text-start ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||
{faq.question}
|
||||
</span>
|
||||
<PlusIcon
|
||||
@@ -63,7 +65,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
|
||||
<p className="pb-7 ps-10 text-sm font-light leading-9 text-ivory/60 text-start">{faq.answer}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { ProjectCard } from '../ProjectCard'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
import type { ProjectStatus } from '../../data/site'
|
||||
import { projectsData } from '../../data/projectsData'
|
||||
|
||||
export function FeaturedProjects() {
|
||||
const { projects, status_labels } = useSite()
|
||||
const { status_labels } = useSite()
|
||||
const { t, href } = useLang()
|
||||
const heading = useSectionHeading('featured_projects')
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveStatusLabel = (key: string, value: string) => {
|
||||
const label = status_labels.find((s) => s.key === key)
|
||||
if (!label) return
|
||||
@@ -30,8 +31,12 @@ export function FeaturedProjects() {
|
||||
{ key: 'done', label: useStatusLabel('done') },
|
||||
{ key: 'progress', label: useStatusLabel('progress') },
|
||||
]
|
||||
|
||||
const [status, setStatus] = useState<ProjectStatus>('done')
|
||||
const list = projects.filter((p) => p.status === status).slice(0, 3)
|
||||
|
||||
// Filter and slice static project data
|
||||
const list = projectsData.filter((p) => p.status === status).slice(0, 3)
|
||||
const BASE_IMG_URL = "/seed-media/2026/09/";
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
|
||||
@@ -44,7 +49,6 @@ export function FeaturedProjects() {
|
||||
const isSelected = status === filter.key
|
||||
return (
|
||||
<React.Fragment key={filter.key}>
|
||||
{/* خط جداکننده بین دکمهها */}
|
||||
{index > 0 && (
|
||||
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
||||
)}
|
||||
@@ -82,19 +86,26 @@ export function FeaturedProjects() {
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="grid gap-6 lg:grid-cols-3"
|
||||
>
|
||||
{list.map((project, i) => (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full"
|
||||
>
|
||||
<EditableItem resource="projects" item={project} className="h-full">
|
||||
<ProjectCard project={project} />
|
||||
</EditableItem>
|
||||
</motion.div>
|
||||
))}
|
||||
{list.map((project, i) => {
|
||||
const dummyImageName = `${(i % 6) + 1}.png`;
|
||||
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full"
|
||||
>
|
||||
<ProjectCard
|
||||
project={project}
|
||||
imageUrl={imageUrl}
|
||||
detailHref={`/projects/${project.slug}`}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -103,11 +114,7 @@ export function FeaturedProjects() {
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink
|
||||
to={href(heading.cta_href)}
|
||||
variant="outline"
|
||||
className="group flex items-center gap-3 rounded-full border border-white/20 bg-white/[0.04] px-8 py-3.5 text-sm backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_30px_rgba(197,143,92,0.35)]"
|
||||
>
|
||||
<PremiumButton to={href(heading.cta_href)}>
|
||||
<InlineEdit
|
||||
value={heading.cta_label}
|
||||
onSave={(v) =>
|
||||
@@ -117,10 +124,7 @@ export function FeaturedProjects() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100" />
|
||||
</span>
|
||||
</ButtonLink>
|
||||
</PremiumButton>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function Gallery() {
|
||||
<AnimatePresence>
|
||||
{active !== null && gallery[active] && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
|
||||
className="fixed inset-0 z-[80] flex flex-col items-center justify-center bg-ink p-4 md:p-8"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
@@ -74,49 +74,71 @@ export function Gallery() {
|
||||
aria-label={t('viewImage')}
|
||||
onClick={close}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="absolute top-8 ltr:right-8 rtl:left-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
className="absolute top-6 right-6 md:top-8 md:right-8 z-10 flex h-10 w-10 md:h-11 md:w-11 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
step(-1)
|
||||
}}
|
||||
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:right-6 md:rtl:right-10 ltr:left-6 md:ltr:left-10"
|
||||
aria-label={t('prevImage')}
|
||||
>
|
||||
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
step(1)
|
||||
}}
|
||||
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:left-6 md:rtl:left-10 ltr:right-6 md:ltr:right-10"
|
||||
aria-label={t('nextImage')}
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
key={active}
|
||||
src={gallery[active].src}
|
||||
alt=""
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="max-h-[85vh] w-auto max-w-[85vw] object-contain shadow-[0_30px_80px_-20px_rgba(0,0,0,0.9)]"
|
||||
{/* Flex Container for Prev, Image, Next */}
|
||||
<div
|
||||
className="relative z-10 flex w-full max-w-7xl flex-1 items-center justify-center gap-4 md:gap-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
||||
aria-label={t('prevImage')}
|
||||
>
|
||||
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
key={active}
|
||||
src={gallery[active].src}
|
||||
alt={gallery[active].alt}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="max-h-[75vh] w-auto max-w-[calc(100%-7rem)] rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
||||
aria-label={t('nextImage')}
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modern Dot Pagination */}
|
||||
<div
|
||||
className="absolute bottom-6 md:bottom-10 flex items-center justify-center gap-2 z-10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{gallery.map((_, idx) => {
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setActive(idx)}
|
||||
aria-label={`${t('viewImage')} ${formatNumber(idx + 1, lang)}`}
|
||||
className={`h-2.5 rounded-full transition-all duration-300 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory ${
|
||||
isActive ? 'w-6 bg-copper' : 'w-2.5 bg-white/50 hover:bg-white/80'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
@@ -84,45 +84,20 @@ export function Hero() {
|
||||
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-10 flex flex-wrap items-center gap-4"
|
||||
>
|
||||
{/* دکمه اصلی: کپسولی شیشهای دودی با دایره کهربایی اختصاصی برای آیکون */}
|
||||
<ButtonLink
|
||||
to={href(hero.cta_primary_href)}
|
||||
variant="copper"
|
||||
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
|
||||
>
|
||||
<span className="text-sm font-medium tracking-wide text-ivory">
|
||||
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||
</span>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1">
|
||||
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5] ltr:-scale-x-100" />
|
||||
</span>
|
||||
</ButtonLink>
|
||||
{/* دکمه اصلی */}
|
||||
<PremiumButton to={href(hero.cta_primary_href)}>
|
||||
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||
</PremiumButton>
|
||||
|
||||
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
|
||||
<ButtonLink
|
||||
to={href(hero.cta_secondary_href)}
|
||||
variant="outline"
|
||||
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
|
||||
>
|
||||
{/* دکمه ثانویه */}
|
||||
<PremiumButton to={href(hero.cta_secondary_href)} className="bg-char/40 backdrop-blur-md">
|
||||
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
|
||||
</ButtonLink>
|
||||
</PremiumButton>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* کادر آمار شناور (گوشه پایین چپ): شیشهای با لبههای گرد مدرن */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:ltr:right-10 lg:rtl:left-10"
|
||||
>
|
||||
<p className="text-3xl font-bold text-copper font-mono">
|
||||
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
|
||||
<InlineEdit multiline value={hero.badge_label} onSave={(v) => saveHero('badge_label', v)} />
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
</motion.div>
|
||||
|
||||
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from 'react'
|
||||
import { CheckIcon, SendIcon } from 'lucide-react'
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { Button } from '../Button'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
@@ -98,7 +98,7 @@ export function Newsletter() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-r from-copper via-[#d8965e] to-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
|
||||
<section className="relative overflow-hidden bg-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
|
||||
<SingletonPill resource="newsletter" label="ویرایش متنهای خبرنامه" className="absolute left-4 top-4" />
|
||||
|
||||
{/* هاله نور پسزمینه برای عمق بصری */}
|
||||
@@ -142,19 +142,18 @@ export function Newsletter() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
<button
|
||||
type="submit"
|
||||
variant="dark"
|
||||
disabled={sending}
|
||||
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
|
||||
className="group/btn relative inline-flex items-center justify-center overflow-hidden rounded-full px-10 py-4 text-sm font-bold tracking-widest text-white border border-ink bg-ink transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg cursor-pointer select-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
<span>
|
||||
<span className="absolute inset-0 z-0 bg-white scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
|
||||
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
|
||||
{sending ? newsletter.sending_label : (
|
||||
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</button>
|
||||
</form>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,68 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useSite } from '../../api';
|
||||
import { useLang, formatNumber } from '../../i18n/lang';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
|
||||
function Counter({ value }: {value: number;}) {
|
||||
const { lang } = useLang();
|
||||
function useCounter(targetNumber: number, duration: number = 2000) {
|
||||
const [count, setCount] = useState(0);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const inView = useInView(ref, { once: true, margin: '-60px' });
|
||||
const [display, setDisplay] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView) return;
|
||||
let frame = 0;
|
||||
const total = 60;
|
||||
const id = window.setInterval(() => {
|
||||
frame += 1;
|
||||
const progress = 1 - Math.pow(1 - frame / total, 3);
|
||||
setDisplay(Math.round(value * progress));
|
||||
if (frame >= total) window.clearInterval(id);
|
||||
}, 20);
|
||||
return () => window.clearInterval(id);
|
||||
}, [inView, value]);
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
return <span ref={ref}>{formatNumber(display, lang)}</span>;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
observer.disconnect();
|
||||
|
||||
let startTime: number | null = null;
|
||||
const step = (timestamp: number) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const progress = timestamp - startTime;
|
||||
|
||||
// ease-out cubic
|
||||
const easeOut = progress >= duration ? 1 : 1 - Math.pow(1 - progress / duration, 3);
|
||||
|
||||
setCount(Math.floor(easeOut * targetNumber));
|
||||
|
||||
if (progress < duration) {
|
||||
window.requestAnimationFrame(step);
|
||||
} else {
|
||||
setCount(targetNumber);
|
||||
}
|
||||
};
|
||||
|
||||
window.requestAnimationFrame(step);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [targetNumber, duration]);
|
||||
|
||||
return { count, ref };
|
||||
}
|
||||
|
||||
function Counter({ value }: { value: string | number }) {
|
||||
const { lang } = useLang();
|
||||
|
||||
const strValue = String(value);
|
||||
// Match the first sequence of digits
|
||||
const numMatch = strValue.match(/(\d+)/);
|
||||
const targetNumber = numMatch ? parseInt(numMatch[0], 10) : 0;
|
||||
|
||||
const { count, ref } = useCounter(targetNumber, 1500);
|
||||
|
||||
// If there's a number, replace it with the animated (and localized) count, otherwise render string as is.
|
||||
const formattedDisplay = formatNumber(count, lang);
|
||||
const renderValue = numMatch ? strValue.replace(numMatch[0], formattedDisplay) : strValue;
|
||||
|
||||
return <span ref={ref}>{renderValue}</span>;
|
||||
}
|
||||
|
||||
export function Stats() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
@@ -89,10 +89,9 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
||||
|
||||
{showLink && (
|
||||
<Reveal delay={0.22}>
|
||||
<ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light">
|
||||
<PremiumButton to={href(story.link_href)} className="mt-10 font-light">
|
||||
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</PremiumButton>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ export function Team() {
|
||||
const { team } = useSite()
|
||||
const { t } = useLang()
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title" >
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10 ">
|
||||
<SectionHeading
|
||||
title={t('fallbackTeamTitle')}
|
||||
lead={t('fallbackTeamLead')}
|
||||
@@ -22,7 +22,8 @@ export function Team() {
|
||||
{team.map((member, i) => (
|
||||
<Reveal key={member.id} delay={0.06 * i} className="h-full">
|
||||
<EditableItem resource="team" item={member} className="h-full">
|
||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
|
||||
{/* اضافه شدن rounded-2xl برای گرد شدن گوشهها */}
|
||||
<article className="group flex h-full flex-col items-center rounded-2xl border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
|
||||
{member.name.trim().charAt(0)}
|
||||
</div>
|
||||
@@ -40,4 +41,4 @@ export function Team() {
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
||||
|
||||
return (
|
||||
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
|
||||
{/* دایرهها: مرکز دایره در موبایل و دسکتاپ دقیقاً روی مرکز خط 2px است */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute right-[-5px] top-8 z-10 h-3 w-3 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
|
||||
@@ -35,7 +36,6 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
||||
>
|
||||
<EditableItem resource="timeline" item={item}>
|
||||
<div className="group relative overflow-hidden rounded-2xl border border-ivory/10 bg-ink/40 p-6 backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-1.5 hover:border-copper/50 hover:bg-ink/60 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] md:p-8">
|
||||
{/* لایه نوری ظریف که در هاور روشن میشود */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
||||
|
||||
<span className="relative z-10 text-sm font-medium tracking-widest text-copper">{item.year}</span>
|
||||
@@ -72,13 +72,15 @@ export function Timeline() {
|
||||
</div>
|
||||
|
||||
<div ref={ref} className="relative mx-auto mt-20 max-w-4xl">
|
||||
{/* خط پسزمینه ثابت - عرض 2px و همراستایی دقیق با مرکز */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-px bg-ivory/10 md:right-1/2"
|
||||
className="absolute inset-y-0 right-0 w-[2px] bg-ivory/10 md:right-1/2 md:translate-x-1/2"
|
||||
/>
|
||||
{/* خط متحرک - عرض 2px و همراستایی دقیق با مرکز */}
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-px origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2"
|
||||
className="absolute inset-y-0 right-0 w-[2px] origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2 md:translate-x-1/2"
|
||||
style={{ scaleY }}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user