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,420 @@
import { setRequestLocale } from 'next-intl/server';
import { Shield, PenTool, Users } from 'lucide-react';
import ScrollReveal from '@/components/ui/ScrollReveal';
import Button from '@/components/ui/Button';
import CornerBracket from '@/components/icons/CornerBracket';
import { routing } from '@/i18n/routing';
// ─── Static Generation ────────────────────────────────────────────────────────
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
// ─── Data ─────────────────────────────────────────────────────────────────────
const PILLARS = [
{
id: 'ownership',
Icon: Shield,
title: 'Ownership & Privacy',
description:
'We build on infrastructure you control. No vendor lock-in, no opaque SaaS dependencies quietly holding your data hostage. Your platform, your servers, your rules — backed by engineering that makes it maintainable.',
},
{
id: 'craftsmanship',
Icon: PenTool,
title: 'Craftsmanship',
description:
'The gap between a website that works and one that endures is craft. We sweat the typography, the transitions, the query performance, the edge cases. Every interface we ship is something we would be proud to sign.',
},
{
id: 'one-team',
Icon: Users,
title: 'One Team, Everything',
description:
'Strategy, design, engineering, infrastructure — under one roof, one point of contact, one shared standard of quality. No handoffs between agencies. No telephone-game briefs. Just people who care about the whole thing.',
},
];
// ─── Sub-components ───────────────────────────────────────────────────────────
function StoryGeometry() {
return (
<div
className="absolute inset-0 overflow-hidden rounded-xl"
aria-hidden="true"
>
{/* Primary gradient circle — top right */}
<div
className="absolute rounded-full"
style={{
width: '70%',
height: '70%',
top: '-15%',
right: '-15%',
background:
'radial-gradient(circle, rgba(91,164,217,0.10) 0%, rgba(0,100,148,0.05) 55%, transparent 100%)',
}}
/>
{/* Secondary circle — bottom left */}
<div
className="absolute rounded-full"
style={{
width: '55%',
height: '55%',
bottom: '-10%',
left: '-8%',
background:
'radial-gradient(circle, rgba(46,196,160,0.07) 0%, transparent 70%)',
}}
/>
{/* Diagonal grid texture */}
<div
className="absolute inset-0 opacity-[0.025]"
style={{
backgroundImage:
'repeating-linear-gradient(-45deg, var(--color-primary-dark) 0, var(--color-primary-dark) 1px, transparent 0, transparent 50%)',
backgroundSize: '28px 28px',
}}
/>
{/* Tilted rectangle — left center */}
<div
className="absolute rounded-lg"
style={{
width: '24%',
height: '34%',
top: '18%',
left: '6%',
border: '1.5px solid rgba(91,164,217,0.12)',
transform: 'rotate(-6deg)',
}}
/>
{/* Dot field — right center */}
<div
className="absolute"
style={{
width: '28%',
height: '28%',
top: '30%',
right: '8%',
backgroundImage:
'radial-gradient(circle, rgba(91,164,217,0.22) 1.5px, transparent 1.5px)',
backgroundSize: '10px 10px',
}}
/>
{/* Dashed arc */}
<svg
className="absolute"
style={{ top: '15%', left: '28%', opacity: 0.07 }}
width="160"
height="160"
viewBox="0 0 160 160"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="80"
cy="80"
r="68"
stroke="var(--color-primary-dark)"
strokeWidth="1"
strokeDasharray="7 5"
/>
</svg>
{/* Small accent square */}
<div
className="absolute rounded-sm"
style={{
width: '5%',
height: '5%',
bottom: '26%',
right: '26%',
background: 'rgba(91,164,217,0.18)',
transform: 'rotate(14deg)',
}}
/>
{/* Teal accent line — bottom */}
<div
className="absolute"
style={{
width: '30%',
height: '2px',
bottom: '14%',
left: '50%',
transform: 'translateX(-50%)',
background:
'linear-gradient(to right, transparent, rgba(46,196,160,0.35), transparent)',
}}
/>
</div>
);
}
function PillarCard({
Icon,
title,
description,
}: {
Icon: React.ElementType;
title: string;
description: string;
}) {
return (
<ScrollReveal variant="fadeUp">
<div className="bg-surface-high rounded-xl p-8 flex flex-col gap-5 h-full shadow-subtle">
{/* Icon */}
<div
className="w-11 h-11 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ background: 'rgba(91,164,217,0.10)' }}
>
<Icon
size={20}
strokeWidth={1.75}
className="text-primary"
aria-hidden="true"
/>
</div>
{/* Text */}
<div className="flex flex-col gap-2.5">
<h3 className="font-serif text-xl font-semibold text-on-surface leading-snug tracking-[-0.02em]">
{title}
</h3>
<p className="text-outline text-sm leading-relaxed">{description}</p>
</div>
</div>
</ScrollReveal>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
type Props = {
params: Promise<{ locale: string }>;
};
export default async function AboutPage({ params }: Props) {
const { locale } = await params;
setRequestLocale(locale);
return (
<main>
{/* ── 1. Hero ── */}
<section className="bg-surface py-32">
<div className="container mx-auto px-6">
<div className="max-w-4xl mx-auto flex flex-col items-center text-center gap-8">
<ScrollReveal variant="fadeIn">
<span className="label-md text-primary">About LetsBe.</span>
</ScrollReveal>
<ScrollReveal variant="fadeUp" delay={0.1}>
<h1 className="font-serif font-semibold text-on-surface text-5xl md:text-6xl lg:text-[4rem] leading-[1.05] tracking-[-0.03em]">
Digital Sovereignty
<br />
is not a luxury.
</h1>
</ScrollReveal>
<ScrollReveal variant="fadeUp" delay={0.2}>
<p className="text-outline text-xl leading-relaxed max-w-2xl">
We build digital platforms for businesses that refuse to compromise on ownership,
on quality, or on the partner they trust to build it with them.
</p>
</ScrollReveal>
</div>
</div>
</section>
{/* ── 2. Story ── */}
<section className="bg-surface-low 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: Text */}
<div className="lg:col-span-5 flex flex-col gap-10">
<ScrollReveal variant="slideLeft" className="flex flex-col gap-4">
<span className="label-md text-primary">Our Story</span>
<h2 className="font-serif text-4xl md:text-[2.75rem] font-semibold text-on-surface leading-[1.1] tracking-[-0.02em]">
Born on the<br />
Côte d&rsquo;Azur.
</h2>
</ScrollReveal>
<ScrollReveal variant="fadeUp" delay={0.1} className="flex flex-col gap-5 text-outline leading-relaxed text-[0.9375rem]">
<p>
LetsBe. was founded on the French Riviera by a small team of engineers and
designers who shared a single conviction: that ambitious businesses deserve digital
infrastructure as carefully considered as the work they do.
</p>
<p>
We started by building for founders and institutions along the coast port
authorities, conservation organisations, maritime operators each one operating
in a context where reliability, elegance, and discretion were not optional extras.
Those early projects shaped everything we believe about what a digital partner
should be.
</p>
<p>
Today we work with clients across Europe and the Mediterranean on platforms that
are built to be owned, not rented. We do not believe in locking clients into
systems they cannot see, services they cannot leave, or vendors whose priorities
will drift from theirs. We build on open infrastructure, we document everything,
and we hand over codebases that outlast the engagement.
</p>
</ScrollReveal>
</div>
{/* Right: Decorative canvas */}
<div className="lg:col-span-7 relative">
<ScrollReveal variant="slideRight">
<div
className="relative bg-surface rounded-xl overflow-hidden"
style={{ minHeight: '460px' }}
>
<StoryGeometry />
{/* Inset rim */}
<div
className="absolute inset-0 rounded-xl pointer-events-none"
style={{
boxShadow: 'inset 0 0 0 1px rgba(194,199,206,0.22)',
}}
aria-hidden="true"
/>
</div>
</ScrollReveal>
{/* Floating pull quote */}
<ScrollReveal
variant="fadeUp"
delay={0.4}
className="absolute -bottom-8 -left-4 lg:-left-10 z-10 max-w-[320px] w-[calc(100%-2.5rem)] lg:max-w-[340px]"
>
<div className="bg-surface-high rounded-xl p-6 shadow-subtle relative">
<div className="absolute top-4 right-4">
<CornerBracket
size={28}
position="top-right"
color="var(--color-primary)"
/>
</div>
<blockquote className="font-serif italic text-lg text-on-surface leading-relaxed pr-8">
&ldquo;Build fewer things. Build them better. Build them to last.&rdquo;
</blockquote>
<div
className="w-8 h-px bg-primary/40 my-4"
aria-hidden="true"
/>
<p className="label-md text-outline">LetsBe. founding principle</p>
</div>
</ScrollReveal>
</div>
</div>
</div>
</section>
{/* ── 3. Three Pillars ── */}
<section className="bg-surface py-24 mt-8">
<div className="container mx-auto px-6">
<ScrollReveal variant="fadeUp" className="flex flex-col items-center text-center gap-4 mb-16">
<span className="label-md text-primary">Our Beliefs</span>
<h2 className="font-serif font-semibold text-on-surface text-4xl md:text-5xl leading-[1.1] tracking-[-0.02em] max-w-2xl">
What We Believe
</h2>
<p className="text-outline text-lg leading-relaxed max-w-xl mt-1">
Three principles that inform every decision we make, every line of code we write, and
every client relationship we enter.
</p>
</ScrollReveal>
<ScrollReveal variant="fadeUp" stagger className="grid grid-cols-1 md:grid-cols-3 gap-6">
{PILLARS.map(({ id, Icon, title, description }) => (
<PillarCard key={id} Icon={Icon} title={title} description={description} />
))}
</ScrollReveal>
</div>
</section>
{/* ── 4. Quote ── */}
<section
className="py-20"
style={{ backgroundColor: 'var(--color-navy)' }}
>
<div className="container mx-auto px-6">
<ScrollReveal variant="fadeUp">
<div className="max-w-3xl mx-auto flex flex-col items-center text-center gap-6">
{/* Decorative line */}
<div
className="w-10 h-px"
style={{ background: 'rgba(91,164,217,0.5)' }}
aria-hidden="true"
/>
<blockquote className="font-serif italic text-white text-3xl md:text-[2.25rem] leading-[1.3] tracking-[-0.02em]">
&ldquo;Our mission is to bring the precision of architecture to the fluidity of the
web.&rdquo;
</blockquote>
<p className="label-md text-white/40">
&mdash; LetsBe. founding philosophy
</p>
</div>
</ScrollReveal>
</div>
</section>
{/* ── 5. CTA ── */}
<section className="bg-surface py-16">
<div className="container mx-auto px-6">
<ScrollReveal variant="fadeUp">
<div className="max-w-3xl mx-auto flex flex-col items-center text-center gap-8">
<div className="flex flex-col gap-3 items-center">
<span className="label-md text-primary">Work With Us</span>
<h2 className="font-serif font-semibold text-on-surface text-3xl md:text-4xl leading-[1.1] tracking-[-0.02em]">
Let&rsquo;s build something together.
</h2>
<p className="text-outline text-lg leading-relaxed max-w-lg">
Whether you have a clear brief or an early-stage idea, we would be glad to talk
through what is possible.
</p>
</div>
<div className="flex flex-col sm:flex-row items-center gap-4">
<Button variant="primary" size="lg" arrow href="/#configure">
Start your project
</Button>
<Button
variant="secondary"
size="lg"
href="mailto:hello@letsbe.biz"
>
Book a call
</Button>
</div>
</div>
</ScrollReveal>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,46 @@
import type { Metadata } from 'next'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages, setRequestLocale } from 'next-intl/server'
import { notFound } from 'next/navigation'
import { routing } from '@/i18n/routing'
import Nav from '@/components/layout/Nav'
import Footer from '@/components/layout/Footer'
import '@/styles/globals.css'
export const metadata: Metadata = {
title: 'LetsBe. | Bespoke Digital Studio & AI Infrastructure',
description:
'Bespoke digital ecosystems, private infrastructure, and AI automation for ambitious businesses on the Côte d\'Azur.',
}
type Props = {
children: React.ReactNode
params: Promise<{ locale: string }>
}
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }))
}
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params
if (!routing.locales.includes(locale as any)) {
notFound()
}
setRequestLocale(locale)
const messages = await getMessages()
return (
<html lang={locale} className="scroll-smooth">
<body className="font-sans text-on-surface bg-surface antialiased">
<NextIntlClientProvider messages={messages}>
<Nav />
{children}
<Footer />
</NextIntlClientProvider>
</body>
</html>
)
}

