'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(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 ( {rendered} {sfx} ); }