first commit
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user