first commit
ci / build (push) Failing after 23s
deploy / deploy (push) Failing after 10m12s

This commit is contained in:
soroush.asadi
2026-05-31 12:47:02 +03:30
commit add78d8460
100 changed files with 15221 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
'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>
);
}