107 lines
3.5 KiB
TypeScript
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>);
|
|
|
|
}
|