View File

@@ -0,0 +1,31 @@
import { setRequestLocale } from 'next-intl/server'
import Hero from '@/components/sections/Hero'
import TrustBar from '@/components/sections/TrustBar'
import ServicesOverview from '@/components/sections/ServicesOverview'
import Process from '@/components/sections/Process'
import SelectedWorks from '@/components/sections/SelectedWorks'
import Philosophy from '@/components/sections/Philosophy'
import Configurator from '@/components/sections/Configurator'
import CTABanner from '@/components/sections/CTABanner'
type Props = {
params: Promise<{ locale: string }>
}
export default async function HomePage({ params }: Props) {
const { locale } = await params
setRequestLocale(locale)
return (
<main>
<Hero />
<TrustBar />
<ServicesOverview />
<Configurator />
<Process />
<SelectedWorks />
<Philosophy />
<CTABanner />
</main>
)
}

View File

@@ -0,0 +1,174 @@
import { setRequestLocale } from 'next-intl/server';
import type { Metadata } from 'next';
import ServicesHero from '@/components/sections/services/ServicesHero';
import ServicePillar from '@/components/sections/services/ServicePillar';
import AILayer from '@/components/sections/services/AILayer';
import ServicesCTA from '@/components/sections/services/ServicesCTA';
// Icon names passed as strings — resolved in client component
// ─── Metadata ─────────────────────────────────────────────────────────────────
export const metadata: Metadata = {
title: 'Services | LetsBe. — Bespoke Digital Studio',
description:
'From bespoke web design to private infrastructure and AI automation — three pillars of digital excellence, engineered for ambitious businesses.',
};
// ─── Service data ──────────────────────────────────────────────────────────────
export const SERVICE_PILLARS = [
{
id: 'design-development',
numeral: '01',
title: 'Web Design & Development',
description:
'Your digital presence is not a template waiting to be filled — it is an engineered expression of your brand. We design and build bespoke websites and web applications from a blank canvas, crafting every interaction, every transition, and every responsive breakpoint with intention. Our work is fast by architecture, not by accident: semantic markup, optimised asset pipelines, and edge-deployed rendering that scores at the top of the Core Web Vitals chart. Whether you need a high-conversion marketing site, a complex SaaS application, or a multi-region e-commerce platform, we deliver a digital product that is built to last and built to grow.',
background: 'bg-surface' as const,
features: [
{
icon: 'Palette',
title: 'Bespoke UI/UX Design',
description:
'Custom Figma-to-code workflows. Every layout, component, and motion decision serves your brand — never a theme, never a shortcut.',
},
{
icon: 'Globe',
title: 'Modern Web Applications',
description:
'React, Next.js, and edge-deployed architecture for SPAs, SSR, and full-stack applications that scale without compromise.',
},
{
icon: 'ShoppingCart',
title: 'E-commerce & Platforms',
description:
'Headless storefronts, custom checkout flows, and multi-currency platforms engineered for high-volume retail.',
},
{
icon: 'Zap',
title: 'Performance Optimisation',
description:
'LCP under one second, zero layout shift. We audit, refactor, and rebuild performance into the foundation of your product.',
},
],
},
{
id: 'custom-systems',
numeral: '02',
title: 'Custom Systems',
description:
'Off-the-shelf software makes assumptions about your business. We don\'t. When your operations outgrow spreadsheets and generic SaaS platforms, we build the exact system your team needs — with the data model, the permission structure, and the workflow logic that reflects how you actually work. From CRM platforms tailored to your sales cycle, to internal management tools that replace three different subscriptions, to API architecture that connects your existing stack into a coherent whole — every system we build is owned by you, documented in full, and maintained without vendor dependency.',
background: 'bg-surface-low' as const,
features: [
{
icon: 'Database',
title: 'CRM & Management',
description:
'Purpose-built relationship and pipeline management with the fields, views, and automation rules your team will actually use.',
},
{
icon: 'Code2',
title: 'Bespoke Software',
description:
'Full-stack business applications: from quoting and booking platforms to complex multi-tenant SaaS products.',
},
{
icon: 'GitBranch',
title: 'API Architecture',
description:
'RESTful and GraphQL APIs, webhook integrations, and the middleware layer that makes your disparate tools speak the same language.',
},
{
icon: 'Wrench',
title: 'Internal Tooling',
description:
'Admin dashboards, reporting portals, and workflow automation that give your team unfair operational advantages.',
},
],
},
{
id: 'infrastructure',
numeral: '03',
title: 'Digital Infrastructure',
description:
'Data sovereignty is not a feature — it is a right. We provision and manage private cloud infrastructure that removes your reliance on opaque hyperscalers and shared-hosting providers. Your data lives where you say it lives, on servers you control, with access policies your legal team can actually review. We handle the full infrastructure lifecycle: dedicated server provisioning, containerised deployments with Docker and Kubernetes, automated backup strategies, TLS certificate management, and 24/7 uptime monitoring. When the European data-residency audit arrives, you will be the only business in the room with a clean answer.',
background: 'bg-surface' as const,
features: [
{
icon: 'Server',
title: 'Dedicated Hosting',
description:
'Private VPS and bare-metal environments in EU data centres — no shared neighbours, no noisy-neighbour risk.',
},
{
icon: 'Shield',
title: 'Data Sovereignty',
description:
'GDPR-aligned architecture with data-residency guarantees, audit trails, and full client ownership of storage and credentials.',
},
{
icon: 'Lock',
title: 'Security Hardening',
description:
'WAF configuration, DDoS mitigation, secrets management, and penetration-testing-ready hardened deployment pipelines.',
},
{
icon: 'Settings',
title: 'DevOps & Maintenance',
description:
'CI/CD pipelines, container orchestration, rolling deployments, and proactive monitoring so your team ships without fear.',
},
],
},
] as const;
// ─── AI Layer data ─────────────────────────────────────────────────────────────
export const AI_CAPABILITIES = [
{
id: 'ai-teammate',
title: 'AI Teammate',
description:
'An operational assistant embedded directly into your workflow. Retrieves and summarises emails, drafts responses, executes tasks across connected tools, and surfaces the information your team needs before they know they need it.',
},
{
id: 'customer-facing-ai',
title: 'Customer-Facing AI',
description:
'Intelligent conversational interfaces that handle enquiries, qualify leads, make product recommendations, and personalise the user journey — at scale, around the clock, without extra headcount.',
},
{
id: 'data-intelligence',
title: 'Data Intelligence',
description:
'Automated analytics pipelines, predictive modelling, and scheduled reporting that transform the data your systems collect into decisions your leadership team can act on.',
},
] as const;
// ─── Page ──────────────────────────────────────────────────────────────────────
type Props = {
params: Promise<{ locale: string }>;
};
export default async function ServicesPage({ params }: Props) {
const { locale } = await params;
setRequestLocale(locale);
return (
<main>
<ServicesHero />
{SERVICE_PILLARS.map((pillar, index) => (
<ServicePillar
key={pillar.id}
pillar={pillar}
index={index}
/>
))}
<AILayer capabilities={AI_CAPABILITIES} />
<ServicesCTA />
</main>
);
}

