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:
2026-03-25 20:37:38 +01:00
commit a1f9eca76c
64 changed files with 15810 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
'use client';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
interface ProgressBarProps {
currentStep: 1 | 2 | 3;
totalSteps?: 3;
className?: string;
}
export default function ProgressBar({ currentStep, className }: ProgressBarProps) {
return (
<div className={cn('flex gap-1.5', className)} role="progressbar" aria-valuenow={currentStep} aria-valuemin={1} aria-valuemax={3}>
{([1, 2, 3] as const).map((step) => {
const isActive = step <= currentStep;
return (
<div
key={step}
className="relative flex-1 h-1 rounded-full bg-outline-variant/40 overflow-hidden"
>
<motion.div
className="absolute inset-0 rounded-full bg-gradient-to-r from-primary-dark to-primary"
initial={{ scaleX: 0 }}
animate={{ scaleX: isActive ? 1 : 0 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
delay: isActive ? (step - 1) * 0.05 : 0,
}}
style={{ transformOrigin: 'left' }}
/>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,204 @@
'use client';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { Calendar, Mail } from 'lucide-react';
import AnimatedCheckmark from '@/components/icons/AnimatedCheckmark';
import Button from '@/components/ui/Button';
import type { WizardFormData } from './WizardContainer';
// ─── Brief Renderer ───────────────────────────────────────────────────────────
function renderBrief(brief: string) {
// Split on double newlines for paragraph blocks
const blocks = brief.split('\n\n').filter(Boolean);
return blocks.map((block, blockIdx) => {
const lines = block.split('\n').filter(Boolean);
// Detect a heading block (starts with **)
const isSectionHeading =
lines.length === 1 && lines[0].startsWith('**') && lines[0].endsWith('**');
if (isSectionHeading) {
const text = lines[0].replace(/\*\*/g, '');
// Top heading is larger
if (blockIdx === 0) {
return (
<p key={blockIdx} className="font-semibold text-sm text-on-surface mb-0.5">
{text}
</p>
);
}
return (
<p key={blockIdx} className="font-semibold text-xs text-primary-dark uppercase tracking-label mt-4 mb-1">
{text}
</p>
);
}
// Separator line
if (lines.length === 1 && lines[0] === '---') {
return <hr key={blockIdx} className="border-outline-variant/30 my-3" />;
}
// Body paragraph — inline bold rendering
return (
<div key={blockIdx} className="text-xs text-outline leading-relaxed">
{lines.map((line, lineIdx) => {
// Render **bold** inline
const parts = line.split(/(\*\*[^*]+\*\*)/g);
return (
<p key={lineIdx} className={lineIdx > 0 ? 'mt-1' : ''}>
{parts.map((part, pIdx) =>
part.startsWith('**') && part.endsWith('**') ? (
<strong key={pIdx} className="font-semibold text-on-surface">
{part.slice(2, -2)}
</strong>
) : (
<span key={pIdx}>{part}</span>
),
)}
</p>
);
})}
</div>
);
});
}
// ─── Cal.com Embed / Booking ──────────────────────────────────────────────────
function BookingSection() {
const calcomUrl = process.env.NEXT_PUBLIC_CALCOM_URL;
if (calcomUrl) {
return (
<div className="rounded-xl overflow-hidden border border-outline-variant/40 bg-surface-high">
<iframe
src={calcomUrl}
width="100%"
height="480"
frameBorder="0"
title="Book a consultation"
className="block"
loading="lazy"
/>
</div>
);
}
return (
<div className="rounded-xl border border-outline-variant/40 bg-surface-low px-5 py-5 text-center">
<div className="flex justify-center mb-3">
<span className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Calendar size={18} strokeWidth={1.5} className="text-primary-dark" />
</span>
</div>
<p className="text-sm font-semibold text-on-surface mb-1">Book a Consultation</p>
<p className="text-xs text-outline mb-4">30 minutes to discuss your brief with our team</p>
<Button
variant="primary"
href="https://cal.com"
target="_blank"
rel="noopener noreferrer"
size="sm"
arrow
>
Book a Call
</Button>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
interface StepCompleteProps {
formData: WizardFormData;
brief: string;
}
export default function StepComplete({ formData, brief }: StepCompleteProps) {
const t = useTranslations('configurator');
const displayEmail = formData.email || 'your inbox';
const containerVariants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.12,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] as const },
},
};
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="flex flex-col gap-6"
>
{/* Checkmark + heading */}
<motion.div variants={itemVariants} className="flex flex-col items-center text-center pt-2 pb-1">
<AnimatedCheckmark size={64} color="#006494" />
<h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface mt-4">
{t('complete.title')}
</h3>
<div className="flex items-center gap-2 mt-2">
<Mail size={14} strokeWidth={1.5} className="text-primary flex-shrink-0" />
<p className="text-sm text-outline">
{t('complete.subtitle', { email: displayEmail })}
</p>
</div>
</motion.div>
{/* Brief preview */}
{brief && (
<motion.div
variants={itemVariants}
className="rounded-xl bg-surface-high border border-outline-variant/40 px-5 py-5 shadow-card"
>
<p className="text-xs font-semibold uppercase tracking-label text-outline mb-3">
Your project brief
</p>
<div className="space-y-1 max-h-72 overflow-y-auto pr-1 scrollbar-thin">
{renderBrief(brief)}
</div>
</motion.div>
)}
{/* Booking */}
<motion.div variants={itemVariants}>
<p className="text-xs font-semibold uppercase tracking-label text-outline mb-3">
{t('complete.bookTitle')}
</p>
<BookingSection />
</motion.div>
{/* Fallback contact */}
<motion.div variants={itemVariants}>
<p className="text-center text-xs text-outline">
Or reach us directly at{' '}
<a
href="mailto:hello@letsbe.biz"
className="text-primary-dark underline underline-offset-2 hover:text-primary transition-colors"
>
hello@letsbe.biz
</a>
</p>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,290 @@
'use client';
import { useTranslations } from 'next-intl';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils';
import Button from '@/components/ui/Button';
import ProgressBar from './ProgressBar';
import type { StepProps } from './WizardContainer';
// ─── Data helpers ─────────────────────────────────────────────────────────────
const SERVICE_LABELS: Record<string, string> = {
web: 'Web Design & Dev',
systems: 'Custom Systems',
infrastructure: 'Digital Infrastructure',
};
const AI_TYPE_LABELS: Record<string, string> = {
teammate: 'AI Teammate',
'customer-facing': 'Customer-Facing AI',
'data-intelligence': 'Data Intelligence',
notsure: 'AI (TBD)',
};
const TIMELINE_LABELS: Record<string, string> = {
asap: 'ASAP',
'1-3months': '13 months',
'3-6months': '36 months',
exploring: 'Just exploring',
};
const INDUSTRY_LABELS: Record<string, string> = {
maritime: 'Maritime / Yachting',
hospitality: 'Hospitality',
technology: 'Technology',
realestate: 'Real Estate',
finance: 'Finance',
ngo: 'NGO / Nonprofit',
other: 'Other',
};
// ─── Sub-components ───────────────────────────────────────────────────────────
interface InputFieldProps {
id: string;
label: string;
type?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
autoComplete?: string;
}
function InputField({
id,
label,
type = 'text',
value,
onChange,
placeholder,
required,
autoComplete,
}: InputFieldProps) {
return (
<div className="flex flex-col gap-1.5">
<label
htmlFor={id}
className="text-xs font-semibold uppercase tracking-label text-outline"
>
{label}
{required && <span className="ml-1 text-primary">*</span>}
</label>
<input
id={id}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
required={required}
autoComplete={autoComplete}
className={cn(
'w-full rounded-xl border border-outline-variant/60 bg-surface-high',
'px-4 py-3 text-sm text-on-surface placeholder:text-outline/50',
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary',
'transition-colors duration-200',
)}
/>
</div>
);
}
interface SummaryTagProps {
label: string;
variant?: 'primary' | 'neutral';
}
function SummaryTag({ label, variant = 'neutral' }: SummaryTagProps) {
return (
<span
className={cn(
'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium',
variant === 'primary'
? 'bg-primary/10 text-primary-dark'
: 'bg-outline-variant/25 text-on-surface',
)}
>
{label}
</span>
);
}
// ─── Loading Dots ─────────────────────────────────────────────────────────────
function LoadingDots() {
return (
<span className="inline-flex items-center gap-1" aria-label="Generating brief">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="w-1.5 h-1.5 rounded-full bg-white"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{
duration: 0.9,
repeat: Infinity,
delay: i * 0.2,
ease: 'easeInOut',
}}
/>
))}
</span>
);
}
// ─── Extended StepContact Props ───────────────────────────────────────────────
interface StepContactProps extends StepProps {
isSubmitting: boolean;
submitError: string | null;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function StepContact({
formData,
setFormData,
onNext,
onBack,
isSubmitting,
submitError,
}: StepContactProps) {
const t = useTranslations('configurator');
const isEmailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email);
const canSubmit =
formData.name.trim().length >= 2 &&
isEmailValid &&
!isSubmitting;
// Build summary tags
const serviceTags = formData.services.map((id) => SERVICE_LABELS[id] ?? id);
const aiTag =
formData.aiEnabled && formData.aiType
? AI_TYPE_LABELS[formData.aiType] ?? formData.aiType
: formData.aiEnabled
? 'AI Enhancement'
: null;
const industryTag = formData.industry ? INDUSTRY_LABELS[formData.industry] ?? formData.industry : null;
const timelineTag = formData.timeline ? TIMELINE_LABELS[formData.timeline] ?? formData.timeline : null;
const allTags = [
...serviceTags.map((s) => ({ label: s, variant: 'primary' as const })),
...(aiTag ? [{ label: aiTag, variant: 'primary' as const }] : []),
...(industryTag ? [{ label: industryTag, variant: 'neutral' as const }] : []),
...(timelineTag ? [{ label: timelineTag, variant: 'neutral' as const }] : []),
];
return (
<div className="flex flex-col gap-6">
{/* Progress */}
<ProgressBar currentStep={3} />
{/* Heading */}
<div>
<h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface">
{t('step3.title')}
</h3>
<p className="mt-1 text-sm text-outline">{t('step3.subtitle')}</p>
</div>
{/* Summary of selections */}
{allTags.length > 0 && (
<div className="rounded-xl border border-outline-variant/40 bg-surface-low px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-label text-outline mb-2.5">
Your selections
</p>
<div className="flex flex-wrap gap-1.5">
{allTags.map((tag, i) => (
<motion.div
key={`${tag.label}-${i}`}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.04, duration: 0.2 }}
>
<SummaryTag label={tag.label} variant={tag.variant} />
</motion.div>
))}
</div>
</div>
)}
{/* Contact fields */}
<div className="flex flex-col gap-4">
<InputField
id="contact-name"
label="Your name"
value={formData.name}
onChange={(v) => setFormData((prev) => ({ ...prev, name: v }))}
placeholder="Sophie Laurent"
required
autoComplete="name"
/>
<InputField
id="contact-company"
label="Company"
value={formData.company}
onChange={(v) => setFormData((prev) => ({ ...prev, company: v }))}
placeholder="Maison Laurent Group"
autoComplete="organization"
/>
<InputField
id="contact-email"
label="Email address"
type="email"
value={formData.email}
onChange={(v) => setFormData((prev) => ({ ...prev, email: v }))}
placeholder="sophie@example.com"
required
autoComplete="email"
/>
</div>
{/* Error state */}
<AnimatePresence>
{submitError && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="rounded-xl border border-red-200 bg-red-50 px-4 py-3"
>
<p className="text-xs text-red-700">{submitError}</p>
</motion.div>
)}
</AnimatePresence>
{/* Navigation */}
<div className="flex gap-3">
<Button
variant="ghost"
onClick={onBack}
disabled={isSubmitting}
className="flex-shrink-0"
>
{t('back')}
</Button>
<Button
variant="primary"
arrow={!isSubmitting}
disabled={!canSubmit}
onClick={onNext}
className="flex-1"
>
{isSubmitting ? (
<span className="flex items-center gap-2">
Generating
<LoadingDots />
</span>
) : (
t('generateBrief')
)}
</Button>
</div>
<p className="text-center text-xs text-outline">
Your information is private and will never be shared.
</p>
</div>
);
}

