100 lines
2.7 KiB
TypeScript
100 lines
2.7 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useRef, useState } from 'react';
|
||
|
||
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
|
||
|
||
function toAscii(str: string) {
|
||
return str.replace(/[۰-۹]/g, (d) =>
|
||
String(FA_DIGITS.indexOf(d as (typeof FA_DIGITS)[number])),
|
||
);
|
||
}
|
||
|
||
function toFa(n: number) {
|
||
return n.toString().replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
|
||
}
|
||
|
||
/**
|
||
* Parses a metric string like "18+", "۱۲ms", "99%", "۹۹٪" into a numeric
|
||
* target plus a trailing suffix that survives the count animation.
|
||
*/
|
||
function parse(value: string) {
|
||
const ascii = toAscii(value);
|
||
const match = ascii.match(/^(\d+(?:\.\d+)?)(.*)$/);
|
||
if (!match) return { target: 0, suffix: value, decimals: 0 };
|
||
const target = parseFloat(match[1]);
|
||
const decimals = match[1].includes('.') ? match[1].split('.')[1].length : 0;
|
||
return { target, suffix: match[2], decimals };
|
||
}
|
||
|
||
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
|
||
|
||
type Props = {
|
||
/** Final string, e.g. "18+", "۱۲ms", "99%" */
|
||
value: string;
|
||
/** Locale controls digit script in the rendered output */
|
||
locale: 'fa' | 'en';
|
||
/** Animation duration in ms */
|
||
duration?: number;
|
||
className?: string;
|
||
};
|
||
|
||
export function Counter({ value, locale, duration = 1600, className }: Props) {
|
||
const { target, suffix, decimals } = parse(value);
|
||
const [display, setDisplay] = useState(0);
|
||
const elRef = useRef<HTMLSpanElement>(null);
|
||
const started = useRef(false);
|
||
|
||
useEffect(() => {
|
||
const el = elRef.current;
|
||
if (!el) return;
|
||
|
||
const start = () => {
|
||
if (started.current) return;
|
||
started.current = true;
|
||
const t0 = performance.now();
|
||
const tick = (now: number) => {
|
||
const p = Math.min(1, (now - t0) / duration);
|
||
const eased = easeOutCubic(p);
|
||
setDisplay(target * eased);
|
||
if (p < 1) requestAnimationFrame(tick);
|
||
else setDisplay(target);
|
||
};
|
||
requestAnimationFrame(tick);
|
||
};
|
||
|
||
if (typeof IntersectionObserver === 'undefined') {
|
||
start();
|
||
return;
|
||
}
|
||
|
||
const io = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const e of entries) {
|
||
if (e.isIntersecting) {
|
||
start();
|
||
io.disconnect();
|
||
break;
|
||
}
|
||
}
|
||
},
|
||
{ threshold: 0.4 },
|
||
);
|
||
io.observe(el);
|
||
return () => io.disconnect();
|
||
}, [target, duration]);
|
||
|
||
const formatted = decimals
|
||
? display.toFixed(decimals)
|
||
: Math.round(display).toString();
|
||
const rendered = locale === 'fa' ? toFa(Number(formatted)) : formatted;
|
||
const sfx = locale === 'fa' ? suffix : toAscii(suffix);
|
||
|
||
return (
|
||
<span ref={elRef} className={className}>
|
||
{rendered}
|
||
{sfx}
|
||
</span>
|
||
);
|
||
}
|