View File

@@ -0,0 +1,293 @@
import { notFound } from 'next/navigation';
import { setRequestLocale } from 'next-intl/server';
import { routing } from '@/i18n/routing';
import ScrollReveal from '@/components/ui/ScrollReveal';
import Button from '@/components/ui/Button';
import CornerBracket from '@/components/icons/CornerBracket';
import Chip from '@/components/ui/Chip';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Project {
title: string;
subtitle: string;
description: string;
challenge: string;
approach: string;
outcome: string;
techStack: string[];
tags: string[];
}
// ─── Data (will come from Payload CMS) ────────────────────────────────────────
const PROJECTS: Record<string, Project> = {
'monaco-ocean': {
title: 'Monaco Ocean Protection Challenge',
subtitle: 'AI-Powered Judging & Analytics Platform',
description:
"A comprehensive judging and analytics system with advanced AI jury integration for one of the Mediterranean's most prestigious conservation events.",
challenge:
'The Monaco Ocean Protection Challenge needed a modern platform to manage submissions, coordinate judges across time zones, and provide AI-assisted evaluation of conservation proposals — all while maintaining the prestige and security expected of a Monaco institution.',
approach:
'We built a custom platform from the ground up using Next.js and a private PostgreSQL infrastructure. The AI jury module uses natural language processing to pre-screen submissions and generate summary reports, while human judges retain full control over final decisions.',
outcome:
"The platform processed over 200 submissions in its first season, reducing judge workload by 40% through AI-assisted pre-screening. The client praised the system's reliability and the elegance of its interface.",
techStack: ['Next.js', 'PostgreSQL', 'OpenAI API', 'Docker', 'Private Cloud'],
tags: ['AI Integration', 'Platform'],
},
'port-nimara': {
title: 'Port Nimara',
subtitle: 'Maritime Digital Hub',
description: 'Scalable digital hub for maritime logistics.',
challenge:
'Port Nimara needed a modern digital presence that could serve as both a marketing website and an operational hub for berth inquiries, event management, and partner communications.',
approach:
'We designed and developed a performant Nuxt.js application with a headless CMS for content management, integrated with their existing maritime scheduling systems via custom API middleware.',
outcome:
'The new platform increased online berth inquiries by 3x and provided the port authority with real-time content management capabilities they previously lacked.',
techStack: ['Nuxt.js', 'Directus CMS', 'Node.js', 'Docker'],
tags: ['Website', 'Infrastructure'],
},
'port-amador': {
title: 'Port Amador',
subtitle: 'Premium Nautical Experience',
description: 'Premium digital experience for elite nautical services.',
challenge:
'Port Amador required a luxury-grade digital experience that matched the exclusivity of their nautical services, with multi-language support and seamless booking integration.',
approach:
'We crafted a bespoke website with cinematic imagery, smooth animations, and an integrated booking flow. The site was built on modern web technologies with a focus on performance and SEO for the competitive luxury maritime market.',
outcome:
"The redesigned platform elevated Port Amador's digital presence to match their premium positioning, with a 60% improvement in page load times and significantly increased organic traffic.",
techStack: ['Next.js', 'Tailwind CSS', 'Framer Motion', 'Vercel'],
tags: ['Website', 'Infrastructure'],
},
};
// ─── Static Generation ────────────────────────────────────────────────────────
export function generateStaticParams() {
const slugs = Object.keys(PROJECTS);
const allParams: { locale: string; slug: string }[] = [];
for (const locale of routing.locales) {
for (const slug of slugs) {
allParams.push({ locale, slug });
}
}
return allParams;
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function ContentSection({
label,
index,
heading,
body,
}: {
label: string;
index: string;
heading: string;
body: string;
}) {
return (
<ScrollReveal variant="fadeUp" className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<span className="label-md text-outline/50">{index}</span>
<span className="label-md text-primary">{label}</span>
</div>
<h3 className="font-serif text-2xl md:text-3xl font-semibold text-on-surface leading-snug tracking-[-0.02em]">
{heading}
</h3>
<p className="text-outline leading-relaxed text-base md:text-[1.0625rem]">{body}</p>
</ScrollReveal>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
type Props = {
params: Promise<{ locale: string; slug: string }>;
};
export default async function CaseStudyPage({ params }: Props) {
const { locale, slug } = await params;
setRequestLocale(locale);
const project = PROJECTS[slug];
if (!project) notFound();
return (
<main>
{/* ── Hero ── */}
<section className="bg-surface-low py-24">
<div className="container mx-auto px-6">
<div className="max-w-3xl mx-auto flex flex-col items-center text-center gap-6">
{/* Tags */}
<ScrollReveal variant="fadeIn">
<div className="flex flex-wrap items-center justify-center gap-2">
{project.tags.map((tag) => (
<Chip key={tag} size="md">
{tag}
</Chip>
))}
</div>
</ScrollReveal>
{/* Title */}
<ScrollReveal variant="fadeUp" delay={0.08}>
<h1 className="font-serif font-semibold text-on-surface text-4xl md:text-5xl lg:text-[3.25rem] leading-[1.1] tracking-[-0.02em]">
{project.title}
</h1>
</ScrollReveal>
{/* Subtitle */}
<ScrollReveal variant="fadeUp" delay={0.16}>
<p className="label-md text-primary tracking-widest">
{project.subtitle}
</p>
</ScrollReveal>
{/* Description */}
<ScrollReveal variant="fadeUp" delay={0.22}>
<p className="text-outline text-lg leading-relaxed max-w-2xl">
{project.description}
</p>
</ScrollReveal>
</div>
</div>
</section>
{/* ── Case Study Content ── */}
<section className="bg-surface py-20 md:py-28">
<div className="container mx-auto px-6">
<div className="max-w-3xl mx-auto">
{/* Decorative corner brackets framing the content area */}
<ScrollReveal variant="fadeIn" className="relative">
<div className="absolute -top-6 -left-6 md:-left-10" aria-hidden="true">
<CornerBracket size={36} position="top-left" color="var(--color-primary)" />
</div>
<div className="absolute -top-6 -right-6 md:-right-10" aria-hidden="true">
<CornerBracket size={36} position="top-right" color="var(--color-primary)" />
</div>
</ScrollReveal>
{/* Sections */}
<div className="flex flex-col gap-16 md:gap-20">
<ContentSection
label="The Challenge"
index="01"
heading="The problem we set out to solve"
body={project.challenge}
/>
{/* Divider */}
<ScrollReveal variant="fadeIn">
<div
className="w-full h-px"
style={{
background:
'linear-gradient(to right, transparent, var(--color-outline-variant), transparent)',
}}
aria-hidden="true"
/>
</ScrollReveal>
<ContentSection
label="Our Approach"
index="02"
heading="How we thought about it"
body={project.approach}
/>
{/* Divider */}
<ScrollReveal variant="fadeIn">
<div
className="w-full h-px"
style={{
background:
'linear-gradient(to right, transparent, var(--color-outline-variant), transparent)',
}}
aria-hidden="true"
/>
</ScrollReveal>
<ContentSection
label="The Outcome"
index="03"
heading="What we delivered"
body={project.outcome}
/>
</div>
</div>
</div>
</section>
{/* ── Tech Stack ── */}
<section className="bg-surface-low py-12">
<div className="container mx-auto px-6">
<div className="max-w-3xl mx-auto">
<ScrollReveal variant="fadeUp" className="flex flex-col gap-5">
<p className="label-md text-outline">Built with</p>
<div className="flex flex-wrap gap-2">
{project.techStack.map((tech) => (
<span
key={tech}
className="inline-flex items-center px-4 py-2 rounded-xl bg-surface-high text-on-surface text-sm font-medium shadow-card"
>
{tech}
</span>
))}
</div>
</ScrollReveal>
</div>
</div>
</section>
{/* ── CTA ── */}
<section className="bg-surface py-20 md:py-24">
<div className="container mx-auto px-6">
<ScrollReveal variant="fadeUp">
<div className="max-w-3xl mx-auto flex flex-col items-center text-center gap-8">
{/* Decorative corner bracket */}
<div className="relative">
<div className="absolute -top-3 -left-3" aria-hidden="true">
<CornerBracket size={24} position="top-left" color="var(--color-teal)" />
</div>
<div className="absolute -top-3 -right-3" aria-hidden="true">
<CornerBracket size={24} position="top-right" color="var(--color-teal)" />
</div>
<p className="label-md text-primary px-4">Next Step</p>
</div>
<h2 className="font-serif font-semibold text-on-surface text-3xl md:text-4xl leading-[1.1] tracking-[-0.02em]">
Ready to build your own landmark?
</h2>
<p className="text-outline text-lg leading-relaxed max-w-xl">
Every project we take on is a collaboration built on trust, precision, and a shared
belief that great digital work is never accidental.
</p>
<Button variant="primary" size="lg" arrow href="/#configure">
Start your project
</Button>
</div>
</ScrollReveal>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,216 @@
import { NextRequest, NextResponse } from 'next/server';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ConfigureRequestBody {
services: string[];
aiEnabled: boolean;
aiType: string | null;
industry: string | null;
scope: string;
timeline: string | null;
name: string;
company: string;
email: string;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatServicesList(services: string[]): string {
if (services.length === 0) return 'digital services';
if (services.length === 1) return services[0];
if (services.length === 2) return `${services[0]} and ${services[1]}`;
const last = services[services.length - 1];
const rest = services.slice(0, -1);
return `${rest.join(', ')}, and ${last}`;
}
function formatTimeline(timeline: string | null): string {
switch (timeline) {
case 'asap':
return 'as soon as possible';
case '1-3months':
return 'within the next 13 months';
case '3-6months':
return 'over a 36 month horizon';
case 'exploring':
return 'at a pace that suits your strategic planning';
default:
return 'within a timeline to be agreed upon';
}
}
function formatIndustry(industry: string | null): string {
switch (industry) {
case 'maritime':
return 'Maritime & Yachting';
case 'hospitality':
return 'Hospitality';
case 'technology':
return 'Technology';
case 'realestate':
return 'Real Estate';
case 'finance':
return 'Finance';
case 'ngo':
return 'NGO & Nonprofit';
case 'other':
return 'your sector';
default:
return 'your industry';
}
}
function formatAIType(aiType: string | null): string {
switch (aiType) {
case 'teammate':
return 'an internal AI teammate that augments your team\'s workflow';
case 'customer-facing':
return 'a customer-facing AI layer to enhance client interactions';
case 'data-intelligence':
return 'a data intelligence system that surfaces actionable insights from your data';
case 'notsure':
return 'an AI integration strategy tailored to your specific use case (to be defined during discovery)';
default:
return 'intelligent automation';
}
}
function generateMockBrief(body: ConfigureRequestBody): string {
const {
services,
aiEnabled,
aiType,
industry,
scope,
timeline,
name,
company,
} = body;
const servicesList = formatServicesList(services);
const industryLabel = formatIndustry(industry);
const timelineStr = formatTimeline(timeline);
const displayCompany = company.trim() || 'your organisation';
const displayName = name.split(' ')[0] || 'there';
const hasWeb = services.some((s) =>
s.toLowerCase().includes('web') || s.toLowerCase().includes('design'),
);
const hasSystems = services.some((s) =>
s.toLowerCase().includes('system') || s.toLowerCase().includes('cog') || s.toLowerCase().includes('custom'),
);
const hasInfra = services.some((s) =>
s.toLowerCase().includes('infra') || s.toLowerCase().includes('server'),
);
let webSection = '';
if (hasWeb) {
webSection = `
**Design & Development**
We will design and develop a bespoke digital presence for ${displayCompany} — starting from a clean slate, not a template. Expect a modern, responsive interface built on a headless architecture with exceptional performance scores (Lighthouse 95+). The design will reflect your brand positioning within the ${industryLabel} sector, with full accessibility compliance and SEO-optimised markup from day one.
`;
}
let systemsSection = '';
if (hasSystems) {
systemsSection = `
**Custom Systems**
We will architect and build a purpose-made internal system tailored to ${displayCompany}'s operational workflows. This includes a custom data model, role-based access control, and integrations with your existing toolchain. No generic SaaS — every logic rule and every interface is written to match how your team actually works.
`;
}
let infraSection = '';
if (hasInfra) {
infraSection = `
**Digital Infrastructure**
We will provision a dedicated, private cloud environment for ${displayCompany} — giving your team full data sovereignty. This includes containerised deployments, automated backups, uptime monitoring, and a managed CI/CD pipeline. Your data remains yours, stored in compliant European infrastructure.
`;
}
let aiSection = '';
if (aiEnabled && aiType) {
aiSection = `
**AI Integration**
Beyond the core build, we will layer in ${formatAIType(aiType)}. This is not a bolted-on chatbot — it is a deeply integrated capability that evolves alongside your digital ecosystem. The AI layer will be scoped precisely during our discovery sessions to ensure maximum return on investment.
`;
}
let scopeSection = '';
if (scope && scope.trim().length > 0) {
scopeSection = `
**Your Goals**
You've shared the following context: "${scope.trim()}" — we've taken note of this and will frame our initial discovery session around these priorities.
`;
}
return `**Project Brief for ${displayCompany}**
Prepared for: ${name}
Date: ${new Date().toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}
---
**Overview**
Hi ${displayName}, based on your requirements for ${servicesList} within the ${industryLabel} sector, we have prepared this preliminary brief to guide our first conversation.
LetsBe. will approach ${displayCompany}'s project as a complete digital ecosystem — not a collection of disconnected deliverables. Every component we build is designed to work in concert, giving you a unified platform that you own and control entirely.
${webSection}${systemsSection}${infraSection}${aiSection}${scopeSection}
**Recommended Approach**
We propose a phased engagement beginning with a structured Discovery sprint (23 sessions) to map your requirements, data flows, and technical constraints before any code is written. This protects your investment and ensures we build exactly what you need — nothing more, nothing less.
**Timeline**
Based on your preference, we will plan to deliver this project ${timelineStr}. A detailed project roadmap with milestones will be shared following the Discovery phase.
**Next Steps**
1. Book a 30-minute introductory call with our team
2. We'll share a detailed scope document within 48 hours of that call
3. Discovery sprint begins — at no obligation
We look forward to building something exceptional together.
— The LetsBe. Team`;
}
// ─── Route Handler ────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as ConfigureRequestBody;
// Validate required fields
if (!body.services || body.services.length === 0) {
return NextResponse.json(
{ success: false, error: 'At least one service must be selected.' },
{ status: 400 },
);
}
if (!body.name || body.name.trim().length < 2) {
return NextResponse.json(
{ success: false, error: 'A valid name is required.' },
{ status: 400 },
);
}
if (!body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
return NextResponse.json(
{ success: false, error: 'A valid email address is required.' },
{ status: 400 },
);
}
// Generate the brief
const brief = generateMockBrief(body);
return NextResponse.json({ success: true, brief });
} catch {
return NextResponse.json(
{ success: false, error: 'An unexpected error occurred. Please try again.' },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,23 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap })
export default Page

View File

@@ -0,0 +1,2 @@
// This file will be auto-generated by Payload CMS
export const importMap = {}

View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next'
import '@payloadcms/next/css'
export const metadata: Metadata = {
title: 'LetsBe. Admin',
}
export default function PayloadLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

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

4
src/i18n/config.ts Normal file
View File

@@ -0,0 +1,4 @@
export const locales = ['en', 'fr'] as const
export const defaultLocale = 'en' as const
export type Locale = (typeof locales)[number]

146
src/i18n/messages/en.json Normal file
View File

@@ -0,0 +1,146 @@
{
"nav": {
"services": "Services",
"configure": "Configure",
"process": "Process",
"work": "Work",
"about": "About",
"startProject": "Start a Project",
"bookCall": "Book a Call"
},
"hero": {
"eyebrow": "Bespoke Digital Studio",
"title": "Your website, your infrastructure, your AI — built {exclusively} for you.",
"subtitle": "We design and develop complete digital ecosystems for ambitious businesses on the Côte d'Azur and beyond. No templates, no compromises.",
"cta": "Configure Your Project",
"ctaSecondary": "See Our Work",
"trust": "Trusted by businesses across the Riviera"
},
"trustBar": {
"customBuilt": {
"title": "Custom-Built",
"description": "Tailored architecture from the first line of code."
},
"privateInfra": {
"title": "Private Infrastructure",
"description": "Secure, dedicated cloud environments for your data."
},
"aiPowered": {
"title": "AI-Powered",
"description": "Intelligent automation that scales your productivity."
},
"rivieraBased": {
"title": "Riviera-Based",
"description": "Local expertise for global ambitions."
}
},
"services": {
"eyebrow": "Our Capabilities",
"title": "Three Pillars of Digital Excellence",
"web": {
"title": "Design & Development",
"features": ["Bespoke UI/UX Design", "Modern Web Applications", "E-commerce & Platforms", "Performance Optimization"]
},
"systems": {
"title": "Custom Systems",
"features": ["CRM & Management Platforms", "Bespoke Business Software", "API & Integration Architecture", "Internal Tooling"]
},
"infrastructure": {
"title": "Digital Infrastructure",
"features": ["Dedicated Cloud Hosting", "Data Sovereignty Solutions", "Security Hardening", "DevOps & Maintenance"]
},
"aiNarrative": "We build your digital ecosystem — then make it intelligent."
},
"configurator": {
"eyebrow": "Interactive Studio",
"title": "Let's define your project scope.",
"description": "Configure your requirements and we'll generate a personalized project brief.",
"step1": {
"title": "What are we building together?",
"subtitle": "Select the services that match your vision."
},
"step2": {
"title": "Tell us about your project",
"subtitle": "This helps us prepare the right approach before our first call."
},
"step3": {
"title": "Almost there",
"subtitle": "We'll generate a personalized project brief and send it to your inbox."
},
"complete": {
"title": "Your project brief is ready",
"subtitle": "Check your inbox — we've sent a detailed brief to {email}",
"bookTitle": "Book a Consultation",
"bookSubtitle": "30 minutes to discuss your brief with our team"
},
"aiToggle": "Enhance with AI",
"aiDescription": "We layer intelligent automation into every system we build.",
"generateBrief": "Generate My Brief",
"nextStep": "Next Step",
"back": "Back"
},
"process": {
"eyebrow": "Our Method",
"title": "Architecture of a Project",
"steps": {
"discovery": {
"title": "Discovery",
"description": "Understanding your business, users, and technical landscape."
},
"strategy": {
"title": "Strategy & Architecture",
"description": "Defining the blueprint — from data model to deployment plan."
},
"build": {
"title": "Design & Build",
"description": "Crafting every pixel and every line of code."
},
"launch": {
"title": "Launch & Evolve",
"description": "Deployment, monitoring, and continuous improvement."
}
}
},
"work": {
"eyebrow": "Selected Works",
"title": "Digital Landmarks",
"readCaseStudy": "Read Case Study",
"comingSoon": "Coming Soon"
},
"philosophy": {
"eyebrow": "The LetsBe. Way",
"title": "Digital Sovereignty is not a luxury.",
"subtitle": "We believe that every modern business is, at its core, a software company. We help you own your tools, your data, and your future.",
"ownership": {
"title": "Ownership & Privacy",
"description": "We move you away from standard SaaS dependencies into private cloud environments where you own 100% of your data."
},
"craftsmanship": {
"title": "Craftsmanship",
"description": "We don't use page builders or bloated themes. We write clean, semantic code that is lightning-fast and search-engine optimized."
},
"oneTeam": {
"title": "One Team, Everything",
"description": "From the initial Figma frame to the Kubernetes deployment, we manage the entire lifecycle under one roof."
},
"quote": "Our mission is to bring the precision of architecture to the fluidity of the web."
},
"cta": {
"eyebrow": "Let's Talk",
"title": "Ready to build something exceptional?",
"subtitle": "From the first consultation to launch day, we're with you every step of the way.",
"cta": "Configure Your Project",
"configure": "Configure Your Project",
"email": "hello@letsbe.biz",
"reassurance": "No commitment required — just a conversation about what's possible."
},
"footer": {
"tagline": "Designing and engineering the digital backbone of tomorrow's leaders.",
"location": "Côte d'Azur, France",
"services": "Services",
"studio": "Studio",
"connect": "Connect",
"privacy": "Privacy Policy",
"terms": "Terms of Service"
}
}

146
src/i18n/messages/fr.json Normal file
View File

@@ -0,0 +1,146 @@
{
"nav": {
"services": "Services",
"configure": "Configurer",
"process": "Méthode",
"work": "Réalisations",
"about": "À propos",
"startProject": "Démarrer un Projet",
"bookCall": "Réserver un Appel"
},
"hero": {
"eyebrow": "Studio Digital Sur Mesure",
"title": "Votre site, votre infrastructure, votre IA — conçus {exclusively} pour vous.",
"subtitle": "Nous concevons et développons des écosystèmes digitaux complets pour les entreprises ambitieuses sur la Côte d'Azur et au-delà. Pas de templates, pas de compromis.",
"cta": "Configurez Votre Projet",
"ctaSecondary": "Voir Nos Réalisations",
"trust": "La confiance des entreprises de la Riviera"
},
"trustBar": {
"customBuilt": {
"title": "Sur Mesure",
"description": "Architecture personnalisée dès la première ligne de code."
},
"privateInfra": {
"title": "Infrastructure Privée",
"description": "Environnements cloud dédiés et sécurisés pour vos données."
},
"aiPowered": {
"title": "Propulsé par l'IA",
"description": "Automatisation intelligente qui démultiplie votre productivité."
},
"rivieraBased": {
"title": "Basé sur la Riviera",
"description": "Expertise locale pour des ambitions globales."
}
},
"services": {
"eyebrow": "Nos Compétences",
"title": "Trois Piliers d'Excellence Digitale",
"web": {
"title": "Design & Développement",
"features": ["Design UI/UX Sur Mesure", "Applications Web Modernes", "E-commerce & Plateformes", "Optimisation des Performances"]
},
"systems": {
"title": "Systèmes Personnalisés",
"features": ["CRM & Plateformes de Gestion", "Logiciels Métier Sur Mesure", "Architecture API & Intégration", "Outillage Interne"]
},
"infrastructure": {
"title": "Infrastructure Digitale",
"features": ["Hébergement Cloud Dédié", "Solutions de Souveraineté des Données", "Renforcement de la Sécurité", "DevOps & Maintenance"]
},
"aiNarrative": "Nous construisons votre écosystème digital — puis nous le rendons intelligent."
},
"configurator": {
"eyebrow": "Studio Interactif",
"title": "Définissons le périmètre de votre projet.",
"description": "Configurez vos besoins et nous générerons un brief personnalisé.",
"step1": {
"title": "Que construisons-nous ensemble ?",
"subtitle": "Sélectionnez les services qui correspondent à votre vision."
},
"step2": {
"title": "Parlez-nous de votre projet",
"subtitle": "Cela nous aide à préparer la bonne approche avant notre premier échange."
},
"step3": {
"title": "Presque terminé",
"subtitle": "Nous générerons un brief personnalisé et l'enverrons dans votre boîte mail."
},
"complete": {
"title": "Votre brief est prêt",
"subtitle": "Vérifiez votre boîte mail — nous avons envoyé un brief détaillé à {email}",
"bookTitle": "Réservez une Consultation",
"bookSubtitle": "30 minutes pour discuter de votre brief avec notre équipe"
},
"aiToggle": "Enrichir avec l'IA",
"aiDescription": "Nous intégrons l'automatisation intelligente dans chaque système que nous construisons.",
"generateBrief": "Générer Mon Brief",
"nextStep": "Étape Suivante",
"back": "Retour"
},
"process": {
"eyebrow": "Notre Méthode",
"title": "Architecture d'un Projet",
"steps": {
"discovery": {
"title": "Découverte",
"description": "Comprendre votre entreprise, vos utilisateurs et votre paysage technique."
},
"strategy": {
"title": "Stratégie & Architecture",
"description": "Définir le plan — du modèle de données au plan de déploiement."
},
"build": {
"title": "Design & Construction",
"description": "Façonner chaque pixel et chaque ligne de code."
},
"launch": {
"title": "Lancement & Évolution",
"description": "Déploiement, monitoring et amélioration continue."
}
}
},
"work": {
"eyebrow": "Réalisations Sélectionnées",
"title": "Réalisations Digitales",
"readCaseStudy": "Lire l'Étude de Cas",
"comingSoon": "Bientôt Disponible"
},
"philosophy": {
"eyebrow": "La Philosophie LetsBe.",
"title": "La souveraineté digitale n'est pas un luxe.",
"subtitle": "Nous croyons que chaque entreprise moderne est, en son cœur, une entreprise logicielle. Nous vous aidons à posséder vos outils, vos données et votre avenir.",
"ownership": {
"title": "Propriété & Confidentialité",
"description": "Nous vous éloignons des dépendances SaaS standard vers des environnements cloud privés où vous possédez 100% de vos données."
},
"craftsmanship": {
"title": "Artisanat",
"description": "Nous n'utilisons pas de constructeurs de pages ou de thèmes surchargés. Nous écrivons du code propre et sémantique, ultra-rapide et optimisé pour le référencement."
},
"oneTeam": {
"title": "Une Équipe, Tout",
"description": "Du premier frame Figma au déploiement Kubernetes, nous gérons l'intégralité du cycle de vie sous un même toit."
},
"quote": "Notre mission est d'apporter la précision de l'architecture à la fluidité du web."
},
"cta": {
"eyebrow": "Parlons-en",
"title": "Prêt à construire quelque chose d'exceptionnel ?",
"subtitle": "De la première consultation au jour du lancement, nous vous accompagnons à chaque étape.",
"cta": "Configurez Votre Projet",
"configure": "Configurez Votre Projet",
"email": "hello@letsbe.biz",
"reassurance": "Sans engagement — juste une conversation sur ce qui est possible."
},
"footer": {
"tagline": "Concevoir et construire la colonne vertébrale digitale des leaders de demain.",
"location": "Côte d'Azur, France",
"services": "Services",
"studio": "Studio",
"connect": "Contact",
"privacy": "Politique de Confidentialité",
"terms": "Conditions d'Utilisation"
}
}

5
src/i18n/navigation.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing)

15
src/i18n/request.ts Normal file
View File

@@ -0,0 +1,15 @@
import { getRequestConfig } from 'next-intl/server'
import { routing } from './routing'
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale
}
return {
locale,
messages: (await import(`./messages/${locale}.json`)).default,
}
})

8
src/i18n/routing.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineRouting } from 'next-intl/routing'
import { locales, defaultLocale } from './config'
export const routing = defineRouting({
locales,
defaultLocale,
localePrefix: 'as-needed',
})