View File

@@ -0,0 +1,174 @@
'use client';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import Button from '@/components/ui/Button';
import Chip from '@/components/ui/Chip';
import ProgressBar from './ProgressBar';
import type { StepProps } from './WizardContainer';
// ─── Data ─────────────────────────────────────────────────────────────────────
interface IndustryOption {
id: string;
label: string;
}
const INDUSTRIES: IndustryOption[] = [
{ id: 'maritime', label: 'Maritime / Yachting' },
{ id: 'hospitality', label: 'Hospitality' },
{ id: 'technology', label: 'Technology' },
{ id: 'realestate', label: 'Real Estate' },
{ id: 'finance', label: 'Finance' },
{ id: 'ngo', label: 'NGO / Nonprofit' },
{ id: 'other', label: 'Other' },
];
interface TimelineOption {
id: string;
label: string;
}
const TIMELINES: TimelineOption[] = [
{ id: 'asap', label: 'ASAP' },
{ id: '1-3months', label: '13 months' },
{ id: '3-6months', label: '36 months' },
{ id: 'exploring', label: 'Just exploring' },
];
// ─── Component ────────────────────────────────────────────────────────────────
export default function StepDetails({ formData, setFormData, onNext, onBack }: StepProps) {
const t = useTranslations('configurator');
const selectIndustry = (id: string) => {
setFormData((prev) => ({
...prev,
industry: prev.industry === id ? null : id,
}));
};
const selectTimeline = (id: string) => {
setFormData((prev) => ({
...prev,
timeline: prev.timeline === id ? null : id,
}));
};
const canProceed = true; // Step 2 fields are optional
return (
<div className="flex flex-col gap-6">
{/* Progress */}
<ProgressBar currentStep={2} />
{/* Heading */}
<div>
<h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface">
{t('step2.title')}
</h3>
<p className="mt-1 text-sm text-outline">{t('step2.subtitle')}</p>
</div>
{/* Industry */}
<div className="flex flex-col gap-2.5">
<label className="text-xs font-semibold uppercase tracking-label text-outline">
Your industry
</label>
<div className="flex flex-wrap gap-2">
{INDUSTRIES.map((option, index) => (
<motion.div
key={option.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: index * 0.04,
duration: 0.3,
ease: [0.16, 1, 0.3, 1],
}}
>
<Chip
active={formData.industry === option.id}
onClick={() => selectIndustry(option.id)}
>
{option.label}
</Chip>
</motion.div>
))}
</div>
</div>
{/* Scope / Goals */}
<div className="flex flex-col gap-2">
<label
htmlFor="scope-textarea"
className="text-xs font-semibold uppercase tracking-label text-outline"
>
What are you looking to achieve?
<span className="ml-1.5 normal-case font-normal text-outline/70">(optional)</span>
</label>
<textarea
id="scope-textarea"
value={formData.scope}
onChange={(e) =>
setFormData((prev) => ({ ...prev, scope: e.target.value }))
}
placeholder="e.g. We need to replace our current booking system and improve the client-facing experience…"
rows={4}
className={cn(
'w-full resize-none rounded-xl border border-outline-variant/60 bg-surface-high',
'px-4 py-3 text-sm text-on-surface placeholder:text-outline/50',
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary',
'transition-colors duration-200',
'leading-relaxed',
)}
/>
</div>
{/* Timeline */}
<div className="flex flex-col gap-2.5">
<label className="text-xs font-semibold uppercase tracking-label text-outline">
Timeline
</label>
<div className="flex flex-wrap gap-2">
{TIMELINES.map((option, index) => (
<motion.div
key={option.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: index * 0.05,
duration: 0.3,
ease: [0.16, 1, 0.3, 1],
}}
>
<Chip
active={formData.timeline === option.id}
onClick={() => selectTimeline(option.id)}
>
{option.label}
</Chip>
</motion.div>
))}
</div>
</div>
{/* Navigation */}
<div className="flex gap-3">
<Button variant="ghost" onClick={onBack} className="flex-shrink-0">
{t('back')}
</Button>
<Button
variant="primary"
arrow
disabled={!canProceed}
onClick={onNext}
className="flex-1"
>
{t('nextStep')}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,346 @@
'use client';
import { useTranslations } from 'next-intl';
import { motion, AnimatePresence } from 'framer-motion';
import { Globe, Cog, Server, Check, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { springTransition } from '@/lib/animations';
import Button from '@/components/ui/Button';
import Chip from '@/components/ui/Chip';
import ProgressBar from './ProgressBar';
import type { StepProps } from './WizardContainer';
// ─── Data ─────────────────────────────────────────────────────────────────────
interface ServiceOption {
id: string;
icon: React.ElementType;
title: string;
description: string;
}
const SERVICES: ServiceOption[] = [
{
id: 'web',
icon: Globe,
title: 'Web Design & Development',
description:
'Bespoke websites and web applications built from scratch — pixel-perfect design, blazing performance, and clean code.',
},
{
id: 'systems',
icon: Cog,
title: 'Custom Systems',
description:
'Purpose-built CRMs, internal tools, and business platforms crafted to match exactly how your team works.',
},
{
id: 'infrastructure',
icon: Server,
title: 'Digital Infrastructure',
description:
'Private cloud hosting, data sovereignty, DevOps pipelines, and security hardening — your stack, fully owned.',
},
];
interface AITypeOption {
id: string;
label: string;
description: string;
}
const AI_TYPES: AITypeOption[] = [
{
id: 'teammate',
label: 'AI Teammate',
description: 'An intelligent assistant embedded in your internal workflows — drafting, summarising, routing tasks.',
},
{
id: 'customer-facing',
label: 'Customer-Facing AI',
description: 'A smart interface layer for your clients — personalised responses, 24/7 availability, on-brand tone.',
},
{
id: 'data-intelligence',
label: 'Data Intelligence',
description: 'Automated reporting, anomaly detection, and decision-support drawn from your own business data.',
},
{
id: 'notsure',
label: 'Not Sure Yet',
description: 'We\'ll identify the right AI application for your context during our discovery sessions.',
},
];
// ─── Sub-components ───────────────────────────────────────────────────────────
interface ServiceCardProps {
option: ServiceOption;
selected: boolean;
onToggle: () => void;
}
function ServiceCard({ option, selected, onToggle }: ServiceCardProps) {
const Icon = option.icon;
return (
<motion.button
type="button"
onClick={onToggle}
whileTap={{ scale: 0.98 }}
className={cn(
'group relative w-full text-left rounded-2xl p-5 transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
selected
? 'bg-primary/5 shadow-card'
: 'bg-surface-high shadow-subtle hover:shadow-card hover:bg-surface-high',
)}
>
<div className="flex items-start gap-4">
{/* Icon */}
<div
className={cn(
'flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-colors duration-200',
selected
? 'bg-primary/15 text-primary-dark'
: 'bg-surface-low text-outline group-hover:bg-primary/10 group-hover:text-primary',
)}
>
<Icon size={20} strokeWidth={1.5} />
</div>
{/* Text */}
<div className="flex-1 min-w-0">
<p
className={cn(
'text-sm font-semibold leading-tight mb-1 transition-colors duration-200',
selected ? 'text-primary-dark' : 'text-on-surface',
)}
>
{option.title}
</p>
<p className="text-xs text-outline leading-relaxed">{option.description}</p>
</div>
{/* Checkbox */}
<div className="flex-shrink-0 mt-0.5">
<motion.div
className={cn(
'w-5 h-5 rounded-full border-2 flex items-center justify-center',
selected ? 'border-primary bg-primary' : 'border-outline-variant bg-transparent',
)}
animate={
selected
? { scale: 1, borderColor: '#5BA4D9', backgroundColor: '#5BA4D9' }
: { scale: 1, borderColor: '#c2c7ce', backgroundColor: 'transparent' }
}
transition={springTransition}
>
<AnimatePresence>
{selected && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={springTransition}
>
<Check size={11} strokeWidth={3} className="text-white" />
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</div>
</motion.button>
);
}
// ─── AI Toggle ─────────────────────────────────────────────────────────────
interface AIToggleProps {
enabled: boolean;
onToggle: () => void;
}
function AIToggle({ enabled, onToggle }: AIToggleProps) {
return (
<button
type="button"
onClick={onToggle}
className={cn(
'flex items-center gap-3 w-full text-left rounded-xl px-4 py-3',
'transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
enabled
? 'bg-primary/5 shadow-card'
: 'bg-surface-high shadow-subtle hover:shadow-card',
)}
>
<Sparkles
size={16}
strokeWidth={1.5}
className={cn(
'flex-shrink-0 transition-colors duration-200',
enabled ? 'text-primary' : 'text-outline',
)}
/>
<div className="flex-1 min-w-0">
<span
className={cn(
'text-sm font-medium transition-colors duration-200',
enabled ? 'text-primary-dark' : 'text-on-surface',
)}
>
Enhance with AI
</span>
<p className="text-xs text-outline mt-0.5">
We layer intelligent automation into every system we build.
</p>
</div>
{/* Switch */}
<div
className={cn(
'flex-shrink-0 w-10 h-6 rounded-full relative transition-colors duration-300',
enabled ? 'bg-primary' : 'bg-outline-variant',
)}
>
<motion.div
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm"
animate={{ x: enabled ? 18 : 2 }}
transition={springTransition}
/>
</div>
</button>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function StepServices({ formData, setFormData, onNext }: StepProps) {
const t = useTranslations('configurator');
const toggleService = (id: string) => {
setFormData((prev) => ({
...prev,
services: prev.services.includes(id)
? prev.services.filter((s) => s !== id)
: [...prev.services, id],
}));
};
const toggleAI = () => {
setFormData((prev) => ({
...prev,
aiEnabled: !prev.aiEnabled,
aiType: prev.aiEnabled ? null : prev.aiType,
}));
};
const selectAIType = (id: string) => {
setFormData((prev) => ({
...prev,
aiType: prev.aiType === id ? null : id,
}));
};
const canProceed = formData.services.length > 0;
const selectedAIType = AI_TYPES.find((a) => a.id === formData.aiType);
return (
<div className="flex flex-col gap-6">
{/* Progress */}
<ProgressBar currentStep={1} totalSteps={3} />
{/* Heading */}
<div>
<h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface">
{t('step1.title')}
</h3>
<p className="mt-1 text-sm text-outline">{t('step1.subtitle')}</p>
</div>
{/* Service cards */}
<div className="flex flex-col gap-3">
{SERVICES.map((option) => (
<ServiceCard
key={option.id}
option={option}
selected={formData.services.includes(option.id)}
onToggle={() => toggleService(option.id)}
/>
))}
</div>
{/* AI Toggle */}
<div className="flex flex-col gap-3">
<AIToggle enabled={formData.aiEnabled} onToggle={toggleAI} />
{/* AI type chips — stagger in */}
<AnimatePresence>
{formData.aiEnabled && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<div className="pt-1 flex flex-col gap-3">
{/* Chips row */}
<div className="flex flex-wrap gap-2">
{AI_TYPES.map((aiOption, index) => (
<motion.div
key={aiOption.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: index * 0.06,
duration: 0.3,
ease: [0.16, 1, 0.3, 1],
}}
>
<Chip
active={formData.aiType === aiOption.id}
onClick={() => selectAIType(aiOption.id)}
>
{aiOption.label}
</Chip>
</motion.div>
))}
</div>
{/* AI type description */}
<AnimatePresence mode="wait">
{selectedAIType && (
<motion.p
key={selectedAIType.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
className="text-xs text-outline leading-relaxed px-1"
>
{selectedAIType.description}
</motion.p>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* CTA */}
<Button
variant="primary"
arrow
disabled={!canProceed}
onClick={onNext}
className="w-full"
>
{t('nextStep')}
</Button>
</div>
);
}

View File

@@ -0,0 +1,183 @@
'use client';
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import StepServices from './StepServices';
import StepDetails from './StepDetails';
import StepContact from './StepContact';
import StepComplete from './StepComplete';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface WizardFormData {
services: string[];
aiEnabled: boolean;
aiType: string | null;
industry: string | null;
scope: string;
timeline: string | null;
name: string;
company: string;
email: string;
}
export interface StepProps {
formData: WizardFormData;
setFormData: React.Dispatch<React.SetStateAction<WizardFormData>>;
onNext: () => void;
onBack: () => void;
}
// ─── Step slide variants ──────────────────────────────────────────────────────
const makeVariants = (direction: 1 | -1) => ({
initial: {
opacity: 0,
x: direction * 60,
},
animate: {
opacity: 1,
x: 0,
transition: {
duration: 0.4,
ease: [0.16, 1, 0.3, 1] as const,
},
},
exit: {
opacity: 0,
x: direction * -60,
transition: {
duration: 0.3,
ease: [0.4, 0, 1, 1] as const,
},
},
});
// ─── Component ────────────────────────────────────────────────────────────────
const DEFAULT_FORM_DATA: WizardFormData = {
services: [],
aiEnabled: false,
aiType: null,
industry: null,
scope: '',
timeline: null,
name: '',
company: '',
email: '',
};
export default function WizardContainer() {
const [currentStep, setCurrentStep] = useState<1 | 2 | 3 | 4>(1);
const [direction, setDirection] = useState<1 | -1>(1);
const [formData, setFormData] = useState<WizardFormData>(DEFAULT_FORM_DATA);
const [brief, setBrief] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const goNext = () => {
setDirection(1);
setCurrentStep((prev) => Math.min(prev + 1, 4) as 1 | 2 | 3 | 4);
};
const goBack = () => {
setDirection(-1);
setCurrentStep((prev) => Math.max(prev - 1, 1) as 1 | 2 | 3 | 4);
};
const handleSubmit = async () => {
setIsSubmitting(true);
setSubmitError(null);
try {
const response = await fetch('/api/configure', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const data = (await response.json()) as { success: boolean; brief?: string; error?: string };
if (!response.ok || !data.success) {
setSubmitError(data.error ?? 'Something went wrong. Please try again.');
setIsSubmitting(false);
return;
}
setBrief(data.brief ?? '');
setDirection(1);
setCurrentStep(4);
} catch {
setSubmitError('Network error. Please check your connection and try again.');
} finally {
setIsSubmitting(false);
}
};
const stepVariants = makeVariants(direction);
const sharedProps: StepProps = {
formData,
setFormData,
onNext: goNext,
onBack: goBack,
};
return (
<div className="relative overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
{currentStep === 1 && (
<motion.div
key="step-1"
variants={stepVariants}
initial="initial"
animate="animate"
exit="exit"
>
<StepServices {...sharedProps} />
</motion.div>
)}
{currentStep === 2 && (
<motion.div
key="step-2"
variants={stepVariants}
initial="initial"
animate="animate"
exit="exit"
>
<StepDetails {...sharedProps} />
</motion.div>
)}
{currentStep === 3 && (
<motion.div
key="step-3"
variants={stepVariants}
initial="initial"
animate="animate"
exit="exit"
>
<StepContact
{...sharedProps}
onNext={handleSubmit}
isSubmitting={isSubmitting}
submitError={submitError}
/>
</motion.div>
)}
{currentStep === 4 && (
<motion.div
key="step-4"
variants={stepVariants}
initial="initial"
animate="animate"
exit="exit"
>
<StepComplete formData={formData} brief={brief} />
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import { motion } from 'framer-motion';
interface AnimatedCheckmarkProps {
size?: number;
color?: string;
className?: string;
}
export default function AnimatedCheckmark({
size = 56,
color = '#006494',
className,
}: AnimatedCheckmarkProps) {
const strokeWidth = size * 0.0625; // scales proportionally with size
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
// Checkmark path — scaled relative to size
const checkStartX = size * 0.27;
const checkStartY = size * 0.5;
const checkMidX = size * 0.44;
const checkMidY = size * 0.66;
const checkEndX = size * 0.73;
const checkEndY = size * 0.34;
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden="true"
>
{/* Circle */}
<motion.circle
cx={center}
cy={center}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="none"
initial={{ strokeDasharray: circumference, strokeDashoffset: circumference }}
animate={{ strokeDashoffset: 0 }}
transition={{
duration: 0.55,
ease: [0.4, 0, 0.2, 1],
delay: 0.05,
}}
/>
{/* Checkmark */}
<motion.path
d={`M ${checkStartX} ${checkStartY} L ${checkMidX} ${checkMidY} L ${checkEndX} ${checkEndY}`}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{
pathLength: {
duration: 0.4,
ease: [0.4, 0, 0.2, 1],
delay: 0.65,
},
opacity: {
duration: 0.01,
delay: 0.65,
},
}}
/>
</svg>
);
}

View File

@@ -0,0 +1,44 @@
interface CornerBracketProps {
size?: number;
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
color?: string;
className?: string;
}
const rotationMap: Record<NonNullable<CornerBracketProps['position']>, string> = {
'top-left': 'rotate(0deg)',
'top-right': 'rotate(90deg)',
'bottom-right': 'rotate(180deg)',
'bottom-left': 'rotate(270deg)',
};
export default function CornerBracket({
size = 40,
position = 'top-left',
color = '#c2c7ce',
className,
}: CornerBracketProps) {
return (
<div
className={className}
style={{
width: size,
height: size,
transform: rotationMap[position],
flexShrink: 0,
}}
aria-hidden="true"
>
<svg
width={size}
height={size}
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<line x1="1" y1="1" x2="1" y2="18" stroke={color} strokeWidth="1" strokeLinecap="square" />
<line x1="1" y1="1" x2="18" y2="1" stroke={color} strokeWidth="1" strokeLinecap="square" />
</svg>
</div>
);
}

View File

@@ -0,0 +1,402 @@
import React from "react";
interface HeroGeometricProps {
className?: string;
}
export default function HeroGeometric({ className }: HeroGeometricProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1440 900"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
className={className}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
overflow: "hidden",
}}
>
<defs>
{/* Celestial blue at various opacities — all values halved for a more atmospheric effect */}
<style>{`
.cb-fill-03 { fill: #5BA4D9; fill-opacity: 0.015; }
.cb-fill-05 { fill: #5BA4D9; fill-opacity: 0.025; }
.cb-fill-08 { fill: #5BA4D9; fill-opacity: 0.04; }
.cb-fill-12 { fill: #5BA4D9; fill-opacity: 0.06; }
.cb-fill-15 { fill: #5BA4D9; fill-opacity: 0.07; }
.dn-stroke-06 { fill: none; stroke: #1C2B3A; stroke-opacity: 0.03; }
.dn-stroke-08 { fill: none; stroke: #1C2B3A; stroke-opacity: 0.04; }
.dn-stroke-10 { fill: none; stroke: #1C2B3A; stroke-opacity: 0.05; }
.dn-stroke-14 { fill: none; stroke: #1C2B3A; stroke-opacity: 0.07; }
.cb-stroke-08 { fill: none; stroke: #5BA4D9; stroke-opacity: 0.04; }
.cb-stroke-12 { fill: none; stroke: #5BA4D9; stroke-opacity: 0.06; }
.cb-dot { fill: #1C2B3A; fill-opacity: 0.06; }
.cb-dot-sm { fill: #5BA4D9; fill-opacity: 0.05; }
`}</style>
</defs>
{/* ─── BACKGROUND LAYER ─────────────────────────────────────────── */}
<g data-layer="background">
{/* Large low-opacity rectangle — far upper-left anchor */}
<rect
x="-60" y="-40"
width="680" height="480"
rx="2"
className="cb-fill-03 dn-stroke-06"
strokeWidth="0.5"
/>
{/* Horizon guide line — full width */}
<line
x1="0" y1="520"
x2="1440" y2="520"
className="dn-stroke-06"
strokeWidth="0.5"
strokeDasharray="6 14"
/>
{/* Vertical axis line — far right third */}
<line
x1="1060" y1="0"
x2="1060" y2="900"
className="dn-stroke-06"
strokeWidth="0.5"
strokeDasharray="4 20"
/>
{/* Large background circle — lower-right anchor */}
<circle
cx="1280" cy="740"
r="320"
className="cb-fill-03 dn-stroke-06"
strokeWidth="0.5"
/>
{/* Secondary background circle — upper-right bleed */}
<circle
cx="1380" cy="-60"
r="200"
className="cb-fill-03 dn-stroke-08"
strokeWidth="0.5"
/>
{/* Wide shallow rectangle — lower band */}
<rect
x="180" y="760"
width="900" height="80"
rx="1"
className="cb-fill-03 dn-stroke-06"
strokeWidth="0.5"
/>
{/* Fine grid cluster — upper-left quadrant (sparse) */}
{/* Horizontal grid lines */}
{[80, 120, 160, 200, 240].map((y) => (
<line
key={`bg-hgrid-${y}`}
x1="40" y1={y}
x2="380" y2={y}
className="dn-stroke-06"
strokeWidth="0.5"
/>
))}
{/* Vertical grid lines */}
{[60, 100, 140, 180, 220, 260, 300, 340, 380].map((x) => (
<line
key={`bg-vgrid-${x}`}
x1={x} y1="60"
x2={x} y2="260"
className="dn-stroke-06"
strokeWidth="0.5"
/>
))}
{/* Blueprint registration mark — upper left */}
<line x1="28" y1="40" x2="28" y2="56" className="dn-stroke-10" strokeWidth="1" />
<line x1="20" y1="48" x2="36" y2="48" className="dn-stroke-10" strokeWidth="1" />
{/* Blueprint registration mark — far right mid */}
<line x1="1400" y1="418" x2="1400" y2="434" className="dn-stroke-10" strokeWidth="1" />
<line x1="1392" y1="426" x2="1408" y2="426" className="dn-stroke-10" strokeWidth="1" />
</g>
{/* ─── MIDGROUND LAYER ──────────────────────────────────────────── */}
<g data-layer="midground">
{/* Large architectural rectangle — right column */}
<rect
x="1100" y="80"
width="260" height="560"
rx="3"
className="cb-fill-05 dn-stroke-08"
strokeWidth="0.75"
/>
{/* Inset rectangle inside large right column */}
<rect
x="1126" y="108"
width="208" height="120"
rx="2"
className="cb-fill-08 dn-stroke-10"
strokeWidth="0.75"
/>
{/* Tall thin divider — left of center */}
<rect
x="520" y="60"
width="2" height="420"
className="cb-fill-15"
/>
{/* Wide horizontal band — upper area */}
<rect
x="460" y="60"
width="540" height="1.5"
className="cb-fill-12"
/>
{/* Mid arc — upper right area, partial */}
<path
d="M 1060 160 A 180 180 0 0 1 1240 160"
className="cb-stroke-08"
strokeWidth="1"
/>
{/* Concentric arc pair — lower left */}
<path
d="M 100 900 A 340 340 0 0 1 440 560"
className="dn-stroke-08"
strokeWidth="0.75"
/>
<path
d="M 60 900 A 380 380 0 0 1 440 520"
className="dn-stroke-06"
strokeWidth="0.5"
strokeDasharray="3 9"
/>
{/* Section label rectangle — left */}
<rect
x="60" y="320"
width="160" height="44"
rx="2"
className="cb-fill-08 dn-stroke-10"
strokeWidth="0.75"
/>
{/* Thin horizontal rule under label */}
<line
x1="60" y1="374"
x2="220" y2="374"
className="dn-stroke-08"
strokeWidth="0.75"
/>
{/* Dotted measurement track — horizontal mid */}
<line
x1="560" y1="440"
x2="980" y2="440"
className="dn-stroke-08"
strokeWidth="0.75"
strokeDasharray="2 6"
/>
{/* Tick marks on measurement track */}
{[560, 640, 720, 800, 880, 980].map((x) => (
<line
key={`tick-${x}`}
x1={x} y1="434"
x2={x} y2="446"
className="dn-stroke-10"
strokeWidth="0.75"
/>
))}
{/* Small dimension rectangle — lower center */}
<rect
x="680" y="620"
width="200" height="120"
rx="2"
className="cb-fill-05 dn-stroke-08"
strokeWidth="0.75"
/>
{/* Diagonal tension line — upper center to right */}
<line
x1="520" y1="60"
x2="1100" y2="640"
className="dn-stroke-06"
strokeWidth="0.5"
/>
{/* Medium circle — center-left */}
<circle
cx="380" cy="480"
r="90"
className="cb-fill-05 dn-stroke-08"
strokeWidth="0.75"
/>
{/* Inner ring of medium circle */}
<circle
cx="380" cy="480"
r="60"
className="cb-stroke-08"
strokeWidth="0.5"
strokeDasharray="4 8"
/>
{/* Small circle — upper right cluster */}
<circle
cx="1200" cy="260"
r="36"
className="cb-fill-08 dn-stroke-10"
strokeWidth="0.75"
/>
{/* Dot grid — right of center, 4x5 */}
{[0, 1, 2, 3].map((col) =>
[0, 1, 2, 3, 4].map((row) => (
<circle
key={`dot-${col}-${row}`}
cx={760 + col * 28}
cy={160 + row * 28}
r="1.5"
className="cb-dot"
/>
))
)}
</g>
{/* ─── FOREGROUND LAYER ─────────────────────────────────────────── */}
<g data-layer="foreground">
{/* Blueprint frame — upper left inset panel */}
<rect
x="80" y="80"
width="320" height="200"
rx="2"
className="cb-fill-05 dn-stroke-14"
strokeWidth="1"
/>
{/* Inner division of upper-left panel */}
<line
x1="80" y1="160"
x2="400" y2="160"
className="dn-stroke-10"
strokeWidth="0.75"
/>
<line
x1="240" y1="80"
x2="240" y2="280"
className="dn-stroke-10"
strokeWidth="0.75"
/>
{/* Corner notches on blueprint frame */}
<path d="M 80 100 L 80 80 L 100 80" className="dn-stroke-14" strokeWidth="1" fill="none" />
<path d="M 380 80 L 400 80 L 400 100" className="dn-stroke-14" strokeWidth="1" fill="none" />
<path d="M 80 260 L 80 280 L 100 280" className="dn-stroke-14" strokeWidth="1" fill="none" />
<path d="M 380 280 L 400 280 L 400 260" className="dn-stroke-14" strokeWidth="1" fill="none" />
{/* Precision arc — upper left panel, quarter circle */}
<path
d="M 160 80 A 80 80 0 0 1 240 160"
className="cb-stroke-12"
strokeWidth="1"
/>
{/* Foreground tall rect — left bleed */}
<rect
x="-20" y="400"
width="100" height="340"
rx="2"
className="cb-fill-08 dn-stroke-10"
strokeWidth="0.75"
/>
{/* Foreground arc — lower right */}
<path
d="M 1140 640 A 240 240 0 0 0 1380 640"
className="dn-stroke-10"
strokeWidth="1"
/>
<path
d="M 1160 640 A 200 200 0 0 0 1360 640"
className="cb-stroke-12"
strokeWidth="0.75"
strokeDasharray="3 7"
/>
{/* Callout line — from blueprint panel to right */}
<line
x1="400" y1="180"
x2="520" y2="180"
className="dn-stroke-14"
strokeWidth="0.75"
/>
<circle cx="520" cy="180" r="3" className="cb-fill-15" />
{/* Thin vertical guide — right of blueprint panel */}
<line
x1="520" y1="80"
x2="520" y2="360"
className="dn-stroke-08"
strokeWidth="0.5"
strokeDasharray="2 8"
/>
{/* Small accent square — upper center */}
<rect
x="690" y="100"
width="48" height="48"
rx="1"
className="cb-fill-12 dn-stroke-14"
strokeWidth="1"
transform="rotate(12 714 124)"
/>
{/* Dot accent — scattered foreground points */}
<circle cx="460" cy="320" r="2.5" className="cb-dot-sm" />
<circle cx="560" cy="280" r="2" className="cb-dot-sm" />
<circle cx="600" cy="340" r="1.5" className="cb-dot-sm" />
<circle cx="1080" cy="680" r="2.5" className="cb-dot-sm" />
<circle cx="1140" cy="700" r="1.5" className="cb-dot-sm" />
<circle cx="300" cy="600" r="2" className="cb-dot-sm" />
<circle cx="340" cy="560" r="1.5" className="cb-dot-sm" />
{/* Small labeled rectangle — lower left */}
<rect
x="100" y="700"
width="120" height="60"
rx="1"
className="cb-fill-08 dn-stroke-10"
strokeWidth="0.75"
/>
{/* Tick detail inside small rect */}
<line x1="100" y1="720" x2="220" y2="720" className="dn-stroke-08" strokeWidth="0.5" />
<line x1="160" y1="700" x2="160" y2="760" className="dn-stroke-08" strokeWidth="0.5" />
{/* Fine detail lines — lower right corner cluster */}
<line x1="1300" y1="820" x2="1440" y2="820" className="dn-stroke-08" strokeWidth="0.5" />
<line x1="1300" y1="840" x2="1440" y2="840" className="dn-stroke-06" strokeWidth="0.5" strokeDasharray="3 6" />
<line x1="1320" y1="800" x2="1320" y2="900" className="dn-stroke-08" strokeWidth="0.5" />
<circle cx="1320" cy="820" r="3" className="cb-fill-15" />
{/* Radius annotation arc — center large circle */}
<path
d="M 320 480 L 380 480"
className="dn-stroke-14"
strokeWidth="0.75"
/>
<circle cx="380" cy="480" r="2" className="cb-fill-15" />
</g>
</svg>
);
}

View File

@@ -0,0 +1,234 @@
import { useTranslations } from 'next-intl'
import { Link } from '@/i18n/navigation'
// ── Static link data ─────────────────────────────────────────────────────────
const SERVICE_LINKS = [
{ label: 'Design & Development', href: '/services#design-development' },
{ label: 'Custom Systems', href: '/services#custom-systems' },
{ label: 'Digital Infrastructure', href: '/services#infrastructure' },
{ label: 'AI & Automation', href: '/services#ai-automation' },
] as const
// Studio links use nav translation keys where they exist (process, work, about)
const STUDIO_NAV_LINKS = [
{ navKey: 'process' as const, href: '/#process' },
{ navKey: 'work' as const, href: '/work' },
{ navKey: 'about' as const, href: '/about' },
] as const
function LinkedInIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
)
}
function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
</svg>
)
}
function XIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
)
}
const SOCIAL_LINKS = [
{
label: 'LinkedIn',
href: 'https://www.linkedin.com/company/letsbe-digital',
Icon: LinkedInIcon,
},
{
label: 'GitHub',
href: 'https://github.com/letsbe-digital',
Icon: GitHubIcon,
},
{
label: 'X',
href: 'https://x.com/letsbe_digital',
Icon: XIcon,
},
] as const
const CAL_LINK = 'https://cal.com/letsbe/discovery'
// ── Sub-components ───────────────────────────────────────────────────────────
function FooterHeading({ children }: { children: React.ReactNode }) {
return (
<p className="label-md text-on-surface/40 mb-5 tracking-widest">{children}</p>
)
}
function InternalLink({
href,
className,
children,
}: {
href: string
className?: string
children: React.ReactNode
}) {
return (
<Link href={href as any} className={className}>
{children}
</Link>
)
}
const linkClass =
'text-sm text-on-surface/60 hover:text-on-surface transition-colors duration-200 leading-relaxed'
// ── Main Footer component ─────────────────────────────────────────────────────
export default function Footer() {
const t = useTranslations('footer')
const tNav = useTranslations('nav')
const currentYear = new Date().getFullYear()
return (
<footer
className="bg-surface-low"
role="contentinfo"
aria-label="Site footer"
>
{/* ── Top grid ── */}
<div className="mx-auto max-w-7xl px-6 lg:px-8 pt-16 pb-12">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-12 lg:gap-8">
{/* Col 1 — Brand */}
<div className="sm:col-span-2 lg:col-span-1 flex flex-col gap-5">
<p
className="font-serif text-3xl text-on-surface tracking-tight leading-none"
aria-label="LetsBe."
>
LetsBe.
</p>
<p className="text-sm text-on-surface/55 leading-relaxed max-w-[260px]">
{t('tagline')}
</p>
<p className="label-md text-on-surface/35 tracking-widest">
{t('location')}
</p>
</div>
{/* Col 2 — Services */}
<div>
<FooterHeading>{t('services')}</FooterHeading>
<ul className="flex flex-col gap-3" role="list">
{SERVICE_LINKS.map(({ label, href }) => (
<li key={label}>
<InternalLink href={href} className={linkClass}>
{label}
</InternalLink>
</li>
))}
</ul>
</div>
{/* Col 3 — Studio */}
<div>
<FooterHeading>{t('studio')}</FooterHeading>
<ul className="flex flex-col gap-3" role="list">
{STUDIO_NAV_LINKS.map(({ navKey, href }) => (
<li key={navKey}>
<InternalLink href={href} className={linkClass}>
{tNav(navKey)}
</InternalLink>
</li>
))}
{/* Contact — separate entry pointing to configure section */}
<li>
<InternalLink href="/#configure" className={linkClass}>
Contact
</InternalLink>
</li>
</ul>
</div>
{/* Col 4 — Connect */}
<div>
<FooterHeading>{t('connect')}</FooterHeading>
{/* Social icons */}
<ul className="flex items-center gap-4 mb-6" role="list">
{SOCIAL_LINKS.map(({ label, href, Icon }) => (
<li key={label}>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
className="inline-flex items-center justify-center w-9 h-9 rounded-full text-on-surface/40 hover:text-primary transition-all duration-200 hover:-translate-y-0.5"
style={{ willChange: 'transform' }}
>
<Icon className="w-[18px] h-[18px]" />
</a>
</li>
))}
</ul>
{/* Book a Call CTA */}
<a
href={CAL_LINK}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full label-md transition-all duration-300 hover:scale-[1.03] hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
style={{
background: 'linear-gradient(135deg, #006494, #5BA4D9)',
color: '#fff',
}}
>
{tNav('bookCall')}
</a>
</div>
</div>
</div>
{/* Tonal divider */}
<div
className="mx-6 lg:mx-8 h-px"
style={{ backgroundColor: 'rgba(25,28,29,0.07)' }}
aria-hidden="true"
/>
{/* ── Bottom bar ── */}
<div className="mx-auto max-w-7xl px-6 lg:px-8 py-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<p className="text-xs text-on-surface/35">
© {currentYear} LetsBe. Digital Studio
</p>
<ul className="flex items-center gap-5" role="list">
<li>
<InternalLink
href="/privacy"
className="text-xs text-on-surface/40 hover:text-on-surface transition-colors duration-200"
>
{t('privacy')}
</InternalLink>
</li>
<li>
<InternalLink
href="/terms"
className="text-xs text-on-surface/40 hover:text-on-surface transition-colors duration-200"
>
{t('terms')}
</InternalLink>
</li>
</ul>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,332 @@
'use client'
import { useEffect, useState } from 'react'
import Image from 'next/image'
import { Menu, X } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { useTranslations } from 'next-intl'
import { Link, usePathname, useRouter } from '@/i18n/navigation'
import { locales } from '@/i18n/config'
// Pages that have dedicated routes vs. homepage anchor sections
const ROUTE_LINKS: Record<string, string> = {
services: '/services',
work: '/work',
about: '/about',
}
const ANCHOR_LINKS: Record<string, string> = {
services: '#services',
configure: '#configure',
process: '#process',
work: '#work',
about: '#about',
}
const NAV_KEYS = ['services', 'configure', 'process', 'work', 'about'] as const
type NavKey = (typeof NAV_KEYS)[number]
function useScrolled(threshold = 100) {
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > threshold)
// check immediately in case page is already scrolled
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [threshold])
return scrolled
}
interface NavLinkProps {
navKey: NavKey
label: string
isHomePage: boolean
onClick?: () => void
mobile?: boolean
}
function NavLink({ navKey, label, isHomePage, onClick, mobile }: NavLinkProps) {
const pathname = usePathname()
const isActive = pathname === ROUTE_LINKS[navKey]
const sharedStyle = mobile
? 'block w-full text-left text-2xl font-serif text-on-surface py-3'
: 'label-md text-on-surface/70 hover:text-on-surface transition-colors duration-200 relative group'
// On homepage, use anchor links for smooth scrolling
if (isHomePage || !ROUTE_LINKS[navKey]) {
return (
<a
href={ANCHOR_LINKS[navKey]}
className={sharedStyle}
onClick={onClick}
>
{label}
{!mobile && (
<span
className="absolute -bottom-1 left-0 h-px bg-primary transition-all duration-300 w-0 group-hover:w-full"
aria-hidden="true"
/>
)}
</a>
)
}
// On other pages, use the actual route
return (
<Link
href={ROUTE_LINKS[navKey] as any}
className={`${sharedStyle} ${isActive ? 'text-primary' : ''}`}
onClick={onClick}
>
{label}
{!mobile && (
<span
className={`absolute -bottom-1 left-0 h-px bg-primary transition-all duration-300 ${
isActive ? 'w-full' : 'w-0 group-hover:w-full'
}`}
aria-hidden="true"
/>
)}
</Link>
)
}
export default function Nav() {
const t = useTranslations('nav')
const pathname = usePathname()
const router = useRouter()
const scrolled = useScrolled(100)
const [mobileOpen, setMobileOpen] = useState(false)
// Derive current locale from pathname (next-intl with localePrefix: 'as-needed')
// The pathname from usePathname() is always locale-stripped for the default locale.
// We can detect the locale from the URL directly.
const [currentLocale, setCurrentLocale] = useState<string>('en')
useEffect(() => {
// Read locale from <html lang> attribute set by the layout
const htmlLang = document.documentElement.lang
if (locales.includes(htmlLang as any)) {
setCurrentLocale(htmlLang)
}
}, [pathname])
const otherLocale = locales.find((l) => l !== currentLocale) ?? 'fr'
const isHomePage = pathname === '/'
function handleLocaleSwitch() {
router.replace(pathname as any, { locale: otherLocale as any })
}
// Prevent body scroll when mobile menu is open
useEffect(() => {
document.body.style.overflow = mobileOpen ? 'hidden' : ''
return () => {
document.body.style.overflow = ''
}
}, [mobileOpen])
return (
<>
{/* ── Fixed navbar ── */}
<motion.header
className="fixed top-0 left-0 right-0 z-50"
initial={false}
animate={scrolled ? 'glass' : 'transparent'}
variants={{
transparent: {
backgroundColor: 'rgba(255,255,255,0)',
backdropFilter: 'blur(0px)',
boxShadow: '0 0px 0px rgba(25,28,29,0)',
} as any,
glass: {
backgroundColor: 'rgba(255,255,255,0.82)',
backdropFilter: 'blur(20px)',
boxShadow: '0 20px 40px rgba(25,28,29,0.06)',
} as any,
}}
transition={{ duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
role="banner"
>
<nav
className="mx-auto max-w-7xl px-6 lg:px-8 h-[72px] flex items-center justify-between gap-8"
aria-label="Main navigation"
>
{/* ── Logo ── */}
<Link href="/" aria-label="LetsBe. — Back to homepage" className="shrink-0">
<Image
src="/images/letsbe-logo-short.png"
alt="LetsBe."
width={132}
height={44}
className="h-11 w-auto object-contain"
priority
/>
</Link>
{/* ── Desktop nav links (center) ── */}
<ul className="hidden lg:flex items-center gap-8 xl:gap-10" role="list">
{NAV_KEYS.map((key) => (
<li key={key}>
<NavLink
navKey={key}
label={t(key)}
isHomePage={isHomePage}
/>
</li>
))}
</ul>
{/* ── Desktop right controls ── */}
<div className="hidden lg:flex items-center gap-4 shrink-0">
{/* Language toggle */}
<button
onClick={handleLocaleSwitch}
className="label-md text-on-surface/60 hover:text-on-surface transition-colors duration-200 px-2 py-1 rounded focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
aria-label={`Switch to ${otherLocale === 'fr' ? 'French' : 'English'}`}
>
{otherLocale.toUpperCase()}
</button>
{/* Start a Project CTA */}
<a
href="#configure"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full text-white label-md whitespace-nowrap transition-all duration-300 hover:scale-[1.03] hover:shadow-lg focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
style={{
background: 'linear-gradient(135deg, #006494, #5BA4D9)',
}}
>
{t('startProject')}
</a>
</div>
{/* ── Mobile hamburger ── */}
<button
className="lg:hidden p-2 -mr-2 text-on-surface focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 rounded"
onClick={() => setMobileOpen(true)}
aria-label="Open navigation menu"
aria-expanded={mobileOpen}
aria-controls="mobile-menu"
>
<Menu size={24} strokeWidth={1.5} />
</button>
</nav>
</motion.header>
{/* ── Mobile menu overlay + panel ── */}
<AnimatePresence>
{mobileOpen && (
<>
{/* Backdrop */}
<motion.div
key="backdrop"
className="fixed inset-0 z-[60] bg-on-surface/30"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
onClick={() => setMobileOpen(false)}
aria-hidden="true"
/>
{/* Slide-in panel */}
<motion.div
key="panel"
id="mobile-menu"
role="dialog"
aria-modal="true"
aria-label="Navigation menu"
className="fixed top-0 right-0 bottom-0 z-[70] w-[min(320px,85vw)] bg-surface-high flex flex-col"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
>
{/* Panel header */}
<div className="flex items-center justify-between px-6 py-5">
<Link
href="/"
onClick={() => setMobileOpen(false)}
aria-label="LetsBe. — Back to homepage"
>
<Image
src="/images/letsbe-logo-short.png"
alt="LetsBe."
width={100}
height={34}
className="h-8 w-auto object-contain"
/>
</Link>
<button
onClick={() => setMobileOpen(false)}
className="p-2 -mr-2 text-on-surface/60 hover:text-on-surface transition-colors rounded focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
aria-label="Close navigation menu"
>
<X size={22} strokeWidth={1.5} />
</button>
</div>
{/* Tonal divider */}
<div className="h-px mx-6 bg-surface-low" aria-hidden="true" />
{/* Nav links */}
<nav className="flex-1 px-6 py-8 overflow-y-auto" aria-label="Mobile navigation">
<ul className="flex flex-col" role="list">
{NAV_KEYS.map((key, i) => (
<motion.li
key={key}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.05 + i * 0.06, duration: 0.3 }}
>
<NavLink
navKey={key}
label={t(key)}
isHomePage={isHomePage}
onClick={() => setMobileOpen(false)}
mobile
/>
{i < NAV_KEYS.length - 1 && (
<div className="h-px bg-surface-low my-1" aria-hidden="true" />
)}
</motion.li>
))}
</ul>
</nav>
{/* Bottom controls */}
<div className="px-6 pb-8 pt-4 flex flex-col gap-3">
{/* Language toggle */}
<button
onClick={() => {
handleLocaleSwitch()
setMobileOpen(false)
}}
className="w-full py-3 label-md text-on-surface/60 hover:text-on-surface transition-colors duration-200 text-left focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 rounded"
>
{otherLocale === 'fr' ? '🇫🇷 Français' : '🇬🇧 English'}
</button>
{/* CTA */}
<a
href="#configure"
onClick={() => setMobileOpen(false)}
className="w-full inline-flex items-center justify-center py-3.5 rounded-full text-white label-md transition-all duration-200 active:scale-95 focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
style={{
background: 'linear-gradient(135deg, #006494, #5BA4D9)',
}}
>
{t('startProject')}
</a>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</>
)
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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">
&ldquo;{t('philosophy.quote')}&rdquo;
</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&rsquo;Azur</p>
</motion.div>
</div>
</div>
</div>
</section>
);
}

View 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 24 (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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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&apos;s possible.
</p>
</ScrollReveal>
</div>
</div>
</div>
</section>
);
}

