feat: complete agency site build (Phases 1-7)
Full Next.js 16 + Payload CMS 3.x agency site with: - Homepage: Hero, TrustBar, Services, Configurator wizard, Process, Selected Works, Philosophy, CTA Banner - Sub-pages: /services (3 pillars + AI Layer), /work/[slug] (case studies), /about (philosophy + story) - Configurator: 3-step wizard with AI brief generation API - i18n: Full EN/FR bilingual with next-intl - Design system: Cormorant Garamond + Inter, celestial blue palette, glassmorphism nav, Framer Motion animations - Payload CMS collections: Projects, Services, Submissions, Media Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
224
src/components/sections/CTABanner.tsx
Normal file
224
src/components/sections/CTABanner.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Mail } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { viewportOnce } from '@/lib/animations';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
// The full-width background clips in from the center (scaleX)
|
||||
const bgScaleVariants = {
|
||||
hidden: { scaleX: 0, opacity: 0 },
|
||||
visible: {
|
||||
scaleX: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.9,
|
||||
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Content fades up slightly after the background arrives
|
||||
const contentContainerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.35,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const contentItemVariants = {
|
||||
hidden: { opacity: 0, y: 28 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Decorative skewed band (white-tinted)
|
||||
function DecorativeBand() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
{/* Large skewed element — upper-left to center */}
|
||||
<div
|
||||
className="absolute -top-24 -left-32 w-[520px] h-[340px] bg-white/[0.04] rounded-3xl"
|
||||
style={{ transform: 'rotate(-14deg) skewX(-8deg)' }}
|
||||
/>
|
||||
|
||||
{/* Smaller skewed element — lower-right */}
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-[360px] h-[220px] bg-white/[0.05] rounded-2xl"
|
||||
style={{ transform: 'rotate(10deg) skewX(6deg) translate(60px, 40px)' }}
|
||||
/>
|
||||
|
||||
{/* Fine diagonal line accent — top-left */}
|
||||
<div
|
||||
className="absolute top-8 left-12 w-[160px] h-[1px] bg-white/10"
|
||||
style={{ transform: 'rotate(-30deg)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-12 left-16 w-[100px] h-[1px] bg-white/8"
|
||||
style={{ transform: 'rotate(-30deg)' }}
|
||||
/>
|
||||
|
||||
{/* Dot cluster — top-right */}
|
||||
{[0, 1, 2, 3, 4].map((col) =>
|
||||
[0, 1, 2].map((row) => (
|
||||
<div
|
||||
key={`dot-${col}-${row}`}
|
||||
className="absolute w-[3px] h-[3px] rounded-full bg-white/10"
|
||||
style={{
|
||||
top: `${28 + row * 14}px`,
|
||||
right: `${80 + col * 14}px`,
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
|
||||
{/* Subtle arc — bottom center */}
|
||||
<svg
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2"
|
||||
width="600"
|
||||
height="120"
|
||||
viewBox="0 0 600 120"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M 0 120 Q 300 40 600 120"
|
||||
stroke="rgba(255,255,255,0.05)"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M 60 120 Q 300 60 540 120"
|
||||
stroke="rgba(255,255,255,0.04)"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
strokeDasharray="4 10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CTABanner() {
|
||||
const t = useTranslations('cta');
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Call to action"
|
||||
className="relative overflow-hidden"
|
||||
>
|
||||
{/* Animated background that clips in from center */}
|
||||
<motion.div
|
||||
variants={bgScaleVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="absolute inset-0 origin-center"
|
||||
style={{ backgroundColor: '#006494' }}
|
||||
>
|
||||
<DecorativeBand />
|
||||
</motion.div>
|
||||
|
||||
{/* Content — positioned relative so it sits above the bg */}
|
||||
<div className="relative z-10 py-24 px-6">
|
||||
<motion.div
|
||||
variants={contentContainerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="max-w-3xl mx-auto flex flex-col items-center text-center gap-8"
|
||||
>
|
||||
{/* Eyebrow */}
|
||||
<motion.span
|
||||
variants={contentItemVariants}
|
||||
className="label-md text-white/60 tracking-widest uppercase"
|
||||
>
|
||||
{t('eyebrow')}
|
||||
</motion.span>
|
||||
|
||||
{/* Headline */}
|
||||
<motion.h2
|
||||
variants={contentItemVariants}
|
||||
className={cn(
|
||||
'font-serif font-semibold text-white',
|
||||
'text-4xl md:text-5xl leading-[1.1] tracking-[-0.02em]',
|
||||
)}
|
||||
>
|
||||
{t('title')}
|
||||
</motion.h2>
|
||||
|
||||
{/* Supporting text */}
|
||||
<motion.p
|
||||
variants={contentItemVariants}
|
||||
className="text-white/70 text-lg leading-relaxed max-w-xl"
|
||||
>
|
||||
{t('subtitle')}
|
||||
</motion.p>
|
||||
|
||||
{/* CTA row */}
|
||||
<motion.div
|
||||
variants={contentItemVariants}
|
||||
className="flex flex-col sm:flex-row items-center gap-4"
|
||||
>
|
||||
{/* Primary CTA — white-styled button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
arrow
|
||||
href="#configure"
|
||||
className={cn(
|
||||
'ring-white/80 text-white',
|
||||
'hover:bg-white/10 hover:ring-white',
|
||||
'focus-visible:ring-white',
|
||||
)}
|
||||
>
|
||||
{t('cta')}
|
||||
</Button>
|
||||
|
||||
{/* Email link — ghost style */}
|
||||
<a
|
||||
href="mailto:hello@letsbe.biz"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-6 py-3 rounded-xl',
|
||||
'text-white/80 text-sm font-medium',
|
||||
'transition-all duration-200',
|
||||
'hover:text-white hover:bg-white/8',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#006494]',
|
||||
)}
|
||||
>
|
||||
<Mail
|
||||
size={16}
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
hello@letsbe.biz
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
{/* Fine-print reassurance */}
|
||||
<motion.p
|
||||
variants={contentItemVariants}
|
||||
className="text-white/40 text-xs"
|
||||
>
|
||||
{t('reassurance')}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
114
src/components/sections/Configurator.tsx
Normal file
114
src/components/sections/Configurator.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { motion } from 'framer-motion';
|
||||
import { revealVariants, staggerContainer, viewportOnce } from '@/lib/animations';
|
||||
import WizardContainer from '@/components/configurator/WizardContainer';
|
||||
|
||||
// ─── Step indicator dot ───────────────────────────────────────────────────────
|
||||
|
||||
interface StepDotProps {
|
||||
index: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function StepDot({ index, label }: StepDotProps) {
|
||||
return (
|
||||
<motion.div variants={revealVariants} className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded-full border border-outline-variant/60 bg-surface-high flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[10px] font-semibold text-outline">{index}</span>
|
||||
</div>
|
||||
<span className="text-sm text-outline">{label}</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Configurator() {
|
||||
const t = useTranslations('configurator');
|
||||
|
||||
const steps = [
|
||||
t('step1.title'),
|
||||
t('step2.title'),
|
||||
t('step3.title'),
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="configure" className="bg-surface-low py-24">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="grid grid-cols-1 gap-12 lg:grid-cols-12">
|
||||
|
||||
{/* ── Left: Sticky context panel ─────────────────────────────── */}
|
||||
<div className="lg:col-span-5">
|
||||
<div className="lg:sticky lg:top-24">
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{/* Eyebrow */}
|
||||
<motion.span
|
||||
variants={revealVariants}
|
||||
className="label-md text-primary"
|
||||
>
|
||||
{t('eyebrow')}
|
||||
</motion.span>
|
||||
|
||||
{/* H2 */}
|
||||
<motion.h2
|
||||
variants={revealVariants}
|
||||
className="font-serif text-4xl font-semibold tracking-headline text-on-surface leading-tight md:text-5xl"
|
||||
>
|
||||
{t('title')}
|
||||
</motion.h2>
|
||||
|
||||
{/* Description */}
|
||||
<motion.p
|
||||
variants={revealVariants}
|
||||
className="text-base text-outline leading-relaxed max-w-sm"
|
||||
>
|
||||
{t('description')}
|
||||
</motion.p>
|
||||
|
||||
{/* Step indicators */}
|
||||
<motion.div
|
||||
variants={revealVariants}
|
||||
className="flex flex-col gap-3 pt-2"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-label text-outline/70">
|
||||
How it works
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{steps.map((step, i) => (
|
||||
<StepDot key={i} index={i + 1} label={step} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Trust signal */}
|
||||
<motion.div
|
||||
variants={revealVariants}
|
||||
className="pt-2 flex items-center gap-2.5"
|
||||
>
|
||||
<div className="h-px flex-1 bg-outline-variant/40" />
|
||||
<p className="text-xs text-outline">No commitment required</p>
|
||||
<div className="h-px flex-1 bg-outline-variant/40" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: Wizard ───────────────────────────────────────────── */}
|
||||
<div className="lg:col-span-7">
|
||||
<div className="rounded-2xl bg-surface-high shadow-subtle p-6 sm:p-8">
|
||||
<WizardContainer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
294
src/components/sections/Hero.tsx
Normal file
294
src/components/sections/Hero.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { viewportOnce } from '@/lib/animations';
|
||||
import Button from '@/components/ui/Button';
|
||||
import HeroGeometric from '@/components/icons/HeroGeometric';
|
||||
|
||||
// Slow drift animation for the SVG background layers
|
||||
const bgDriftA = {
|
||||
animate: {
|
||||
y: [0, -12, 0],
|
||||
x: [0, 6, 0],
|
||||
transition: {
|
||||
duration: 18,
|
||||
ease: 'easeInOut' as const,
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const bgDriftB = {
|
||||
animate: {
|
||||
y: [0, 8, 0],
|
||||
x: [0, -8, 0],
|
||||
scale: [1, 1.015, 1],
|
||||
transition: {
|
||||
duration: 24,
|
||||
ease: 'easeInOut' as const,
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const bgRotate = {
|
||||
animate: {
|
||||
rotate: [0, 1.5, 0, -1.5, 0],
|
||||
transition: {
|
||||
duration: 30,
|
||||
ease: 'easeInOut' as const,
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Word-level stagger container — fires on mount (not scroll)
|
||||
const heroHeadlineContainer = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.07,
|
||||
delayChildren: 0.15,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Individual word reveal
|
||||
const wordReveal = {
|
||||
hidden: { opacity: 0, y: 48, filter: 'blur(4px)' },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: 'blur(0px)',
|
||||
transition: {
|
||||
duration: 0.7,
|
||||
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Subtitle fade — delay after headline
|
||||
const subtitleVariant = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
delay: 0.5,
|
||||
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// CTA scale-in
|
||||
const ctaVariant = {
|
||||
hidden: { opacity: 0, scale: 0.93 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.45,
|
||||
delay: 0.7,
|
||||
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Trust strip slide in from left
|
||||
const trustVariant = {
|
||||
hidden: { opacity: 0, x: -32 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 0.55,
|
||||
delay: 0.9,
|
||||
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Gradient stops for each avatar circle — subtle variations on the primary palette
|
||||
const AVATAR_GRADIENTS = [
|
||||
'linear-gradient(135deg, rgba(0,100,148,0.35), rgba(91,164,217,0.45))',
|
||||
'linear-gradient(135deg, rgba(91,164,217,0.40), rgba(0,100,148,0.30))',
|
||||
'linear-gradient(135deg, rgba(0,100,148,0.28), rgba(91,164,217,0.38))',
|
||||
] as const;
|
||||
|
||||
function AvatarCircle({ index }: { index: number }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block w-8 h-8 rounded-full ring-2 ring-white',
|
||||
index > 0 && '-ml-2',
|
||||
)}
|
||||
style={{ background: AVATAR_GRADIENTS[index] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Hero() {
|
||||
const t = useTranslations('hero');
|
||||
|
||||
// Split the raw title by the {exclusively} placeholder so we can inject the styled <em>
|
||||
// Expected translation shape: "Built {exclusively} for ambitious brands."
|
||||
const rawTitle: string = t.raw('title');
|
||||
const parts = rawTitle.split('{exclusively}');
|
||||
const before = parts[0] ?? '';
|
||||
const after = parts[1] ?? '';
|
||||
|
||||
// Split each segment into words for per-word animation
|
||||
const beforeWords = before.trim() ? before.trim().split(' ') : [];
|
||||
const afterWords = after.trim() ? after.trim().split(' ') : [];
|
||||
const exclusivelyWord = 'exclusively';
|
||||
|
||||
// All words in order: before + [exclusively] + after
|
||||
type WordItem =
|
||||
| { type: 'normal'; text: string }
|
||||
| { type: 'accent'; text: string };
|
||||
|
||||
const allWords: WordItem[] = [
|
||||
...beforeWords.map((w) => ({ type: 'normal' as const, text: w })),
|
||||
{ type: 'accent' as const, text: exclusivelyWord },
|
||||
...afterWords.map((w) => ({ type: 'normal' as const, text: w })),
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
id="hero"
|
||||
aria-label="Hero"
|
||||
className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden bg-surface-high"
|
||||
>
|
||||
{/* ─── Background: animated SVG layers ─────────────────────────── */}
|
||||
<motion.div
|
||||
className="absolute inset-0 z-0 pointer-events-none"
|
||||
{...bgDriftA}
|
||||
>
|
||||
<motion.div className="absolute inset-0" {...bgRotate}>
|
||||
<HeroGeometric className="absolute inset-0 w-full h-full" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Subtle radial gradient vignette over the SVG */}
|
||||
<motion.div
|
||||
className="absolute inset-0 z-0 pointer-events-none"
|
||||
{...bgDriftB}
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse 70% 60% at 50% 45%, transparent 30%, rgba(248,249,250,0.55) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ─── Content ──────────────────────────────────────────────────── */}
|
||||
<div className="relative z-10 w-full max-w-4xl mx-auto px-6 py-24 flex flex-col items-center text-center">
|
||||
|
||||
{/* Eyebrow */}
|
||||
<motion.span
|
||||
className="label-md text-primary mb-6 tracking-widest uppercase"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.05, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
BESPOKE DIGITAL STUDIO
|
||||
</motion.span>
|
||||
|
||||
{/* Headline with per-word stagger */}
|
||||
<motion.h1
|
||||
className={cn(
|
||||
'font-serif font-semibold text-on-surface',
|
||||
'text-5xl sm:text-6xl md:text-7xl leading-[1.08] tracking-[-0.02em]',
|
||||
'mb-6 flex flex-wrap items-baseline justify-center gap-x-[0.25em] gap-y-1',
|
||||
)}
|
||||
variants={heroHeadlineContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
aria-label={rawTitle.replace('{exclusively}', exclusivelyWord)}
|
||||
>
|
||||
{allWords.map((word, i) =>
|
||||
word.type === 'accent' ? (
|
||||
<motion.span
|
||||
key={`word-accent-${i}`}
|
||||
variants={wordReveal}
|
||||
className="inline-block overflow-hidden"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
<em className="text-primary font-serif italic not-italic">
|
||||
{word.text}
|
||||
</em>
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key={`word-${i}`}
|
||||
variants={wordReveal}
|
||||
className="inline-block"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
{word.text}
|
||||
</motion.span>
|
||||
),
|
||||
)}
|
||||
</motion.h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<motion.p
|
||||
className="text-lg text-outline leading-relaxed max-w-xl mb-10"
|
||||
variants={subtitleVariant}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{t('subtitle')}
|
||||
</motion.p>
|
||||
|
||||
{/* CTA row */}
|
||||
<motion.div
|
||||
className="flex flex-col sm:flex-row items-center gap-4 mb-12"
|
||||
variants={ctaVariant}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<Button variant="primary" size="lg" arrow href="#configure">
|
||||
{t('cta')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" href="#work">
|
||||
{t('ctaSecondary')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Trust proof strip */}
|
||||
<motion.div
|
||||
className="flex items-center gap-3"
|
||||
variants={trustVariant}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* Overlapping avatar circles */}
|
||||
<div className="flex items-center" aria-hidden="true">
|
||||
<AvatarCircle index={0} />
|
||||
<AvatarCircle index={1} />
|
||||
<AvatarCircle index={2} />
|
||||
</div>
|
||||
<p className="text-sm text-outline">
|
||||
{t('trust')}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Bottom fade-out to next section */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-24 pointer-events-none z-10"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, transparent, rgba(248,249,250,0.6))',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
353
src/components/sections/Philosophy.tsx
Normal file
353
src/components/sections/Philosophy.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
slideLeftVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import CornerBracket from '@/components/icons/CornerBracket';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PillarKey {
|
||||
key: 'ownership' | 'craftsmanship' | 'oneTeam';
|
||||
}
|
||||
|
||||
// ─── Data ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PILLARS: PillarKey[] = [
|
||||
{ key: 'ownership' },
|
||||
{ key: 'craftsmanship' },
|
||||
{ key: 'oneTeam' },
|
||||
];
|
||||
|
||||
// ─── Animation Variants ───────────────────────────────────────────────────────
|
||||
|
||||
const ease = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
const pillarContainerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.12,
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const pillarVariants = {
|
||||
hidden: { opacity: 0, x: -32 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.6, ease },
|
||||
},
|
||||
};
|
||||
|
||||
const decorativeVariants = {
|
||||
hidden: { opacity: 0, scale: 0.96 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { duration: 0.8, ease, delay: 0.15 },
|
||||
},
|
||||
};
|
||||
|
||||
const quoteCardVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 32,
|
||||
rotate: 4,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
rotate: 0,
|
||||
transition: { duration: 0.75, ease, delay: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
const leftBorderVariants = {
|
||||
hidden: { scaleY: 0 },
|
||||
visible: {
|
||||
scaleY: 1,
|
||||
transition: { duration: 0.6, ease },
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Philosophy Pillar ────────────────────────────────────────────────────────
|
||||
|
||||
function PhilosophyPillar({
|
||||
title,
|
||||
description,
|
||||
index,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={pillarVariants}
|
||||
className="relative flex items-stretch gap-0"
|
||||
>
|
||||
{/* Animated left border */}
|
||||
<div className="relative flex-shrink-0 w-[2px] mr-5 self-stretch overflow-hidden">
|
||||
<div className="absolute inset-0 bg-outline-variant/20 rounded-full" />
|
||||
<motion.div
|
||||
variants={leftBorderVariants}
|
||||
className="absolute inset-0 origin-top rounded-full"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, var(--color-primary), var(--color-primary-dark))`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-1.5 py-4">
|
||||
<p
|
||||
className="label-md text-outline/60 mb-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</p>
|
||||
<h3 className="font-semibold text-on-surface text-base leading-snug">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-outline leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Abstract Geometric Decoration ───────────────────────────────────────────
|
||||
|
||||
function AbstractGeometry() {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden rounded-xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Primary large circle */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '65%',
|
||||
height: '65%',
|
||||
top: '-8%',
|
||||
right: '-12%',
|
||||
background:
|
||||
'radial-gradient(circle, rgba(91,164,217,0.12) 0%, rgba(0,100,148,0.06) 60%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Secondary circle, bottom-left */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '50%',
|
||||
height: '50%',
|
||||
bottom: '-10%',
|
||||
left: '-6%',
|
||||
background:
|
||||
'radial-gradient(circle, rgba(0,100,148,0.08) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Diagonal grid */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(-45deg, var(--color-primary-dark) 0, var(--color-primary-dark) 1px, transparent 0, transparent 50%)',
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Accent rectangle top-left */}
|
||||
<div
|
||||
className="absolute rounded-md"
|
||||
style={{
|
||||
width: '22%',
|
||||
height: '30%',
|
||||
top: '10%',
|
||||
left: '8%',
|
||||
border: '1.5px solid rgba(91,164,217,0.15)',
|
||||
transform: 'rotate(-8deg)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Floating dot cluster center-right */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
width: '22%',
|
||||
height: '22%',
|
||||
top: '35%',
|
||||
right: '10%',
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, rgba(91,164,217,0.25) 1.5px, transparent 1.5px)',
|
||||
backgroundSize: '10px 10px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Thin arc line */}
|
||||
<svg
|
||||
className="absolute"
|
||||
style={{ top: '20%', left: '30%', opacity: 0.08 }}
|
||||
width="140"
|
||||
height="140"
|
||||
viewBox="0 0 140 140"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r="60"
|
||||
stroke="var(--color-primary-dark)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="8 6"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Small solid accent square */}
|
||||
<div
|
||||
className="absolute rounded-sm"
|
||||
style={{
|
||||
width: '6%',
|
||||
height: '6%',
|
||||
bottom: '28%',
|
||||
right: '28%',
|
||||
background: 'rgba(91,164,217,0.20)',
|
||||
transform: 'rotate(12deg)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function Philosophy() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<section id="about" className="bg-surface py-24">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-start">
|
||||
|
||||
{/* ── Left Column: Text (5 cols) ── */}
|
||||
<div className="lg:col-span-5 flex flex-col gap-10">
|
||||
|
||||
{/* Header block */}
|
||||
<motion.div
|
||||
variants={slideLeftVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<p className="label-md text-primary">{t('philosophy.eyebrow')}</p>
|
||||
<h2 className="font-serif text-4xl md:text-[2.75rem] font-semibold text-on-surface leading-[1.1] tracking-[-0.02em]">
|
||||
{t('philosophy.title')}
|
||||
</h2>
|
||||
<p className="text-outline leading-relaxed text-[0.9375rem]">
|
||||
{t('philosophy.subtitle')}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Philosophy pillars */}
|
||||
<motion.div
|
||||
variants={pillarContainerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{PILLARS.map(({ key }, i) => (
|
||||
<PhilosophyPillar
|
||||
key={key}
|
||||
index={i}
|
||||
title={t(`philosophy.${key}.title`)}
|
||||
description={t(`philosophy.${key}.description`)}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Right Column: Decorative (7 cols) ── */}
|
||||
<div className="lg:col-span-7 relative">
|
||||
|
||||
{/* Main decorative canvas */}
|
||||
<motion.div
|
||||
variants={decorativeVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className={cn(
|
||||
'relative bg-surface-low rounded-xl',
|
||||
'min-h-[420px] lg:min-h-[500px]',
|
||||
'overflow-visible',
|
||||
)}
|
||||
>
|
||||
<AbstractGeometry />
|
||||
|
||||
{/* Subtle inner shadow rim */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-xl pointer-events-none"
|
||||
style={{
|
||||
boxShadow: 'inset 0 0 0 1px rgba(194,199,206,0.25)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Pull-Quote Card ── */}
|
||||
<motion.div
|
||||
variants={quoteCardVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className={cn(
|
||||
'absolute -bottom-8 -left-4 lg:-left-10',
|
||||
'bg-surface-high rounded-xl p-6 shadow-subtle',
|
||||
'max-w-[320px] w-[calc(100%-2.5rem)] lg:max-w-[340px]',
|
||||
'z-10',
|
||||
)}
|
||||
>
|
||||
{/* CornerBracket top-right decoration */}
|
||||
<div className="absolute top-4 right-4">
|
||||
<CornerBracket
|
||||
size={28}
|
||||
position="top-right"
|
||||
color="var(--color-primary)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quote text */}
|
||||
<blockquote className="font-serif italic text-lg text-on-surface leading-relaxed pr-8">
|
||||
“{t('philosophy.quote')}”
|
||||
</blockquote>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
className="w-8 h-px bg-primary/40 my-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Attribution */}
|
||||
<p className="label-md text-outline">Founded on the Côte d’Azur</p>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
140
src/components/sections/Process.tsx
Normal file
140
src/components/sections/Process.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { motion, type Variants } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Search, LayoutDashboard, PenTool, Rocket, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
staggerContainerWide,
|
||||
revealVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import SectionHeader from '@/components/ui/SectionHeader';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Step {
|
||||
numeral: string;
|
||||
key: 'discovery' | 'strategy' | 'build' | 'launch';
|
||||
Icon: LucideIcon;
|
||||
}
|
||||
|
||||
// ─── Data ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{ numeral: '01', key: 'discovery', Icon: Search },
|
||||
{ numeral: '02', key: 'strategy', Icon: LayoutDashboard },
|
||||
{ numeral: '03', key: 'build', Icon: PenTool },
|
||||
{ numeral: '04', key: 'launch', Icon: Rocket },
|
||||
];
|
||||
|
||||
// ─── Variants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Numeral scales up from 80% while fading in
|
||||
const numeralScaleVariants: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.8 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.7,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StepCard({ numeral, stepKey, Icon }: { numeral: string; stepKey: string; Icon: LucideIcon }) {
|
||||
const t = useTranslations();
|
||||
const title = t(`process.steps.${stepKey}.title`);
|
||||
const description = t(`process.steps.${stepKey}.description`);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={revealVariants}
|
||||
className={cn(
|
||||
'relative flex flex-col bg-surface-high rounded-xl p-6',
|
||||
'shadow-subtle',
|
||||
)}
|
||||
>
|
||||
{/* Ghosted numeral — scales up on scroll */}
|
||||
<motion.span
|
||||
variants={numeralScaleVariants}
|
||||
aria-hidden="true"
|
||||
className="font-serif text-6xl font-light leading-none text-on-surface/[0.06] select-none -ml-0.5 mb-3"
|
||||
>
|
||||
{numeral}
|
||||
</motion.span>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="mb-4">
|
||||
<Icon
|
||||
size={24}
|
||||
strokeWidth={1.5}
|
||||
className="text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-lg text-on-surface mb-2 leading-snug">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-outline leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function Process() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<section id="process" className="bg-surface-low py-24">
|
||||
<div className="container mx-auto px-6">
|
||||
|
||||
{/*
|
||||
Desktop layout:
|
||||
col 1 (lg:col-span-1) → SectionHeader, flush left
|
||||
col 2–4 (lg:col-span-3) → 2×2 grid of step cards
|
||||
|
||||
Mobile layout:
|
||||
header on top, steps in single column below
|
||||
*/}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-12 lg:gap-10 items-start">
|
||||
|
||||
{/* ── Header column ── */}
|
||||
<div className="lg:col-span-1 lg:sticky lg:top-32">
|
||||
<SectionHeader
|
||||
eyebrow={t('process.eyebrow')}
|
||||
title={t('process.title')}
|
||||
align="left"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Steps column ── */}
|
||||
<div className="lg:col-span-3">
|
||||
<motion.div
|
||||
variants={staggerContainerWide}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-5"
|
||||
>
|
||||
{STEPS.map((step) => (
|
||||
<StepCard key={step.key} numeral={step.numeral} stepKey={step.key} Icon={step.Icon} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
464
src/components/sections/SelectedWorks.tsx
Normal file
464
src/components/sections/SelectedWorks.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
staggerContainerWide,
|
||||
slideLeftVariants,
|
||||
fadeVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Lock, Clock, ArrowRight } from 'lucide-react';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Project {
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
slug: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
interface ComingSoonItem {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
// ─── Data ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PROJECTS: Project[] = [
|
||||
{
|
||||
title: 'Monaco Ocean Protection Challenge',
|
||||
description:
|
||||
"A comprehensive judging and analytics system with advanced AI jury integration for one of the Mediterranean's most prestigious conservation events.",
|
||||
tags: ['AI Integration', 'Platform'],
|
||||
slug: 'monaco-ocean',
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
title: 'Port Nimara',
|
||||
description: 'Scalable digital hub for maritime logistics.',
|
||||
tags: ['Website', 'Infrastructure'],
|
||||
slug: 'port-nimara',
|
||||
},
|
||||
{
|
||||
title: 'Port Amador',
|
||||
description: 'Premium digital experience for elite nautical services.',
|
||||
tags: ['Website', 'Infrastructure'],
|
||||
slug: 'port-amador',
|
||||
},
|
||||
];
|
||||
|
||||
const COMING_SOON: ComingSoonItem[] = [
|
||||
{ title: 'Confidential Riviera Project', subtitle: 'Coming Soon' },
|
||||
{ title: 'Sophia Antipolis AI Startup', subtitle: 'Launching Q4' },
|
||||
];
|
||||
|
||||
// ─── Animation Variants ───────────────────────────────────────────────────────
|
||||
|
||||
const ease = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
const featuredCardVariants = {
|
||||
hidden: { opacity: 0, y: 48 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.7, ease },
|
||||
},
|
||||
};
|
||||
|
||||
const smallCardVariants = {
|
||||
hidden: { opacity: 0, y: 32 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease },
|
||||
},
|
||||
};
|
||||
|
||||
const comingSoonVariants = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.55, ease },
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Geometric Placeholder ────────────────────────────────────────────────────
|
||||
|
||||
function GeometricPlaceholder({
|
||||
variant = 'featured',
|
||||
className,
|
||||
}: {
|
||||
variant?: 'featured' | 'small';
|
||||
className?: string;
|
||||
}) {
|
||||
const isFeatured = variant === 'featured';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden bg-gradient-to-br from-primary-dark/90 to-primary/70',
|
||||
className,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Abstract geometric shapes */}
|
||||
<div className="absolute inset-0">
|
||||
{/* Large circle top-right */}
|
||||
<div
|
||||
className="absolute rounded-full bg-white/[0.06]"
|
||||
style={{
|
||||
width: isFeatured ? '55%' : '70%',
|
||||
height: isFeatured ? '55%' : '70%',
|
||||
top: '-15%',
|
||||
right: '-10%',
|
||||
}}
|
||||
/>
|
||||
{/* Medium circle bottom-left */}
|
||||
<div
|
||||
className="absolute rounded-full bg-white/[0.08]"
|
||||
style={{
|
||||
width: isFeatured ? '40%' : '50%',
|
||||
height: isFeatured ? '40%' : '50%',
|
||||
bottom: '-20%',
|
||||
left: '-5%',
|
||||
}}
|
||||
/>
|
||||
{/* Diagonal stripes overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.04]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(-45deg, #fff 0, #fff 1px, transparent 0, transparent 50%)',
|
||||
backgroundSize: isFeatured ? '28px 28px' : '20px 20px',
|
||||
}}
|
||||
/>
|
||||
{/* Small accent rectangle */}
|
||||
<div
|
||||
className="absolute bg-white/[0.12] rounded-sm"
|
||||
style={{
|
||||
width: isFeatured ? '18%' : '24%',
|
||||
height: isFeatured ? '28%' : '36%',
|
||||
bottom: '18%',
|
||||
right: '15%',
|
||||
transform: 'rotate(-6deg)',
|
||||
}}
|
||||
/>
|
||||
{/* Thin horizontal line accent */}
|
||||
<div
|
||||
className="absolute bg-white/20 rounded-full"
|
||||
style={{
|
||||
width: isFeatured ? '30%' : '40%',
|
||||
height: '1px',
|
||||
top: '38%',
|
||||
left: '10%',
|
||||
}}
|
||||
/>
|
||||
{/* Grid-dot accent */}
|
||||
<div
|
||||
className="absolute opacity-[0.07]"
|
||||
style={{
|
||||
width: isFeatured ? '25%' : '30%',
|
||||
height: isFeatured ? '25%' : '30%',
|
||||
top: '50%',
|
||||
right: '22%',
|
||||
backgroundImage: 'radial-gradient(circle, #fff 1px, transparent 1px)',
|
||||
backgroundSize: '8px 8px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Bottom gradient fade */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-primary-dark/50 to-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tag Chip ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TagChip({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center bg-primary/10 text-primary-dark text-xs font-medium px-2.5 py-1 rounded-full leading-none">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Featured Card ────────────────────────────────────────────────────────────
|
||||
|
||||
function FeaturedCard({ project, readLabel }: { project: Project; readLabel: string }) {
|
||||
return (
|
||||
<motion.article
|
||||
variants={featuredCardVariants}
|
||||
className={cn(
|
||||
'group relative flex flex-col bg-surface-high rounded-2xl overflow-hidden',
|
||||
'shadow-subtle',
|
||||
'transition-shadow duration-300 hover:shadow-[0_24px_48px_rgba(25,28,29,0.10)]',
|
||||
)}
|
||||
>
|
||||
{/* Geometric image placeholder */}
|
||||
<GeometricPlaceholder
|
||||
variant="featured"
|
||||
className="w-full aspect-[16/9] md:aspect-[2/1]"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col flex-1 p-7 gap-4">
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<TagChip key={tag} label={tag} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-serif text-2xl font-semibold text-on-surface leading-snug">
|
||||
{project.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-outline leading-relaxed flex-1">
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
href={`/work/${project.slug}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 text-sm font-medium text-primary-dark',
|
||||
'transition-gap duration-200 group/link',
|
||||
'mt-1',
|
||||
)}
|
||||
>
|
||||
<span className="underline underline-offset-4 decoration-primary/40 group-hover/link:decoration-primary-dark transition-colors duration-200">
|
||||
{readLabel}
|
||||
</span>
|
||||
<ArrowRight
|
||||
size={14}
|
||||
className="transition-transform duration-200 group-hover/link:translate-x-1"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Small Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SmallCard({ project, readLabel }: { project: Project; readLabel: string }) {
|
||||
return (
|
||||
<motion.article
|
||||
variants={smallCardVariants}
|
||||
className={cn(
|
||||
'group relative flex flex-col bg-surface-high rounded-xl overflow-hidden',
|
||||
'shadow-card',
|
||||
'transition-all duration-300',
|
||||
'hover:shadow-subtle',
|
||||
)}
|
||||
>
|
||||
{/* Geometric placeholder — grayscale to color on hover */}
|
||||
<div className="relative overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'transition-all duration-500',
|
||||
'grayscale group-hover:grayscale-0',
|
||||
'opacity-80 group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<GeometricPlaceholder
|
||||
variant="small"
|
||||
className="w-full aspect-[16/7]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col flex-1 p-5 gap-3">
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{project.tags.map((tag) => (
|
||||
<TagChip key={tag} label={tag} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-serif text-lg font-semibold text-on-surface leading-snug">
|
||||
{project.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-outline leading-relaxed flex-1">
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
href={`/work/${project.slug}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-primary-dark group/link"
|
||||
>
|
||||
<span className="underline underline-offset-4 decoration-primary/40 group-hover/link:decoration-primary-dark transition-colors duration-200">
|
||||
{readLabel}
|
||||
</span>
|
||||
<ArrowRight
|
||||
size={12}
|
||||
className="transition-transform duration-200 group-hover/link:translate-x-0.5"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Coming Soon Card ─────────────────────────────────────────────────────────
|
||||
|
||||
function ComingSoonCard({ item }: { item: ComingSoonItem }) {
|
||||
const isConfidential = item.subtitle === 'Coming Soon';
|
||||
const Icon = isConfidential ? Lock : Clock;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={comingSoonVariants}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center gap-4',
|
||||
'rounded-xl p-8 min-h-[160px]',
|
||||
'border border-dashed border-outline-variant/30',
|
||||
'coming-soon-card',
|
||||
'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{/* Subtle animated pulse overlay */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-xl opacity-0 coming-soon-pulse"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center gap-3 text-center">
|
||||
<Icon
|
||||
size={20}
|
||||
className="text-outline/40"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-serif text-base font-medium text-on-surface/40 leading-snug">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="label-md text-outline/50 mt-1">{item.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function SelectedWorks() {
|
||||
const t = useTranslations();
|
||||
|
||||
const featuredProject = PROJECTS.find((p) => p.featured)!;
|
||||
const secondaryProjects = PROJECTS.filter((p) => !p.featured);
|
||||
|
||||
return (
|
||||
<section id="work" className="bg-surface-low py-24">
|
||||
<style>{`
|
||||
@keyframes dashed-drift {
|
||||
0% { background-position: 0 0, 100% 0, 100% 100%, 0 100%; }
|
||||
100% { background-position: 100% 0, 100% 100%, 0 100%, 0 0; }
|
||||
}
|
||||
@keyframes coming-soon-fade {
|
||||
0%, 100% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.coming-soon-pulse {
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(91, 164, 217, 0.04) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: coming-soon-fade 4s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="container mx-auto px-6">
|
||||
|
||||
{/* ── Section Header ── */}
|
||||
<motion.div
|
||||
variants={slideLeftVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-14"
|
||||
>
|
||||
<div>
|
||||
<p className="label-md text-primary mb-2">{t('work.eyebrow')}</p>
|
||||
<h2 className="font-serif text-4xl md:text-5xl font-semibold text-on-surface leading-[1.1] tracking-[-0.02em]">
|
||||
{t('work.title')}
|
||||
</h2>
|
||||
</div>
|
||||
<motion.div
|
||||
variants={fadeVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
>
|
||||
<Link
|
||||
href="/work"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-outline hover:text-primary-dark transition-colors duration-200 group"
|
||||
>
|
||||
<span>View all work</span>
|
||||
<ArrowRight
|
||||
size={14}
|
||||
className="transition-transform duration-200 group-hover:translate-x-1"
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Primary Grid: Featured + 2 Small ── */}
|
||||
<motion.div
|
||||
variants={staggerContainerWide}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="grid grid-cols-1 md:grid-cols-12 gap-5 mb-5"
|
||||
>
|
||||
{/* Featured card — 8 cols */}
|
||||
<div className="md:col-span-8">
|
||||
<FeaturedCard
|
||||
project={featuredProject}
|
||||
readLabel={t('work.readCaseStudy')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Secondary column — 4 cols, 2 stacked */}
|
||||
<div className="md:col-span-4 flex flex-col gap-5">
|
||||
{secondaryProjects.map((project) => (
|
||||
<SmallCard
|
||||
key={project.slug}
|
||||
project={project}
|
||||
readLabel={t('work.readCaseStudy')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Coming Soon Row ── */}
|
||||
<motion.div
|
||||
variants={staggerContainerWide}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-5"
|
||||
>
|
||||
{COMING_SOON.map((item) => (
|
||||
<ComingSoonCard key={item.title} item={item} />
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
140
src/components/sections/ServicesOverview.tsx
Normal file
140
src/components/sections/ServicesOverview.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
staggerContainerWide,
|
||||
revealVariants,
|
||||
fadeVariants,
|
||||
scaleVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import SectionHeader from '@/components/ui/SectionHeader';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ServicePillar {
|
||||
numeral: string;
|
||||
titleKey: 'web' | 'systems' | 'infrastructure';
|
||||
featuresKey: 'web' | 'systems' | 'infrastructure';
|
||||
}
|
||||
|
||||
// ─── Data ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PILLARS: ServicePillar[] = [
|
||||
{ numeral: '01', titleKey: 'web', featuresKey: 'web' },
|
||||
{ numeral: '02', titleKey: 'systems', featuresKey: 'systems' },
|
||||
{ numeral: '03', titleKey: 'infrastructure', featuresKey: 'infrastructure' },
|
||||
];
|
||||
|
||||
// ─── Variants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const numeralVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1] as const },
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ServicesOverview() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<section id="services" className="bg-surface py-24">
|
||||
<div className="container mx-auto px-6">
|
||||
|
||||
{/* Section header */}
|
||||
<div className="mb-16">
|
||||
<SectionHeader
|
||||
eyebrow={t('services.eyebrow')}
|
||||
title={t('services.title')}
|
||||
align="center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Three-pillar grid with gap-px separator trick */}
|
||||
<div className="bg-outline/10 rounded-xl overflow-hidden shadow-subtle">
|
||||
<motion.div
|
||||
variants={staggerContainerWide}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-px"
|
||||
>
|
||||
{PILLARS.map(({ numeral, titleKey, featuresKey }) => {
|
||||
const features = t.raw(`services.${featuresKey}.features`) as string[];
|
||||
const title = t(`services.${titleKey}.title`);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={numeral}
|
||||
variants={revealVariants}
|
||||
className={cn(
|
||||
'relative flex flex-col p-8 bg-surface',
|
||||
'transition-colors duration-200 ease-out',
|
||||
'hover:bg-surface-high',
|
||||
)}
|
||||
>
|
||||
{/* Ghosted numeral */}
|
||||
<motion.span
|
||||
variants={numeralVariants}
|
||||
aria-hidden="true"
|
||||
className="font-serif text-7xl font-light leading-none text-on-surface/[0.06] select-none mb-4 -ml-1"
|
||||
>
|
||||
{numeral}
|
||||
</motion.span>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-serif text-2xl font-semibold text-on-surface mb-5 leading-snug">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Feature list */}
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
{features.map((feature: string) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-start gap-2.5 text-sm text-outline leading-relaxed"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mt-[0.35em] shrink-0 w-1 h-1 rounded-full bg-primary/50"
|
||||
/>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* AI narrative callout */}
|
||||
<motion.div
|
||||
variants={fadeVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="mt-16 flex flex-col items-center gap-4"
|
||||
>
|
||||
{/* Decorative rule */}
|
||||
<motion.span
|
||||
variants={scaleVariants}
|
||||
className="block w-px h-10 bg-primary/30"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<p className="font-serif italic text-xl text-primary-dark text-center max-w-xl leading-relaxed">
|
||||
{t('services.aiNarrative')}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
125
src/components/sections/TrustBar.tsx
Normal file
125
src/components/sections/TrustBar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Compass, Shield, Brain, MapPin } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
revealVariants,
|
||||
staggerContainerWide,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
// Each item's icon scale-bounce on enter
|
||||
const iconBounceVariants = {
|
||||
hidden: { scale: 0.7, opacity: 0 },
|
||||
visible: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: [0.34, 1.56, 0.64, 1] as [number, number, number, number], // spring-like overshoot
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface TrustItem {
|
||||
key: string;
|
||||
Icon: LucideIcon;
|
||||
}
|
||||
|
||||
const ITEMS: TrustItem[] = [
|
||||
{ key: 'customBuilt', Icon: Compass },
|
||||
{ key: 'privateInfra', Icon: Shield },
|
||||
{ key: 'aiPowered', Icon: Brain },
|
||||
{ key: 'rivieraBased', Icon: MapPin },
|
||||
];
|
||||
|
||||
interface TrustCardProps {
|
||||
item: TrustItem;
|
||||
index: number;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
function TrustCard({ item, index, t }: TrustCardProps) {
|
||||
const { Icon, key } = item;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={revealVariants}
|
||||
className={cn(
|
||||
'group flex flex-col items-start gap-4 p-6',
|
||||
'rounded-2xl bg-surface-high shadow-subtle',
|
||||
'transition-shadow duration-300 hover:shadow-card',
|
||||
'cursor-default',
|
||||
)}
|
||||
>
|
||||
{/* Icon with scale-bounce on scroll reveal */}
|
||||
<motion.div
|
||||
variants={iconBounceVariants}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-12 h-12 rounded-xl',
|
||||
'bg-primary/8',
|
||||
'transition-transform duration-300 ease-out',
|
||||
'group-hover:-translate-y-1',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
size={28}
|
||||
className="text-primary transition-colors duration-300"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h3
|
||||
className={cn(
|
||||
'font-semibold text-on-surface text-base leading-snug mb-1',
|
||||
'transition-colors duration-300 group-hover:text-primary-dark',
|
||||
)}
|
||||
>
|
||||
{t(`${key}.title`)}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-outline leading-relaxed">
|
||||
{t(`${key}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrustBar() {
|
||||
const t = useTranslations('trustBar');
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Trust indicators"
|
||||
className="bg-surface-low py-16"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
{/* Stagger wrapper — triggers children revealVariants on scroll */}
|
||||
<motion.div
|
||||
variants={staggerContainerWide}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="grid grid-cols-2 md:grid-cols-4 gap-6 md:gap-8"
|
||||
>
|
||||
{ITEMS.map((item, index) => (
|
||||
<TrustCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
index={index}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
211
src/components/sections/services/AILayer.tsx
Normal file
211
src/components/sections/services/AILayer.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Users, MessageCircle, BarChart3 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
staggerContainer,
|
||||
revealVariants,
|
||||
slideLeftVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AiCapability {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface AILayerProps {
|
||||
capabilities: readonly AiCapability[];
|
||||
}
|
||||
|
||||
// ─── Icon map ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ICON_MAP: Record<string, typeof Users> = {
|
||||
'ai-teammate': Users,
|
||||
'customer-facing-ai': MessageCircle,
|
||||
'data-intelligence': BarChart3,
|
||||
};
|
||||
|
||||
// ─── Animation variants ────────────────────────────────────────────────────────
|
||||
|
||||
const ease = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
const sectionHeadVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.1 },
|
||||
},
|
||||
};
|
||||
|
||||
const cardContainerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.12,
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
hidden: { opacity: 0, y: 28 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease },
|
||||
},
|
||||
};
|
||||
|
||||
const lineVariants = {
|
||||
hidden: { scaleY: 0 },
|
||||
visible: {
|
||||
scaleY: 1,
|
||||
transition: { duration: 0.6, ease, delay: 0.15 },
|
||||
},
|
||||
};
|
||||
|
||||
// ─── AI Capability card ────────────────────────────────────────────────────────
|
||||
|
||||
function AICapabilityCard({ capability }: { capability: AiCapability }) {
|
||||
const Icon = ICON_MAP[capability.id] ?? Users;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
className={cn(
|
||||
'relative flex flex-col gap-4 p-6',
|
||||
'rounded-xl',
|
||||
'bg-white/5 backdrop-blur-glass',
|
||||
'border border-white/10',
|
||||
'transition-colors duration-200 hover:bg-white/[0.08]',
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex items-center justify-center w-10 h-10 rounded-xl"
|
||||
style={{
|
||||
background: 'rgba(91, 164, 217, 0.15)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
className="text-primary-light"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-serif font-semibold text-white text-xl leading-snug">
|
||||
{capability.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'rgba(255,255,255,0.6)' }}>
|
||||
{capability.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AILayer({ capabilities }: AILayerProps) {
|
||||
return (
|
||||
<section
|
||||
id="ai-automation"
|
||||
className="py-24"
|
||||
style={{ backgroundColor: '#1C2B3A' }}
|
||||
aria-labelledby="ai-layer-heading"
|
||||
>
|
||||
<div className="container mx-auto px-6">
|
||||
|
||||
{/* Section header */}
|
||||
<motion.div
|
||||
variants={sectionHeadVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="mb-14 flex flex-col items-center text-center"
|
||||
>
|
||||
{/* Eyebrow */}
|
||||
<motion.span
|
||||
variants={revealVariants}
|
||||
className="label-md text-primary mb-4"
|
||||
>
|
||||
Intelligent Layer
|
||||
</motion.span>
|
||||
|
||||
{/* Heading */}
|
||||
<motion.h2
|
||||
id="ai-layer-heading"
|
||||
variants={revealVariants}
|
||||
className="font-serif font-semibold tracking-headline text-white text-4xl md:text-5xl max-w-2xl leading-[1.1]"
|
||||
>
|
||||
The AI Layer
|
||||
</motion.h2>
|
||||
|
||||
{/* Vertical line */}
|
||||
<motion.div
|
||||
variants={lineVariants}
|
||||
className="origin-top w-px h-8 mt-5 mb-5"
|
||||
style={{ background: 'rgba(91,164,217,0.4)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Subtitle */}
|
||||
<motion.p
|
||||
variants={revealVariants}
|
||||
className="font-serif italic text-xl leading-relaxed max-w-xl"
|
||||
style={{ color: 'rgba(255,255,255,0.75)' }}
|
||||
>
|
||||
We build your ecosystem — then make it intelligent.
|
||||
</motion.p>
|
||||
|
||||
{/* Context paragraph */}
|
||||
<motion.p
|
||||
variants={revealVariants}
|
||||
className="mt-5 text-[0.9375rem] leading-relaxed max-w-2xl"
|
||||
style={{ color: 'rgba(255,255,255,0.5)' }}
|
||||
>
|
||||
AI is not a product we bolt on — it is the connective tissue of
|
||||
every system we build. Once your digital infrastructure is live, we
|
||||
layer language models, automation pipelines, and predictive analytics
|
||||
directly into your workflows, so your team operates with capabilities
|
||||
that were previously reserved for organisations ten times your size.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Capability cards */}
|
||||
<motion.div
|
||||
variants={cardContainerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
{capabilities.map((capability) => (
|
||||
<AICapabilityCard key={capability.id} capability={capability} />
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Bottom note */}
|
||||
<motion.p
|
||||
variants={revealVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="mt-10 text-center text-xs uppercase tracking-widest"
|
||||
style={{ color: 'rgba(255,255,255,0.25)' }}
|
||||
>
|
||||
Compatible with your existing stack — no data ever leaves your infrastructure
|
||||
</motion.p>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
202
src/components/sections/services/ServicePillar.tsx
Normal file
202
src/components/sections/services/ServicePillar.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Palette, Globe, ShoppingCart, Zap,
|
||||
Database, Code2, GitBranch, Wrench,
|
||||
Server, Shield, Lock, Settings,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Palette, Globe, ShoppingCart, Zap,
|
||||
Database, Code2, GitBranch, Wrench,
|
||||
Server, Shield, Lock, Settings,
|
||||
};
|
||||
import {
|
||||
revealVariants,
|
||||
staggerContainer,
|
||||
slideLeftVariants,
|
||||
slideRightVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal';
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Feature {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Pillar {
|
||||
id: string;
|
||||
numeral: string;
|
||||
title: string;
|
||||
description: string;
|
||||
background: 'bg-surface' | 'bg-surface-low';
|
||||
features: readonly Feature[];
|
||||
}
|
||||
|
||||
interface ServicePillarProps {
|
||||
pillar: Pillar;
|
||||
index: number;
|
||||
}
|
||||
|
||||
// ─── Animation variants ────────────────────────────────────────────────────────
|
||||
|
||||
const ease = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
const numeralVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { duration: 1, ease },
|
||||
},
|
||||
};
|
||||
|
||||
const featureCardVariants = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.55, ease },
|
||||
},
|
||||
};
|
||||
|
||||
const featureGridVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.09,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Feature card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function FeatureCard({ feature }: { feature: Feature }) {
|
||||
const Icon = ICON_MAP[feature.icon] ?? Globe;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={featureCardVariants}
|
||||
className={cn(
|
||||
'flex flex-col gap-3 p-6',
|
||||
'bg-surface-high rounded-xl',
|
||||
'shadow-card',
|
||||
'transition-shadow duration-200 hover:shadow-subtle',
|
||||
)}
|
||||
>
|
||||
{/* Icon container */}
|
||||
<div
|
||||
className="flex items-center justify-center w-10 h-10 rounded-xl"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(91,164,217,0.12), rgba(0,100,148,0.08))',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
className="text-primary-dark"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="font-semibold text-on-surface text-sm leading-snug">
|
||||
{feature.title}
|
||||
</h4>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-outline leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ServicePillar({ pillar, index }: ServicePillarProps) {
|
||||
const headingVariants = index % 2 === 0 ? slideLeftVariants : slideRightVariants;
|
||||
|
||||
return (
|
||||
<section
|
||||
id={pillar.id}
|
||||
className={cn('py-24', pillar.background)}
|
||||
aria-labelledby={`${pillar.id}-heading`}
|
||||
>
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-start">
|
||||
|
||||
{/* ── Left: Header copy (5 cols) ─────────────────────────────────── */}
|
||||
<div className="lg:col-span-5 flex flex-col gap-6">
|
||||
|
||||
{/* Ghosted numeral */}
|
||||
<motion.span
|
||||
variants={numeralVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'font-serif leading-none select-none',
|
||||
'text-[6rem] md:text-[7.5rem] font-light',
|
||||
'text-on-surface/[0.05]',
|
||||
'-ml-1 -mb-6',
|
||||
)}
|
||||
>
|
||||
{pillar.numeral}
|
||||
</motion.span>
|
||||
|
||||
{/* Heading */}
|
||||
<motion.h2
|
||||
id={`${pillar.id}-heading`}
|
||||
variants={headingVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="font-serif font-semibold tracking-headline text-on-surface text-4xl md:text-[2.75rem] leading-[1.1]"
|
||||
>
|
||||
{pillar.title}
|
||||
</motion.h2>
|
||||
|
||||
{/* Accent rule */}
|
||||
<ScrollReveal variant="fadeIn" delay={0.15}>
|
||||
<div
|
||||
className="w-12 h-0.5 rounded-full bg-gradient-cta"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Description */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.2}>
|
||||
<p className="text-outline leading-relaxed text-[0.9375rem]">
|
||||
{pillar.description}
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
{/* ── Right: Feature grid (7 cols) ───────────────────────────────── */}
|
||||
<motion.div
|
||||
variants={featureGridVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="lg:col-span-7 grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||
>
|
||||
{pillar.features.map((feature) => (
|
||||
<FeatureCard key={feature.title} feature={feature} />
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
146
src/components/sections/services/ServicesCTA.tsx
Normal file
146
src/components/sections/services/ServicesCTA.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
staggerContainer,
|
||||
revealVariants,
|
||||
scaleVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import Button from '@/components/ui/Button';
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal';
|
||||
|
||||
// ─── Animation variants ────────────────────────────────────────────────────────
|
||||
|
||||
const ease = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
const decorLineVariants = {
|
||||
hidden: { scaleX: 0 },
|
||||
visible: {
|
||||
scaleX: 1,
|
||||
transition: { duration: 0.7, ease },
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ServicesCTA() {
|
||||
return (
|
||||
<section
|
||||
className="bg-surface-low py-24"
|
||||
aria-label="Call to action"
|
||||
>
|
||||
<div className="container mx-auto px-6">
|
||||
|
||||
{/* Outer wrapper with subtle rim */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-xl bg-surface-high',
|
||||
'shadow-subtle',
|
||||
)}
|
||||
style={{
|
||||
boxShadow: 'inset 0 0 0 1px rgba(194,199,206,0.3), 0 20px 40px rgba(25,28,29,0.06)',
|
||||
}}
|
||||
>
|
||||
{/* Background geometry — purely decorative */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 overflow-hidden rounded-xl pointer-events-none"
|
||||
>
|
||||
{/* Primary radial glow */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '60%',
|
||||
height: '200%',
|
||||
top: '-50%',
|
||||
right: '-10%',
|
||||
background:
|
||||
'radial-gradient(ellipse, rgba(91,164,217,0.07) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
{/* Secondary glow */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '40%',
|
||||
height: '160%',
|
||||
bottom: '-60%',
|
||||
left: '-5%',
|
||||
background:
|
||||
'radial-gradient(ellipse, rgba(0,100,148,0.05) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
{/* Dot grid */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.025]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, var(--color-primary-dark) 1px, transparent 1px)',
|
||||
backgroundSize: '28px 28px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative px-8 py-16 md:px-16 md:py-20 flex flex-col items-center text-center gap-6">
|
||||
|
||||
{/* Eyebrow */}
|
||||
<ScrollReveal variant="fadeUp">
|
||||
<span className="label-md text-primary">
|
||||
Let's Talk
|
||||
</span>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Heading */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.08}>
|
||||
<h2 className="font-serif font-semibold tracking-headline text-on-surface text-4xl md:text-5xl max-w-2xl leading-[1.1]">
|
||||
Ready to scope your project?
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Subtitle */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.16}>
|
||||
<p className="text-lg text-outline leading-relaxed max-w-xl">
|
||||
Use our interactive configurator to define your requirements, select
|
||||
your services, and generate a personalised project brief — no
|
||||
commitment required, just clarity.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* CTA buttons */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.24}>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3 mt-2">
|
||||
<Button
|
||||
href="/#configure"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
arrow
|
||||
>
|
||||
Configure Your Project
|
||||
</Button>
|
||||
<Button
|
||||
href="mailto:hello@letsbe.biz"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
>
|
||||
hello@letsbe.biz
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Reassurance */}
|
||||
<ScrollReveal variant="fadeIn" delay={0.3}>
|
||||
<p className="text-sm text-outline/60 mt-1">
|
||||
No commitment required — just a conversation about what's possible.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
97
src/components/sections/services/ServicesHero.tsx
Normal file
97
src/components/sections/services/ServicesHero.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { staggerContainer, revealVariants, viewportOnce } from '@/lib/animations';
|
||||
|
||||
// ─── Animation variants ────────────────────────────────────────────────────────
|
||||
|
||||
const ease = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
const eyebrowVariants = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5, ease },
|
||||
},
|
||||
};
|
||||
|
||||
const headlineVariants = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.65, ease, delay: 0.1 },
|
||||
},
|
||||
};
|
||||
|
||||
const subtitleVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease, delay: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
const ruleVariants = {
|
||||
hidden: { scaleX: 0 },
|
||||
visible: {
|
||||
scaleX: 1,
|
||||
transition: { duration: 0.7, ease, delay: 0.35 },
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ServicesHero() {
|
||||
return (
|
||||
<section
|
||||
className="bg-surface pt-32 pb-20"
|
||||
aria-label="Services hero"
|
||||
>
|
||||
<div className="container mx-auto px-6">
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="flex flex-col items-center text-center"
|
||||
>
|
||||
{/* Eyebrow */}
|
||||
<motion.span
|
||||
variants={eyebrowVariants}
|
||||
className="label-md text-primary mb-5"
|
||||
>
|
||||
Our Capabilities
|
||||
</motion.span>
|
||||
|
||||
{/* Headline */}
|
||||
<motion.h1
|
||||
variants={headlineVariants}
|
||||
className="font-serif font-semibold tracking-headline text-on-surface text-5xl md:text-6xl lg:text-7xl max-w-4xl leading-[1.05]"
|
||||
>
|
||||
Three Pillars of{' '}
|
||||
<span className="text-gradient">Digital Excellence</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<motion.p
|
||||
variants={subtitleVariants}
|
||||
className="mt-6 text-lg text-outline leading-relaxed max-w-2xl"
|
||||
>
|
||||
We design, build, and operate complete digital ecosystems — from the
|
||||
first pixel to the server rack. Every discipline under one roof,
|
||||
every deliverable built to a standard most agencies never attempt.
|
||||
</motion.p>
|
||||
|
||||
{/* Decorative rule */}
|
||||
<motion.div
|
||||
variants={ruleVariants}
|
||||
className="mt-10 origin-left w-16 h-px bg-gradient-cta"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user