first commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user