115
src/lib/animations.ts Normal file
View File

@@ -0,0 +1,115 @@
import type { Variants, Transition } from 'framer-motion'
// Shared easing
const ease = [0.16, 1, 0.3, 1] as const
// Scroll-triggered reveal: fade in + slide up
export const revealVariants: Variants = {
hidden: {
opacity: 0,
y: 40,
},
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
ease,
},
},
}
// Stagger container for child elements
export const staggerContainer: Variants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.08,
},
},
}
// Stagger with wider gap (for larger elements)
export const staggerContainerWide: Variants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.1,
},
},
}
// Fade in only (no movement)
export const fadeVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { duration: 0.5, ease },
},
}
// Scale + fade (for CTAs, buttons)
export const scaleVariants: Variants = {
hidden: {
opacity: 0,
scale: 0.95,
},
visible: {
opacity: 1,
scale: 1,
transition: { duration: 0.3, ease },
},
}
// Slide from left
export const slideLeftVariants: Variants = {
hidden: { opacity: 0, x: -40 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.6, ease },
},
}
// Slide from right
export const slideRightVariants: Variants = {
hidden: { opacity: 0, x: 40 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.6, ease },
},
}
// Page transition
export const pageTransition: Transition = {
duration: 0.4,
ease,
}
export const pageVariants: Variants = {
initial: { opacity: 0, y: 20 },
animate: {
opacity: 1,
y: 0,
transition: pageTransition,
},
exit: {
opacity: 0,
y: -20,
transition: { duration: 0.3, ease },
},
}
// Spring for interactive elements (select, toggle)
export const springTransition: Transition = {
type: 'spring',
stiffness: 400,
damping: 25,
}
// Viewport settings for scroll-triggered animations
export const viewportOnce = {
once: true,
amount: 0.15 as const,
}

