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>
);
}
+147
View File
@@ -0,0 +1,147 @@
'use client';
import { useEffect, useRef, useState } from 'react';
const HOVER_SELECTOR =
'a, button, [role="button"], input, textarea, select, summary, [data-cursor-hover]';
export function CustomCursor() {
const dotRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLDivElement>(null);
const [enabled, setEnabled] = useState(false);
useEffect(() => {
// Only enable on desktop pointers (>= 900px and fine pointer)
const mq = window.matchMedia('(min-width: 900px) and (pointer: fine)');
const apply = () => {
const on = mq.matches;
setEnabled(on);
document.documentElement.classList.toggle('has-cursor', on);
};
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
}, []);
useEffect(() => {
if (!enabled) return;
let dotX = window.innerWidth / 2;
let dotY = window.innerHeight / 2;
let ringX = dotX;
let ringY = dotY;
let raf = 0;
const onMove = (e: MouseEvent) => {
dotX = e.clientX;
dotY = e.clientY;
};
const tick = () => {
// Ring lags the dot — trailing effect.
ringX += (dotX - ringX) * 0.18;
ringY += (dotY - ringY) * 0.18;
if (dotRef.current) {
dotRef.current.style.transform = `translate3d(${dotX}px, ${dotY}px, 0) translate(-50%, -50%)`;
}
if (ringRef.current) {
ringRef.current.style.transform = `translate3d(${ringX}px, ${ringY}px, 0) translate(-50%, -50%)`;
}
raf = requestAnimationFrame(tick);
};
const onOver = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
const isHover = !!target?.closest(HOVER_SELECTOR);
ringRef.current?.classList.toggle('cursor-ring--hover', isHover);
dotRef.current?.classList.toggle('cursor-dot--hover', isHover);
};
const onDown = () => ringRef.current?.classList.add('cursor-ring--down');
const onUp = () => ringRef.current?.classList.remove('cursor-ring--down');
const onLeave = () => {
ringRef.current?.classList.add('cursor--hidden');
dotRef.current?.classList.add('cursor--hidden');
};
const onEnter = () => {
ringRef.current?.classList.remove('cursor--hidden');
dotRef.current?.classList.remove('cursor--hidden');
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseover', onOver);
window.addEventListener('mousedown', onDown);
window.addEventListener('mouseup', onUp);
document.addEventListener('mouseleave', onLeave);
document.addEventListener('mouseenter', onEnter);
raf = requestAnimationFrame(tick);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseover', onOver);
window.removeEventListener('mousedown', onDown);
window.removeEventListener('mouseup', onUp);
document.removeEventListener('mouseleave', onLeave);
document.removeEventListener('mouseenter', onEnter);
cancelAnimationFrame(raf);
};
}, [enabled]);
if (!enabled) return null;
return (
<>
<style jsx global>{`
.cursor-dot,
.cursor-ring {
position: fixed;
left: 0;
top: 0;
pointer-events: none;
z-index: 9999;
will-change: transform;
transition:
width 0.25s ease,
height 0.25s ease,
background 0.25s ease,
border-color 0.25s ease,
opacity 0.25s ease;
}
.cursor-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: #38bdf8;
box-shadow: 0 0 14px rgba(56, 189, 248, 0.8);
mix-blend-mode: screen;
}
.cursor-ring {
width: 36px;
height: 36px;
border-radius: 999px;
border: 1.5px solid rgba(56, 189, 248, 0.55);
}
.cursor-dot--hover {
background: #e879f9;
box-shadow: 0 0 18px rgba(232, 121, 249, 0.85);
}
.cursor-ring--hover {
width: 56px;
height: 56px;
border-color: rgba(232, 121, 249, 0.7);
background: rgba(232, 121, 249, 0.05);
}
.cursor-ring--down {
width: 30px;
height: 30px;
border-color: rgba(255, 255, 255, 0.8);
}
.cursor--hidden {
opacity: 0;
}
`}</style>
<div ref={ringRef} className="cursor-ring" aria-hidden />
<div ref={dotRef} className="cursor-dot" aria-hidden />
</>
);
}
+50
View File
@@ -0,0 +1,50 @@
'use client';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
type Props = {
eyebrow: string;
title: string;
sub?: string;
align?: 'center' | 'start';
className?: string;
};
export function SectionHeader({
eyebrow,
title,
sub,
align = 'start',
className,
}: Props) {
const isCenter = align === 'center';
return (
<motion.header
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
className={cn(
'flex flex-col gap-4',
isCenter ? 'items-center text-center' : 'items-start',
'max-w-3xl',
isCenter && 'mx-auto',
className,
)}
>
<span className="label-mono inline-flex items-center gap-2">
<span className="h-px w-8 bg-electric/60" aria-hidden />
{eyebrow}
</span>
<h2 className="font-display text-balance text-[clamp(1.85rem,3.6vw,2.9rem)] font-semibold leading-[1.1] tracking-tight text-white">
{title}
</h2>
{sub && (
<p className="text-balance text-[clamp(0.98rem,1.4vw,1.1rem)] leading-relaxed text-slate-400">
{sub}
</p>
)}
</motion.header>
);
}