View 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>
);
}

View File

@@ -0,0 +1,141 @@
'use client';
import { forwardRef } from 'react';
import Link from 'next/link';
import { cn } from '@/lib/utils';
type ButtonVariant = 'primary' | 'secondary' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonBaseProps {
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
arrow?: boolean;
children: React.ReactNode;
}
interface ButtonAsButtonProps
extends ButtonBaseProps,
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, keyof ButtonBaseProps> {
href?: undefined;
}
interface ButtonAsLinkProps extends ButtonBaseProps {
href: string;
target?: string;
rel?: string;
}
type ButtonProps = ButtonAsButtonProps | ButtonAsLinkProps;
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-4 py-2 text-sm gap-1.5',
md: 'px-6 py-3 text-sm gap-2',
lg: 'px-8 py-4 text-base gap-2.5',
};
// Primary gradient is applied via inline style on the rendered element
// because bg-gradient-cta (a custom Tailwind backgroundImage utility) does not
// reliably compile in Tailwind v4 without explicit CSS variable support.
const PRIMARY_GRADIENT_STYLE: React.CSSProperties = {
background: 'linear-gradient(135deg, #006494, #5BA4D9)',
};
const variantClasses: Record<ButtonVariant, string> = {
primary: [
'text-white font-medium',
'shadow-[0_4px_16px_rgba(0,100,148,0.25)]',
'hover:shadow-[0_8px_24px_rgba(0,100,148,0.35)]',
'active:shadow-[0_2px_8px_rgba(0,100,148,0.2)]',
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
].join(' '),
secondary: [
'bg-transparent text-primary-dark font-medium',
'ring-1 ring-inset ring-primary',
'hover:bg-primary/5',
'active:bg-primary/10',
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
].join(' '),
ghost: [
'bg-transparent text-on-surface font-medium',
'hover:bg-on-surface/5',
'active:bg-on-surface/10',
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
].join(' '),
};
const baseClasses = [
'inline-flex items-center justify-center',
'rounded-xl',
'transition-all duration-200 ease-out',
'hover:-translate-y-px active:translate-y-0',
'cursor-pointer select-none',
'whitespace-nowrap',
'outline-none',
'disabled:opacity-50 disabled:pointer-events-none',
].join(' ');
const ArrowIcon = () => (
<span aria-hidden="true" className="transition-transform duration-200 group-hover:translate-x-0.5">
</span>
);
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'md',
className,
arrow = false,
children,
...props
},
ref,
) => {
const classes = cn(
baseClasses,
sizeClasses[size],
variantClasses[variant],
'group',
className,
);
const gradientStyle = variant === 'primary' ? PRIMARY_GRADIENT_STYLE : undefined;
if ('href' in props && props.href !== undefined) {
const { href, target, rel, ...linkProps } = props as ButtonAsLinkProps;
// Strip non-anchor props before passing to Link
void linkProps;
return (
<Link
href={href}
target={target}
rel={rel}
className={classes}
style={gradientStyle}
>
{children}
{arrow && <ArrowIcon />}
</Link>
);
}
const { href: _href, ...buttonProps } = props as ButtonAsButtonProps & { href?: undefined };
void _href;
return (
<button ref={ref} className={classes} style={gradientStyle} {...buttonProps}>
{children}
{arrow && <ArrowIcon />}
</button>
);
},
);
Button.displayName = 'Button';
export default Button;
export type { ButtonProps, ButtonVariant, ButtonSize };