5
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,5 @@
import { type ClassValue, clsx } from 'clsx'
export function cn(...inputs: ClassValue[]) {
return clsx(inputs)
}

8
src/middleware.ts Normal file
View File

@@ -0,0 +1,8 @@
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'
export default createMiddleware(routing)
export const config = {
matcher: ['/', '/(fr|en)/:path*', '/((?!api|_next|admin|media|fonts|images|favicon.ico).*)'],
}

View File

@@ -0,0 +1,20 @@
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
access: {
read: () => true,
},
upload: {
staticDir: 'public/media',
mimeTypes: ['image/*', 'application/pdf'],
},
fields: [
{
name: 'alt',
type: 'text',
localized: true,
required: true,
},
],
}

View File

@@ -0,0 +1,80 @@
import type { CollectionConfig } from 'payload'
export const Projects: CollectionConfig = {
slug: 'projects',
admin: {
useAsTitle: 'title',
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true,
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
admin: {
position: 'sidebar',
},
},
{
name: 'description',
type: 'textarea',
required: true,
localized: true,
},
{
name: 'tags',
type: 'array',
fields: [
{
name: 'tag',
type: 'text',
},
],
},
{
name: 'featured',
type: 'checkbox',
defaultValue: false,
admin: {
position: 'sidebar',
},
},
{
name: 'comingSoon',
type: 'checkbox',
defaultValue: false,
admin: {
position: 'sidebar',
},
},
{
name: 'thumbnail',
type: 'upload',
relationTo: 'media',
},
{
name: 'content',
type: 'richText',
localized: true,
},
{
name: 'techStack',
type: 'array',
fields: [
{
name: 'technology',
type: 'text',
},
],
},
],
}

