Files
soroushasadi/components/sections/Services.tsx
T
soroush.asadi add78d8460
ci / build (push) Failing after 23s
deploy / deploy (push) Failing after 10m12s
first commit
2026-05-31 12:47:02 +03:30

222 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}