View File

@@ -0,0 +1,53 @@
import { forwardRef } from 'react';
import { cn } from '@/lib/utils';
type CardVariant = 'default' | 'surface-low';
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: CardVariant;
hover?: boolean;
className?: string;
children: React.ReactNode;
}
const variantClasses: Record<CardVariant, string> = {
default: 'bg-surface-high shadow-card',
'surface-low': 'bg-surface-low shadow-none',
};
const Card = forwardRef<HTMLDivElement, CardProps>(
(
{
variant = 'default',
hover = true,
className,
children,
...props
},
ref,
) => {
return (
<div
ref={ref}
className={cn(
'rounded-xl',
variantClasses[variant],
hover && [
'transition-all duration-300 ease-out',
'hover:-translate-y-1.5',
'hover:shadow-[0_20px_40px_rgba(25,28,29,0.06)]',
],
className,
)}
{...props}
>
{children}
</div>
);
},
);
Card.displayName = 'Card';
export default Card;
export type { CardProps, CardVariant };

View File

@@ -0,0 +1,84 @@
'use client';
import { forwardRef } from 'react';
import { cn } from '@/lib/utils';
type ChipSize = 'sm' | 'md';
interface ChipProps extends React.HTMLAttributes<HTMLSpanElement> {
active?: boolean;
size?: ChipSize;
className?: string;
children: React.ReactNode;
onClick?: () => void;
}
const sizeClasses: Record<ChipSize, string> = {
sm: 'px-3 py-1 text-xs',
md: 'px-4 py-1.5 text-sm',
};
const Chip = forwardRef<HTMLSpanElement, ChipProps>(
(
{
active = false,
size = 'md',
className,
children,
onClick,
...props
},
ref,
) => {
const isInteractive = typeof onClick === 'function';
return (
<span
ref={ref}
role={isInteractive ? 'button' : undefined}
tabIndex={isInteractive ? 0 : undefined}
onClick={onClick}
onKeyDown={
isInteractive
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
}
: undefined
}
className={cn(
// Base
'inline-flex items-center justify-center',
'rounded-full font-medium',
'transition-colors duration-150 ease-out',
'select-none',
// Size
sizeClasses[size],
// State: inactive
!active && [
'bg-surface-low text-on-surface',
isInteractive && 'hover:bg-outline-variant/30 cursor-pointer',
],
// State: active
active && 'bg-primary/10 text-primary-dark',
// Focus for interactive
isInteractive && [
'outline-none',
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
],
className,
)}
{...props}
>
{children}
</span>
);
},
);
Chip.displayName = 'Chip';
export default Chip;
export type { ChipProps, ChipSize };

