first commit
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useLocale } from '@/lib/i18n/locale-context';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ParticleCanvas } from './ParticleCanvas';
|
||||
import { Typewriter } from './Typewriter';
|
||||
import { Counter } from '@/components/ui/Counter';
|
||||
|
||||
const fadeUp = (delay = 0) => ({
|
||||
initial: { opacity: 0, y: 28 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { duration: 0.7, ease: [0.22, 1, 0.36, 1], delay },
|
||||
});
|
||||
|
||||
export function Hero() {
|
||||
const { t, locale } = useLocale();
|
||||
|
||||
return (
|
||||
<section
|
||||
id="top"
|
||||
className={cn(
|
||||
'relative isolate overflow-hidden',
|
||||
// Full-screen on desktop, generous on mobile — leaves room for hero
|
||||
// metrics without forcing a scroll on first paint at 1080p.
|
||||
'min-h-[100svh] pt-28 pb-20 sm:pt-32',
|
||||
)}
|
||||
>
|
||||
{/* Particle network background */}
|
||||
<div className="pointer-events-none absolute inset-0 -z-10">
|
||||
<ParticleCanvas />
|
||||
{/* Edge fade so particles don't fight section seams */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-b from-transparent to-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center px-5 text-center sm:px-8">
|
||||
{/* Availability chip */}
|
||||
<motion.div {...fadeUp(0)} className="mb-7">
|
||||
<span className="chip">
|
||||
<span className="relative inline-flex h-2 w-2">
|
||||
<span className="absolute inset-0 animate-pulse-dot rounded-full bg-emerald" />
|
||||
<span className="relative inline-block h-2 w-2 rounded-full bg-emerald" />
|
||||
</span>
|
||||
{t.hero.availability}
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Eyebrow */}
|
||||
<motion.p
|
||||
{...fadeUp(0.08)}
|
||||
className="label-mono mb-6 inline-flex items-center gap-3 text-[clamp(0.65rem,1vw,0.75rem)]"
|
||||
>
|
||||
<span className="h-px w-10 bg-electric/60" aria-hidden />
|
||||
{t.hero.eyebrow}
|
||||
<span className="h-px w-10 bg-electric/60" aria-hidden />
|
||||
</motion.p>
|
||||
|
||||
{/* Name */}
|
||||
<motion.h1
|
||||
{...fadeUp(0.15)}
|
||||
className={cn(
|
||||
'font-display text-balance text-[clamp(2.4rem,7vw,5.4rem)] font-extrabold leading-[1.02] tracking-tight text-white',
|
||||
locale === 'fa' && 'font-fa',
|
||||
)}
|
||||
>
|
||||
{t.hero.name}
|
||||
</motion.h1>
|
||||
|
||||
{/* Headline */}
|
||||
<motion.p
|
||||
{...fadeUp(0.25)}
|
||||
className={cn(
|
||||
'mt-5 max-w-4xl text-balance text-[clamp(1.15rem,2.2vw,1.75rem)] font-medium leading-[1.25] text-slate-200',
|
||||
)}
|
||||
>
|
||||
{t.hero.headlineLead}{' '}
|
||||
<span className="gradient-text font-semibold">
|
||||
{t.hero.headlineAccent}
|
||||
</span>{' '}
|
||||
{t.hero.headlineTrail}
|
||||
</motion.p>
|
||||
|
||||
{/* Role typewriter */}
|
||||
<motion.div
|
||||
{...fadeUp(0.35)}
|
||||
className="mt-5 flex items-center gap-3 font-mono text-[clamp(0.9rem,1.4vw,1.05rem)] uppercase tracking-[0.15em] text-slate-400"
|
||||
>
|
||||
<span className="h-px w-6 bg-slate-700" aria-hidden />
|
||||
<Typewriter words={t.hero.roles} />
|
||||
<span className="h-px w-6 bg-slate-700" aria-hidden />
|
||||
</motion.div>
|
||||
|
||||
{/* Sub */}
|
||||
<motion.p
|
||||
{...fadeUp(0.42)}
|
||||
className="mt-7 max-w-2xl text-balance text-[clamp(0.95rem,1.4vw,1.08rem)] leading-relaxed text-slate-400"
|
||||
>
|
||||
{t.hero.sub}
|
||||
</motion.p>
|
||||
|
||||
{/* CTAs */}
|
||||
<motion.div
|
||||
{...fadeUp(0.5)}
|
||||
className="mt-9 flex flex-wrap items-center justify-center gap-3"
|
||||
>
|
||||
<a href="#contact" className="btn-primary">
|
||||
{t.hero.ctaPrimary}
|
||||
<Arrow locale={locale} />
|
||||
</a>
|
||||
<a href="#services" className="btn-ghost">
|
||||
{t.hero.ctaSecondary}
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
{/* Metrics */}
|
||||
<motion.div
|
||||
{...fadeUp(0.6)}
|
||||
className="mt-16 grid w-full max-w-4xl grid-cols-2 gap-4 sm:grid-cols-4"
|
||||
>
|
||||
{t.hero.metrics.map((m, i) => (
|
||||
<div
|
||||
key={m.label}
|
||||
className="glass relative overflow-hidden px-5 py-5 text-start"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-electric/50 to-transparent"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'font-display text-[clamp(1.6rem,3vw,2.25rem)] font-bold leading-none',
|
||||
// Cycle the accent colors across the 4 tiles
|
||||
[
|
||||
'text-electric',
|
||||
'text-violet',
|
||||
'text-magenta',
|
||||
'text-emerald',
|
||||
][i % 4],
|
||||
)}
|
||||
>
|
||||
<Counter value={m.value} locale={locale} />
|
||||
</div>
|
||||
<div className="mt-2 text-[0.78rem] leading-snug text-slate-400">
|
||||
{m.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Scroll cue */}
|
||||
<motion.a
|
||||
href="#services"
|
||||
{...fadeUp(0.75)}
|
||||
aria-label={t.hero.scroll}
|
||||
className="mt-14 inline-flex flex-col items-center gap-2 text-slate-500 transition-colors hover:text-slate-200"
|
||||
>
|
||||
<span className="label-mono">{t.hero.scroll}</span>
|
||||
<span className="relative block h-9 w-5 rounded-full border border-slate-700">
|
||||
<span className="absolute left-1/2 top-1.5 inline-block h-1.5 w-0.5 -translate-x-1/2 animate-float-y rounded-full bg-electric" />
|
||||
</span>
|
||||
</motion.a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Arrow({ locale }: { locale: 'fa' | 'en' }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={locale === 'fa' ? 'rotate-180' : ''}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M5 12 H19" />
|
||||
<path d="M13 6 L19 12 L13 18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Lightweight 2D hex-grid particle network.
|
||||
* - Nodes drift slowly, repelled by the cursor.
|
||||
* - Edges drawn between nearby nodes form a connection mesh.
|
||||
* - Pauses when the tab is hidden or the section scrolls offscreen.
|
||||
*/
|
||||
export function ParticleCanvas() {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const DPR = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let raf = 0;
|
||||
let running = true;
|
||||
const mouse = { x: -9999, y: -9999, active: false };
|
||||
|
||||
type Node = { x: number; y: number; vx: number; vy: number; r: number; hue: number };
|
||||
let nodes: Node[] = [];
|
||||
|
||||
const COLORS = [
|
||||
{ r: 56, g: 189, b: 248 }, // electric
|
||||
{ r: 129, g: 140, b: 248 }, // violet
|
||||
{ r: 232, g: 121, b: 249 }, // magenta
|
||||
{ r: 34, g: 211, b: 238 }, // cyan
|
||||
];
|
||||
|
||||
const seed = () => {
|
||||
const area = width * height;
|
||||
const density = window.matchMedia('(max-width: 640px)').matches ? 14000 : 9000;
|
||||
const count = Math.min(140, Math.max(40, Math.floor(area / density)));
|
||||
nodes = Array.from({ length: count }, () => ({
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
vx: (Math.random() - 0.5) * 0.18,
|
||||
vy: (Math.random() - 0.5) * 0.18,
|
||||
r: 0.8 + Math.random() * 1.6,
|
||||
hue: Math.floor(Math.random() * COLORS.length),
|
||||
}));
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
width = rect.width;
|
||||
height = rect.height;
|
||||
canvas.width = Math.floor(width * DPR);
|
||||
canvas.height = Math.floor(height * DPR);
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
seed();
|
||||
};
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
mouse.active = true;
|
||||
};
|
||||
const onLeave = () => {
|
||||
mouse.active = false;
|
||||
mouse.x = -9999;
|
||||
mouse.y = -9999;
|
||||
};
|
||||
|
||||
const onVisibility = () => {
|
||||
running = !document.hidden;
|
||||
if (running && !raf) raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
raf = 0;
|
||||
if (!running) return;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Drift + cursor repel
|
||||
for (const n of nodes) {
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
|
||||
// Wrap around edges
|
||||
if (n.x < -10) n.x = width + 10;
|
||||
else if (n.x > width + 10) n.x = -10;
|
||||
if (n.y < -10) n.y = height + 10;
|
||||
else if (n.y > height + 10) n.y = -10;
|
||||
|
||||
if (mouse.active) {
|
||||
const dx = n.x - mouse.x;
|
||||
const dy = n.y - mouse.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
const R = 140;
|
||||
if (d2 < R * R && d2 > 0.01) {
|
||||
const d = Math.sqrt(d2);
|
||||
const force = (R - d) / R;
|
||||
n.x += (dx / d) * force * 2.4;
|
||||
n.y += (dy / d) * force * 2.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edges
|
||||
const LINK_DIST = 130;
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const a = nodes[i];
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const b = nodes[j];
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 < LINK_DIST * LINK_DIST) {
|
||||
const d = Math.sqrt(d2);
|
||||
const alpha = (1 - d / LINK_DIST) * 0.35;
|
||||
const ca = COLORS[a.hue];
|
||||
const cb = COLORS[b.hue];
|
||||
const grad = ctx.createLinearGradient(a.x, a.y, b.x, b.y);
|
||||
grad.addColorStop(0, `rgba(${ca.r},${ca.g},${ca.b},${alpha})`);
|
||||
grad.addColorStop(1, `rgba(${cb.r},${cb.g},${cb.b},${alpha})`);
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = 0.7;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nodes
|
||||
for (const n of nodes) {
|
||||
const c = COLORS[n.hue];
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},0.85)`;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowColor = `rgba(${c.r},${c.g},${c.b},0.55)`;
|
||||
ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
resize();
|
||||
raf = requestAnimationFrame(tick);
|
||||
window.addEventListener('resize', resize);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseleave', onLeave);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', resize);
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseleave', onLeave);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={ref}
|
||||
aria-hidden
|
||||
className="absolute inset-0 h-full w-full"
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
words: readonly string[];
|
||||
typeSpeed?: number;
|
||||
eraseSpeed?: number;
|
||||
holdMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cycles through a list of phrases — types one in, holds, erases, advances.
|
||||
* Resets cleanly when `words` reference changes (e.g. locale switch).
|
||||
*/
|
||||
export function Typewriter({
|
||||
words,
|
||||
typeSpeed = 70,
|
||||
eraseSpeed = 40,
|
||||
holdMs = 1600,
|
||||
}: Props) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [text, setText] = useState('');
|
||||
const [phase, setPhase] = useState<'typing' | 'holding' | 'erasing'>('typing');
|
||||
|
||||
// Reset state when the words array identity changes (locale switch).
|
||||
useEffect(() => {
|
||||
setIndex(0);
|
||||
setText('');
|
||||
setPhase('typing');
|
||||
}, [words]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!words.length) return;
|
||||
const target = words[index % words.length];
|
||||
|
||||
if (phase === 'typing') {
|
||||
if (text === target) {
|
||||
const t = setTimeout(() => setPhase('erasing'), holdMs);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
const t = setTimeout(
|
||||
() => setText(target.slice(0, text.length + 1)),
|
||||
typeSpeed,
|
||||
);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
|
||||
if (phase === 'erasing') {
|
||||
if (text === '') {
|
||||
setIndex((i) => (i + 1) % words.length);
|
||||
setPhase('typing');
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => setText(text.slice(0, -1)), eraseSpeed);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [text, phase, index, words, typeSpeed, eraseSpeed, holdMs]);
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-0.5" aria-live="polite">
|
||||
<span className="gradient-text">{text}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block w-[2px] self-stretch translate-y-[2px] bg-electric animate-caret-blink"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user