Files
aramesh-backend/frontend/src/components/home/Stats.tsx
T
2026-09-25 22:49:30 +03:30

107 lines
3.5 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
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 useCounter(targetNumber: number, duration: number = 2000) {
const [count, setCount] = useState(0);
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
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() {
const { stats } = useSite();
const { t } = useLang();
const save = useFieldSaver();
return (
<section className="bg-char py-16" aria-label={t('statsAria')}>
<div className="mx-auto grid max-w-site grid-cols-2 gap-y-12 px-6 lg:grid-cols-4 lg:px-10">
{stats.map((stat) =>
<EditableItem key={stat.id} resource="stats" item={stat}>
<div className="text-center">
<p className="text-4xl font-bold text-copper md:text-5xl">
<Counter value={stat.value} />
<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="ltr:ml-1 rtl: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>
</div>
</EditableItem>
)}
<AddItemButton resource="stats" />
</div>
</section>);
}