View File

@@ -0,0 +1,82 @@
'use client';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import {
revealVariants,
fadeVariants,
slideLeftVariants,
slideRightVariants,
staggerContainer,
viewportOnce,
} from '@/lib/animations';
import type { Variants } from 'framer-motion';
type RevealVariant = 'fadeUp' | 'fadeIn' | 'slideLeft' | 'slideRight';
interface ScrollRevealProps {
children: React.ReactNode;
className?: string;
variant?: RevealVariant;
delay?: number;
stagger?: boolean;
}
const variantMap: Record<RevealVariant, Variants> = {
fadeUp: revealVariants,
fadeIn: fadeVariants,
slideLeft: slideLeftVariants,
slideRight: slideRightVariants,
};
export default function ScrollReveal({
children,
className,
variant = 'fadeUp',
delay,
stagger = false,
}: ScrollRevealProps) {
if (stagger) {
return (
<motion.div
variants={staggerContainer}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
className={cn(className)}
>
{children}
</motion.div>
);
}
const selectedVariants = variantMap[variant];
// If a delay is provided, override the transition on the visible state
const resolvedVariants: Variants = delay
? {
...selectedVariants,
visible: {
...(selectedVariants.visible as object),
transition: {
...((selectedVariants.visible as { transition?: object }).transition ?? {}),
delay,
},
},
}
: selectedVariants;
return (
<motion.div
variants={resolvedVariants}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
className={cn(className)}
>
{children}
</motion.div>
);
}
export type { ScrollRevealProps, RevealVariant };

