222 lines
6.1 KiB
TypeScript
222 lines
6.1 KiB
TypeScript
'use client';
|
||
|
||
import { useRef } from 'react';
|
||
import { motion, useMotionTemplate, useMotionValue } from 'framer-motion';
|
||
import { useLocale } from '@/lib/i18n/locale-context';
|
||
import { SectionHeader } from '@/components/ui/SectionHeader';
|
||
import { cn } from '@/lib/utils';
|
||
import { ServiceIcon, type ServiceIconKind } from './ServiceIcon';
|
||
|
||
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
|
||
function num(n: number, locale: 'fa' | 'en') {
|
||
const str = n.toString().padStart(2, '0');
|
||
return locale === 'fa'
|
||
? str.replace(/\d/g, (d) => FA_DIGITS[Number(d)])
|
||
: str;
|
||
}
|
||
|
||
const COLOR_MAP: Record<
|
||
string,
|
||
{ text: string; ring: string; glow: string; chip: string }
|
||
> = {
|
||
electric: {
|
||
text: 'text-electric',
|
||
ring: 'group-hover:border-electric/50',
|
||
glow: 'group-hover:shadow-glow-electric',
|
||
chip: 'border-electric/30 bg-electric/5 text-electric/90',
|
||
},
|
||
violet: {
|
||
text: 'text-violet',
|
||
ring: 'group-hover:border-violet/50',
|
||
glow: 'group-hover:shadow-glow-violet',
|
||
chip: 'border-violet/30 bg-violet/5 text-violet/90',
|
||
},
|
||
magenta: {
|
||
text: 'text-magenta',
|
||
ring: 'group-hover:border-magenta/50',
|
||
glow: 'group-hover:shadow-glow-magenta',
|
||
chip: 'border-magenta/30 bg-magenta/5 text-magenta/90',
|
||
},
|
||
emerald: {
|
||
text: 'text-emerald',
|
||
ring: 'group-hover:border-emerald/50',
|
||
glow: 'group-hover:shadow-glow-emerald',
|
||
chip: 'border-emerald/30 bg-emerald/5 text-emerald/90',
|
||
},
|
||
cyan: {
|
||
text: 'text-cyan',
|
||
ring: 'group-hover:border-cyan/50',
|
||
glow: 'group-hover:shadow-glow-electric',
|
||
chip: 'border-cyan/30 bg-cyan/5 text-cyan/90',
|
||
},
|
||
};
|
||
|
||
export function Services() {
|
||
const { t, locale } = useLocale();
|
||
|
||
return (
|
||
<section id="services" className="relative px-5 py-28 sm:px-8">
|
||
<div className="mx-auto max-w-7xl">
|
||
<SectionHeader
|
||
eyebrow={t.services.eyebrow}
|
||
title={t.services.title}
|
||
sub={t.services.sub}
|
||
/>
|
||
|
||
<div className="mt-14 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||
{t.services.items.map((item, i) => (
|
||
<ServiceCard
|
||
key={item.id}
|
||
index={i}
|
||
numLabel={num(i + 1, locale)}
|
||
title={item.title}
|
||
description={item.description}
|
||
tags={item.tags}
|
||
color={item.color}
|
||
iconKind={item.id as ServiceIconKind}
|
||
href={`/services/${item.id}`}
|
||
locale={locale}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ServiceCard({
|
||
index,
|
||
numLabel,
|
||
title,
|
||
description,
|
||
tags,
|
||
color,
|
||
iconKind,
|
||
href,
|
||
locale,
|
||
}: {
|
||
index: number;
|
||
numLabel: string;
|
||
title: string;
|
||
description: string;
|
||
tags: readonly string[];
|
||
color: string;
|
||
iconKind: ServiceIconKind;
|
||
href: string;
|
||
locale: 'fa' | 'en';
|
||
}) {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
const mx = useMotionValue(50);
|
||
const my = useMotionValue(50);
|
||
const rotateX = useMotionValue(0);
|
||
const rotateY = useMotionValue(0);
|
||
|
||
// Subtle 3D tilt on pointer move — keeps the card "alive" without
|
||
// forcing GPU work when the cursor isn't over it.
|
||
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||
const el = ref.current;
|
||
if (!el) return;
|
||
const r = el.getBoundingClientRect();
|
||
const x = (e.clientX - r.left) / r.width;
|
||
const y = (e.clientY - r.top) / r.height;
|
||
mx.set(x * 100);
|
||
my.set(y * 100);
|
||
rotateY.set((x - 0.5) * 8);
|
||
rotateX.set((0.5 - y) * 8);
|
||
};
|
||
const onPointerLeave = () => {
|
||
rotateX.set(0);
|
||
rotateY.set(0);
|
||
};
|
||
|
||
const spotlight = useMotionTemplate`radial-gradient(220px circle at ${mx}% ${my}%, rgba(255,255,255,0.08), transparent 60%)`;
|
||
|
||
const c = COLOR_MAP[color] ?? COLOR_MAP.electric;
|
||
|
||
return (
|
||
<motion.article
|
||
ref={ref}
|
||
onPointerMove={onPointerMove}
|
||
onPointerLeave={onPointerLeave}
|
||
style={{
|
||
rotateX,
|
||
rotateY,
|
||
transformStyle: 'preserve-3d',
|
||
transformPerspective: 1000,
|
||
}}
|
||
initial={{ opacity: 0, y: 30 }}
|
||
whileInView={{ opacity: 1, y: 0 }}
|
||
viewport={{ once: true, margin: '-60px' }}
|
||
transition={{
|
||
duration: 0.6,
|
||
ease: [0.22, 1, 0.36, 1],
|
||
delay: 0.05 * index,
|
||
}}
|
||
className={cn(
|
||
'group relative isolate overflow-hidden p-6 sm:p-7',
|
||
'glass transition-all duration-300',
|
||
c.ring,
|
||
c.glow,
|
||
)}
|
||
>
|
||
{/* Spotlight */}
|
||
<motion.div
|
||
aria-hidden
|
||
style={{ background: spotlight }}
|
||
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||
/>
|
||
|
||
{/* Number + icon row */}
|
||
<div className="relative flex items-start justify-between">
|
||
<span
|
||
className={cn(
|
||
'font-mono text-[0.78rem] tracking-[0.18em] text-slate-500',
|
||
locale === 'fa' && 'fa-nums',
|
||
)}
|
||
>
|
||
{numLabel}
|
||
</span>
|
||
<span className={cn('transition-colors duration-300', c.text)}>
|
||
<ServiceIcon kind={iconKind} className="h-7 w-7" />
|
||
</span>
|
||
</div>
|
||
|
||
{/* Title */}
|
||
<h3
|
||
className={cn(
|
||
'relative mt-6 font-display text-[clamp(1.15rem,1.8vw,1.4rem)] font-semibold leading-snug text-white',
|
||
locale === 'fa' && 'font-fa',
|
||
)}
|
||
>
|
||
{title}
|
||
</h3>
|
||
|
||
{/* Description */}
|
||
<p className="relative mt-3 text-[0.94rem] leading-relaxed text-slate-400">
|
||
{description}
|
||
</p>
|
||
|
||
{/* Tags */}
|
||
<div className="relative mt-5 flex flex-wrap gap-1.5">
|
||
{tags.map((tag) => (
|
||
<span
|
||
key={tag}
|
||
className={cn(
|
||
'rounded-full border px-2.5 py-0.5 font-mono text-[0.65rem] uppercase tracking-wider',
|
||
c.chip,
|
||
)}
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
{/* Hairline */}
|
||
<span
|
||
aria-hidden
|
||
className="absolute inset-x-6 bottom-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent"
|
||
/>
|
||
</motion.article>
|
||
);
|
||
}
|