View File

@@ -0,0 +1,49 @@
import type { CollectionConfig } from 'payload'
export const Services: CollectionConfig = {
slug: 'services',
admin: {
useAsTitle: 'title',
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true,
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
},
{
name: 'description',
type: 'textarea',
required: true,
localized: true,
},
{
name: 'features',
type: 'array',
localized: true,
fields: [
{
name: 'feature',
type: 'text',
},
],
},
{
name: 'order',
type: 'number',
admin: {
position: 'sidebar',
},
},
],
}

View File

@@ -0,0 +1,87 @@
import type { CollectionConfig } from 'payload'
export const Submissions: CollectionConfig = {
slug: 'submissions',
admin: {
useAsTitle: 'name',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'company',
type: 'text',
},
{
name: 'email',
type: 'email',
required: true,
},
{
name: 'services',
type: 'select',
hasMany: true,
options: [
{ label: 'Web Design & Development', value: 'web' },
{ label: 'Custom Systems', value: 'systems' },
{ label: 'Digital Infrastructure', value: 'infrastructure' },
],
},
{
name: 'aiEnhancement',
type: 'checkbox',
defaultValue: false,
},
{
name: 'aiType',
type: 'select',
options: [
{ label: 'AI Teammate', value: 'teammate' },
{ label: 'Customer-Facing AI', value: 'customer-facing' },
{ label: 'Data Intelligence', value: 'data-intelligence' },
{ label: 'Not Sure Yet', value: 'unsure' },
],
admin: {
condition: (data) => data?.aiEnhancement,
},
},
{
name: 'industry',
type: 'select',
options: [
{ label: 'Maritime / Yachting', value: 'maritime' },
{ label: 'Hospitality', value: 'hospitality' },
{ label: 'Technology', value: 'technology' },
{ label: 'Real Estate', value: 'real-estate' },
{ label: 'Finance', value: 'finance' },
{ label: 'NGO / Nonprofit', value: 'ngo' },
{ label: 'Other', value: 'other' },
],
},
{
name: 'scope',
type: 'textarea',
},
{
name: 'timeline',
type: 'select',
options: [
{ label: 'ASAP', value: 'asap' },
{ label: '1-3 months', value: '1-3-months' },
{ label: '3-6 months', value: '3-6-months' },
{ label: 'Just exploring', value: 'exploring' },
],
},
{
name: 'brief',
type: 'textarea',
admin: {
readOnly: true,
description: 'AI-generated project brief',
},
},
],
}

