Files
soroushasadi/components/ui/Counter.tsx
T
soroush.asadi add78d8460
ci / build (push) Failing after 23s
deploy / deploy (push) Failing after 10m12s
first commit
2026-05-31 12:47:02 +03:30

100 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}