View File

@@ -0,0 +1,73 @@
'use client';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import {
revealVariants,
staggerContainer,
viewportOnce,
} from '@/lib/animations';
interface SectionHeaderProps {
eyebrow: string;
title: string;
subtitle?: string;
className?: string;
align?: 'left' | 'center';
}
export default function SectionHeader({
eyebrow,
title,
subtitle,
className,
align = 'center',
}: SectionHeaderProps) {
const isCenter = align === 'center';
return (
<motion.div
variants={staggerContainer}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
className={cn(
'flex flex-col',
isCenter ? 'items-center text-center' : 'items-start text-left',
className,
)}
>
<motion.span
variants={revealVariants}
className="label-md text-primary mb-3"
>
{eyebrow}
</motion.span>
<motion.h2
variants={revealVariants}
className={cn(
'font-serif font-semibold tracking-headline text-on-surface',
'text-4xl md:text-5xl',
isCenter && 'max-w-3xl',
)}
>
{title}
</motion.h2>
{subtitle && (
<motion.p
variants={revealVariants}
className={cn(
'mt-4 text-lg text-outline leading-relaxed',
isCenter ? 'max-w-2xl' : 'max-w-2xl',
)}
>
{subtitle}
</motion.p>
)}
</motion.div>
);
}
export type { SectionHeaderProps };