View File

@@ -0,0 +1,15 @@
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
fields: [
{
name: 'name',
type: 'text',
},
],
}

View File

@@ -0,0 +1,44 @@
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import sharp from 'sharp'
import path from 'path'
import { fileURLToPath } from 'url'
import { Projects } from './collections/Projects'
import { Services } from './collections/Services'
import { Submissions } from './collections/Submissions'
import { Media } from './collections/Media'
import { Users } from './collections/Users'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname, '..'),
},
},
collections: [Users, Media, Projects, Services, Submissions],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || 'CHANGE-ME-IN-PRODUCTION',
typescript: {
outputFile: path.resolve(dirname, '../payload-types.ts'),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI || 'postgresql://postgres:postgres@localhost:5432/letsbe',
},
}),
sharp,
localization: {
locales: [
{ label: 'English', code: 'en' },
{ label: 'Français', code: 'fr' },
],
defaultLocale: 'en',
fallback: true,
},
})

62
src/styles/globals.css Normal file
View File

@@ -0,0 +1,62 @@
@import 'tailwindcss';
@theme {
--color-primary: #5BA4D9;
--color-primary-dark: #006494;
--color-primary-light: #8EC5E8;
--color-navy: #1C2B3A;
--color-teal: #2EC4A0;
--color-surface: #f8f9fa;
--color-surface-low: #f3f4f5;
--color-surface-high: #ffffff;
--color-on-surface: #191c1d;
--color-outline: #72787e;
--color-outline-variant: #c2c7ce;
--font-serif: 'Cormorant Garamond', Georgia, serif;
--font-sans: 'Inter', system-ui, sans-serif;
--shadow-subtle: 0 20px 40px rgba(25, 28, 29, 0.06);
--shadow-card: 0 4px 16px rgba(25, 28, 29, 0.04);
}
@layer base {
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-sans);
color: var(--color-on-surface);
background-color: var(--color-surface);
}
h1, h2, h3, h4 {
font-family: var(--font-serif);
letter-spacing: -0.02em;
}
}
@layer utilities {
.text-gradient {
background: linear-gradient(135deg, var(--color-primary-dark), var(--color-primary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.glass {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.label-md {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 500;
}
}