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:
22
.env.example
Normal file
22
.env.example
Normal file
@@ -0,0 +1,22 @@
|
||||
# Database
|
||||
DATABASE_URI=postgresql://postgres:postgres@localhost:5432/letsbe
|
||||
|
||||
# Payload CMS
|
||||
PAYLOAD_SECRET=your-secret-key-here-change-in-production
|
||||
|
||||
# OpenRouter (for AI brief generation)
|
||||
OPENROUTER_API_KEY=your-openrouter-api-key
|
||||
|
||||
# Email (Poste.io SMTP)
|
||||
SMTP_HOST=mail.letsbe.biz
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=hello@letsbe.biz
|
||||
SMTP_PASS=your-smtp-password
|
||||
SMTP_FROM=hello@letsbe.biz
|
||||
ADMIN_EMAIL=hello@letsbe.biz
|
||||
|
||||
# Cal.com
|
||||
NEXT_PUBLIC_CALCOM_URL=https://cal.letsbe.biz
|
||||
|
||||
# Site
|
||||
NEXT_PUBLIC_SITE_URL=https://letsbe.biz
|
||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
out/
|
||||
|
||||
# production
|
||||
build/
|
||||
dist/
|
||||
|
||||
# env files
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# payload
|
||||
public/media/
|
||||
|
||||
# superpowers
|
||||
.superpowers/
|
||||
14
next.config.mjs
Normal file
14
next.config.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
import createNextIntlPlugin from 'next-intl/plugin'
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
},
|
||||
}
|
||||
|
||||
export default withPayload(withNextIntl(nextConfig))
|
||||
8296
package-lock.json
generated
Normal file
8296
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "letsbe-agency",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"generate:types": "payload generate:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/db-postgres": "^3.80.0",
|
||||
"@payloadcms/next": "^3.80.0",
|
||||
"@payloadcms/richtext-lexical": "^3.80.0",
|
||||
"@payloadcms/ui": "^3.80.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "^19.2.14",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"graphql": "^16.13.2",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "^16.2.1",
|
||||
"next-intl": "^4.8.3",
|
||||
"nodemailer": "^8.0.4",
|
||||
"payload": "^3.80.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.2"
|
||||
}
|
||||
}
|
||||
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
BIN
public/images/LBB_Short_Logo_Blue.png
Normal file
BIN
public/images/LBB_Short_Logo_Blue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
BIN
public/images/LogoLetsBe_Biz_celesBlue.png
Normal file
BIN
public/images/LogoLetsBe_Biz_celesBlue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 485 KiB |
BIN
public/images/letsbe-logo-short.png
Normal file
BIN
public/images/letsbe-logo-short.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 454 KiB |
420
src/app/(frontend)/[locale]/about/page.tsx
Normal file
420
src/app/(frontend)/[locale]/about/page.tsx
Normal 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’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">
|
||||
“Build fewer things. Build them better. Build them to last.”
|
||||
</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]">
|
||||
“Our mission is to bring the precision of architecture to the fluidity of the
|
||||
web.”
|
||||
</blockquote>
|
||||
|
||||
<p className="label-md text-white/40">
|
||||
— 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’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>
|
||||
);
|
||||
}
|
||||
46
src/app/(frontend)/[locale]/layout.tsx
Normal file
46
src/app/(frontend)/[locale]/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
src/app/(frontend)/[locale]/page.tsx
Normal file
31
src/app/(frontend)/[locale]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
174
src/app/(frontend)/[locale]/services/page.tsx
Normal file
174
src/app/(frontend)/[locale]/services/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
293
src/app/(frontend)/[locale]/work/[slug]/page.tsx
Normal file
293
src/app/(frontend)/[locale]/work/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
src/app/(frontend)/api/configure/route.ts
Normal file
216
src/app/(frontend)/api/configure/route.ts
Normal 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 1–3 months';
|
||||
case '3-6months':
|
||||
return 'over a 3–6 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 (2–3 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
23
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal 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
|
||||
2
src/app/(payload)/admin/importMap.js
Normal file
2
src/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// This file will be auto-generated by Payload CMS
|
||||
export const importMap = {}
|
||||
14
src/app/(payload)/layout.tsx
Normal file
14
src/app/(payload)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
src/components/configurator/ProgressBar.tsx
Normal file
39
src/components/configurator/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
src/components/configurator/StepComplete.tsx
Normal file
204
src/components/configurator/StepComplete.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
290
src/components/configurator/StepContact.tsx
Normal file
290
src/components/configurator/StepContact.tsx
Normal 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': '1–3 months',
|
||||
'3-6months': '3–6 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>
|
||||
);
|
||||
}
|
||||
174
src/components/configurator/StepDetails.tsx
Normal file
174
src/components/configurator/StepDetails.tsx
Normal 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: '1–3 months' },
|
||||
{ id: '3-6months', label: '3–6 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>
|
||||
);
|
||||
}
|
||||
346
src/components/configurator/StepServices.tsx
Normal file
346
src/components/configurator/StepServices.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
src/components/configurator/WizardContainer.tsx
Normal file
183
src/components/configurator/WizardContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/components/icons/AnimatedCheckmark.tsx
Normal file
81
src/components/icons/AnimatedCheckmark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/icons/CornerBracket.tsx
Normal file
44
src/components/icons/CornerBracket.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
402
src/components/icons/HeroGeometric.tsx
Normal file
402
src/components/icons/HeroGeometric.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
234
src/components/layout/Footer.tsx
Normal file
234
src/components/layout/Footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
332
src/components/layout/Nav.tsx
Normal file
332
src/components/layout/Nav.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
224
src/components/sections/CTABanner.tsx
Normal file
224
src/components/sections/CTABanner.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Mail } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { viewportOnce } from '@/lib/animations';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
// The full-width background clips in from the center (scaleX)
|
||||
const bgScaleVariants = {
|
||||
hidden: { scaleX: 0, opacity: 0 },
|
||||
visible: {
|
||||
scaleX: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.9,
|
||||
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Content fades up slightly after the background arrives
|
||||
const contentContainerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.35,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const contentItemVariants = {
|
||||
hidden: { opacity: 0, y: 28 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Decorative skewed band (white-tinted)
|
||||
function DecorativeBand() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
{/* Large skewed element — upper-left to center */}
|
||||
<div
|
||||
className="absolute -top-24 -left-32 w-[520px] h-[340px] bg-white/[0.04] rounded-3xl"
|
||||
style={{ transform: 'rotate(-14deg) skewX(-8deg)' }}
|
||||
/>
|
||||
|
||||
{/* Smaller skewed element — lower-right */}
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-[360px] h-[220px] bg-white/[0.05] rounded-2xl"
|
||||
style={{ transform: 'rotate(10deg) skewX(6deg) translate(60px, 40px)' }}
|
||||
/>
|
||||
|
||||
{/* Fine diagonal line accent — top-left */}
|
||||
<div
|
||||
className="absolute top-8 left-12 w-[160px] h-[1px] bg-white/10"
|
||||
style={{ transform: 'rotate(-30deg)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-12 left-16 w-[100px] h-[1px] bg-white/8"
|
||||
style={{ transform: 'rotate(-30deg)' }}
|
||||
/>
|
||||
|
||||
{/* Dot cluster — top-right */}
|
||||
{[0, 1, 2, 3, 4].map((col) =>
|
||||
[0, 1, 2].map((row) => (
|
||||
<div
|
||||
key={`dot-${col}-${row}`}
|
||||
className="absolute w-[3px] h-[3px] rounded-full bg-white/10"
|
||||
style={{
|
||||
top: `${28 + row * 14}px`,
|
||||
right: `${80 + col * 14}px`,
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
|
||||
{/* Subtle arc — bottom center */}
|
||||
<svg
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2"
|
||||
width="600"
|
||||
height="120"
|
||||
viewBox="0 0 600 120"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M 0 120 Q 300 40 600 120"
|
||||
stroke="rgba(255,255,255,0.05)"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M 60 120 Q 300 60 540 120"
|
||||
stroke="rgba(255,255,255,0.04)"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
strokeDasharray="4 10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CTABanner() {
|
||||
const t = useTranslations('cta');
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Call to action"
|
||||
className="relative overflow-hidden"
|
||||
>
|
||||
{/* Animated background that clips in from center */}
|
||||
<motion.div
|
||||
variants={bgScaleVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="absolute inset-0 origin-center"
|
||||
style={{ backgroundColor: '#006494' }}
|
||||
>
|
||||
<DecorativeBand />
|
||||
</motion.div>
|
||||
|
||||
{/* Content — positioned relative so it sits above the bg */}
|
||||
<div className="relative z-10 py-24 px-6">
|
||||
<motion.div
|
||||
variants={contentContainerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="max-w-3xl mx-auto flex flex-col items-center text-center gap-8"
|
||||
>
|
||||
{/* Eyebrow */}
|
||||
<motion.span
|
||||
variants={contentItemVariants}
|
||||
className="label-md text-white/60 tracking-widest uppercase"
|
||||
>
|
||||
{t('eyebrow')}
|
||||
</motion.span>
|
||||
|
||||
{/* Headline */}
|
||||
<motion.h2
|
||||
variants={contentItemVariants}
|
||||
className={cn(
|
||||
'font-serif font-semibold text-white',
|
||||
'text-4xl md:text-5xl leading-[1.1] tracking-[-0.02em]',
|
||||
)}
|
||||
>
|
||||
{t('title')}
|
||||
</motion.h2>
|
||||
|
||||
{/* Supporting text */}
|
||||
<motion.p
|
||||
variants={contentItemVariants}
|
||||
className="text-white/70 text-lg leading-relaxed max-w-xl"
|
||||
>
|
||||
{t('subtitle')}
|
||||
</motion.p>
|
||||
|
||||
{/* CTA row */}
|
||||
<motion.div
|
||||
variants={contentItemVariants}
|
||||
className="flex flex-col sm:flex-row items-center gap-4"
|
||||
>
|
||||
{/* Primary CTA — white-styled button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
arrow
|
||||
href="#configure"
|
||||
className={cn(
|
||||
'ring-white/80 text-white',
|
||||
'hover:bg-white/10 hover:ring-white',
|
||||
'focus-visible:ring-white',
|
||||
)}
|
||||
>
|
||||
{t('cta')}
|
||||
</Button>
|
||||
|
||||
{/* Email link — ghost style */}
|
||||
<a
|
||||
href="mailto:hello@letsbe.biz"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-6 py-3 rounded-xl',
|
||||
'text-white/80 text-sm font-medium',
|
||||
'transition-all duration-200',
|
||||
'hover:text-white hover:bg-white/8',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#006494]',
|
||||
)}
|
||||
>
|
||||
<Mail
|
||||
size={16}
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
hello@letsbe.biz
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
{/* Fine-print reassurance */}
|
||||
<motion.p
|
||||
variants={contentItemVariants}
|
||||
className="text-white/40 text-xs"
|
||||
>
|
||||
{t('reassurance')}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
114
src/components/sections/Configurator.tsx
Normal file
114
src/components/sections/Configurator.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { motion } from 'framer-motion';
|
||||
import { revealVariants, staggerContainer, viewportOnce } from '@/lib/animations';
|
||||
import WizardContainer from '@/components/configurator/WizardContainer';
|
||||
|
||||
// ─── Step indicator dot ───────────────────────────────────────────────────────
|
||||
|
||||
interface StepDotProps {
|
||||
index: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function StepDot({ index, label }: StepDotProps) {
|
||||
return (
|
||||
<motion.div variants={revealVariants} className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded-full border border-outline-variant/60 bg-surface-high flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[10px] font-semibold text-outline">{index}</span>
|
||||
</div>
|
||||
<span className="text-sm text-outline">{label}</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Configurator() {
|
||||
const t = useTranslations('configurator');
|
||||
|
||||
const steps = [
|
||||
t('step1.title'),
|
||||
t('step2.title'),
|
||||
t('step3.title'),
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="configure" className="bg-surface-low py-24">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="grid grid-cols-1 gap-12 lg:grid-cols-12">
|
||||
|
||||
{/* ── Left: Sticky context panel ─────────────────────────────── */}
|
||||
<div className="lg:col-span-5">
|
||||
<div className="lg:sticky lg:top-24">
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{/* Eyebrow */}
|
||||
<motion.span
|
||||
variants={revealVariants}
|
||||
className="label-md text-primary"
|
||||
>
|
||||
{t('eyebrow')}
|
||||
</motion.span>
|
||||
|
||||
{/* H2 */}
|
||||
<motion.h2
|
||||
variants={revealVariants}
|
||||
className="font-serif text-4xl font-semibold tracking-headline text-on-surface leading-tight md:text-5xl"
|
||||
>
|
||||
{t('title')}
|
||||
</motion.h2>
|
||||
|
||||
{/* Description */}
|
||||
<motion.p
|
||||
variants={revealVariants}
|
||||
className="text-base text-outline leading-relaxed max-w-sm"
|
||||
>
|
||||
{t('description')}
|
||||
</motion.p>
|
||||
|
||||
{/* Step indicators */}
|
||||
<motion.div
|
||||
variants={revealVariants}
|
||||
className="flex flex-col gap-3 pt-2"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-label text-outline/70">
|
||||
How it works
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{steps.map((step, i) => (
|
||||
<StepDot key={i} index={i + 1} label={step} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Trust signal */}
|
||||
<motion.div
|
||||
variants={revealVariants}
|
||||
className="pt-2 flex items-center gap-2.5"
|
||||
>
|
||||
<div className="h-px flex-1 bg-outline-variant/40" />
|
||||
<p className="text-xs text-outline">No commitment required</p>
|
||||
<div className="h-px flex-1 bg-outline-variant/40" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: Wizard ───────────────────────────────────────────── */}
|
||||
<div className="lg:col-span-7">
|
||||
<div className="rounded-2xl bg-surface-high shadow-subtle p-6 sm:p-8">
|
||||
<WizardContainer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
294
src/components/sections/Hero.tsx
Normal file
294
src/components/sections/Hero.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { viewportOnce } from '@/lib/animations';
|
||||
import Button from '@/components/ui/Button';
|
||||
import HeroGeometric from '@/components/icons/HeroGeometric';
|
||||
|
||||
// Slow drift animation for the SVG background layers
|
||||
const bgDriftA = {
|
||||
animate: {
|
||||
y: [0, -12, 0],
|
||||
x: [0, 6, 0],
|
||||
transition: {
|
||||
duration: 18,
|
||||
ease: 'easeInOut' as const,
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const bgDriftB = {
|
||||
animate: {
|
||||
y: [0, 8, 0],
|
||||
x: [0, -8, 0],
|
||||
scale: [1, 1.015, 1],
|
||||
transition: {
|
||||
duration: 24,
|
||||
ease: 'easeInOut' as const,
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const bgRotate = {
|
||||
animate: {
|
||||
rotate: [0, 1.5, 0, -1.5, 0],
|
||||
transition: {
|
||||
duration: 30,
|
||||
ease: 'easeInOut' as const,
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Word-level stagger container — fires on mount (not scroll)
|
||||
const heroHeadlineContainer = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.07,
|
||||
delayChildren: 0.15,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Individual word reveal
|
||||
const wordReveal = {
|
||||
hidden: { opacity: 0, y: 48, filter: 'blur(4px)' },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: 'blur(0px)',
|
||||
transition: {
|
||||
duration: 0.7,
|
||||
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Subtitle fade — delay after headline
|
||||
const subtitleVariant = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
delay: 0.5,
|
||||
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// CTA scale-in
|
||||
const ctaVariant = {
|
||||
hidden: { opacity: 0, scale: 0.93 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.45,
|
||||
delay: 0.7,
|
||||
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Trust strip slide in from left
|
||||
const trustVariant = {
|
||||
hidden: { opacity: 0, x: -32 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 0.55,
|
||||
delay: 0.9,
|
||||
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Gradient stops for each avatar circle — subtle variations on the primary palette
|
||||
const AVATAR_GRADIENTS = [
|
||||
'linear-gradient(135deg, rgba(0,100,148,0.35), rgba(91,164,217,0.45))',
|
||||
'linear-gradient(135deg, rgba(91,164,217,0.40), rgba(0,100,148,0.30))',
|
||||
'linear-gradient(135deg, rgba(0,100,148,0.28), rgba(91,164,217,0.38))',
|
||||
] as const;
|
||||
|
||||
function AvatarCircle({ index }: { index: number }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block w-8 h-8 rounded-full ring-2 ring-white',
|
||||
index > 0 && '-ml-2',
|
||||
)}
|
||||
style={{ background: AVATAR_GRADIENTS[index] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Hero() {
|
||||
const t = useTranslations('hero');
|
||||
|
||||
// Split the raw title by the {exclusively} placeholder so we can inject the styled <em>
|
||||
// Expected translation shape: "Built {exclusively} for ambitious brands."
|
||||
const rawTitle: string = t.raw('title');
|
||||
const parts = rawTitle.split('{exclusively}');
|
||||
const before = parts[0] ?? '';
|
||||
const after = parts[1] ?? '';
|
||||
|
||||
// Split each segment into words for per-word animation
|
||||
const beforeWords = before.trim() ? before.trim().split(' ') : [];
|
||||
const afterWords = after.trim() ? after.trim().split(' ') : [];
|
||||
const exclusivelyWord = 'exclusively';
|
||||
|
||||
// All words in order: before + [exclusively] + after
|
||||
type WordItem =
|
||||
| { type: 'normal'; text: string }
|
||||
| { type: 'accent'; text: string };
|
||||
|
||||
const allWords: WordItem[] = [
|
||||
...beforeWords.map((w) => ({ type: 'normal' as const, text: w })),
|
||||
{ type: 'accent' as const, text: exclusivelyWord },
|
||||
...afterWords.map((w) => ({ type: 'normal' as const, text: w })),
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
id="hero"
|
||||
aria-label="Hero"
|
||||
className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden bg-surface-high"
|
||||
>
|
||||
{/* ─── Background: animated SVG layers ─────────────────────────── */}
|
||||
<motion.div
|
||||
className="absolute inset-0 z-0 pointer-events-none"
|
||||
{...bgDriftA}
|
||||
>
|
||||
<motion.div className="absolute inset-0" {...bgRotate}>
|
||||
<HeroGeometric className="absolute inset-0 w-full h-full" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Subtle radial gradient vignette over the SVG */}
|
||||
<motion.div
|
||||
className="absolute inset-0 z-0 pointer-events-none"
|
||||
{...bgDriftB}
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse 70% 60% at 50% 45%, transparent 30%, rgba(248,249,250,0.55) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ─── Content ──────────────────────────────────────────────────── */}
|
||||
<div className="relative z-10 w-full max-w-4xl mx-auto px-6 py-24 flex flex-col items-center text-center">
|
||||
|
||||
{/* Eyebrow */}
|
||||
<motion.span
|
||||
className="label-md text-primary mb-6 tracking-widest uppercase"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.05, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
BESPOKE DIGITAL STUDIO
|
||||
</motion.span>
|
||||
|
||||
{/* Headline with per-word stagger */}
|
||||
<motion.h1
|
||||
className={cn(
|
||||
'font-serif font-semibold text-on-surface',
|
||||
'text-5xl sm:text-6xl md:text-7xl leading-[1.08] tracking-[-0.02em]',
|
||||
'mb-6 flex flex-wrap items-baseline justify-center gap-x-[0.25em] gap-y-1',
|
||||
)}
|
||||
variants={heroHeadlineContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
aria-label={rawTitle.replace('{exclusively}', exclusivelyWord)}
|
||||
>
|
||||
{allWords.map((word, i) =>
|
||||
word.type === 'accent' ? (
|
||||
<motion.span
|
||||
key={`word-accent-${i}`}
|
||||
variants={wordReveal}
|
||||
className="inline-block overflow-hidden"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
<em className="text-primary font-serif italic not-italic">
|
||||
{word.text}
|
||||
</em>
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key={`word-${i}`}
|
||||
variants={wordReveal}
|
||||
className="inline-block"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
{word.text}
|
||||
</motion.span>
|
||||
),
|
||||
)}
|
||||
</motion.h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<motion.p
|
||||
className="text-lg text-outline leading-relaxed max-w-xl mb-10"
|
||||
variants={subtitleVariant}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{t('subtitle')}
|
||||
</motion.p>
|
||||
|
||||
{/* CTA row */}
|
||||
<motion.div
|
||||
className="flex flex-col sm:flex-row items-center gap-4 mb-12"
|
||||
variants={ctaVariant}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<Button variant="primary" size="lg" arrow href="#configure">
|
||||
{t('cta')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" href="#work">
|
||||
{t('ctaSecondary')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Trust proof strip */}
|
||||
<motion.div
|
||||
className="flex items-center gap-3"
|
||||
variants={trustVariant}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* Overlapping avatar circles */}
|
||||
<div className="flex items-center" aria-hidden="true">
|
||||
<AvatarCircle index={0} />
|
||||
<AvatarCircle index={1} />
|
||||
<AvatarCircle index={2} />
|
||||
</div>
|
||||
<p className="text-sm text-outline">
|
||||
{t('trust')}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Bottom fade-out to next section */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-24 pointer-events-none z-10"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, transparent, rgba(248,249,250,0.6))',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
353
src/components/sections/Philosophy.tsx
Normal file
353
src/components/sections/Philosophy.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
slideLeftVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import CornerBracket from '@/components/icons/CornerBracket';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PillarKey {
|
||||
key: 'ownership' | 'craftsmanship' | 'oneTeam';
|
||||
}
|
||||
|
||||
// ─── Data ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PILLARS: PillarKey[] = [
|
||||
{ key: 'ownership' },
|
||||
{ key: 'craftsmanship' },
|
||||
{ key: 'oneTeam' },
|
||||
];
|
||||
|
||||
// ─── Animation Variants ───────────────────────────────────────────────────────
|
||||
|
||||
const ease = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
const pillarContainerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.12,
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const pillarVariants = {
|
||||
hidden: { opacity: 0, x: -32 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.6, ease },
|
||||
},
|
||||
};
|
||||
|
||||
const decorativeVariants = {
|
||||
hidden: { opacity: 0, scale: 0.96 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { duration: 0.8, ease, delay: 0.15 },
|
||||
},
|
||||
};
|
||||
|
||||
const quoteCardVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 32,
|
||||
rotate: 4,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
rotate: 0,
|
||||
transition: { duration: 0.75, ease, delay: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
const leftBorderVariants = {
|
||||
hidden: { scaleY: 0 },
|
||||
visible: {
|
||||
scaleY: 1,
|
||||
transition: { duration: 0.6, ease },
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Philosophy Pillar ────────────────────────────────────────────────────────
|
||||
|
||||
function PhilosophyPillar({
|
||||
title,
|
||||
description,
|
||||
index,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={pillarVariants}
|
||||
className="relative flex items-stretch gap-0"
|
||||
>
|
||||
{/* Animated left border */}
|
||||
<div className="relative flex-shrink-0 w-[2px] mr-5 self-stretch overflow-hidden">
|
||||
<div className="absolute inset-0 bg-outline-variant/20 rounded-full" />
|
||||
<motion.div
|
||||
variants={leftBorderVariants}
|
||||
className="absolute inset-0 origin-top rounded-full"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, var(--color-primary), var(--color-primary-dark))`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-1.5 py-4">
|
||||
<p
|
||||
className="label-md text-outline/60 mb-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</p>
|
||||
<h3 className="font-semibold text-on-surface text-base leading-snug">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-outline leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Abstract Geometric Decoration ───────────────────────────────────────────
|
||||
|
||||
function AbstractGeometry() {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden rounded-xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Primary large circle */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '65%',
|
||||
height: '65%',
|
||||
top: '-8%',
|
||||
right: '-12%',
|
||||
background:
|
||||
'radial-gradient(circle, rgba(91,164,217,0.12) 0%, rgba(0,100,148,0.06) 60%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Secondary circle, bottom-left */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '50%',
|
||||
height: '50%',
|
||||
bottom: '-10%',
|
||||
left: '-6%',
|
||||
background:
|
||||
'radial-gradient(circle, rgba(0,100,148,0.08) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Diagonal grid */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(-45deg, var(--color-primary-dark) 0, var(--color-primary-dark) 1px, transparent 0, transparent 50%)',
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Accent rectangle top-left */}
|
||||
<div
|
||||
className="absolute rounded-md"
|
||||
style={{
|
||||
width: '22%',
|
||||
height: '30%',
|
||||
top: '10%',
|
||||
left: '8%',
|
||||
border: '1.5px solid rgba(91,164,217,0.15)',
|
||||
transform: 'rotate(-8deg)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Floating dot cluster center-right */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
width: '22%',
|
||||
height: '22%',
|
||||
top: '35%',
|
||||
right: '10%',
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, rgba(91,164,217,0.25) 1.5px, transparent 1.5px)',
|
||||
backgroundSize: '10px 10px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Thin arc line */}
|
||||
<svg
|
||||
className="absolute"
|
||||
style={{ top: '20%', left: '30%', opacity: 0.08 }}
|
||||
width="140"
|
||||
height="140"
|
||||
viewBox="0 0 140 140"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r="60"
|
||||
stroke="var(--color-primary-dark)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="8 6"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Small solid accent square */}
|
||||
<div
|
||||
className="absolute rounded-sm"
|
||||
style={{
|
||||
width: '6%',
|
||||
height: '6%',
|
||||
bottom: '28%',
|
||||
right: '28%',
|
||||
background: 'rgba(91,164,217,0.20)',
|
||||
transform: 'rotate(12deg)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function Philosophy() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<section id="about" className="bg-surface py-24">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-start">
|
||||
|
||||
{/* ── Left Column: Text (5 cols) ── */}
|
||||
<div className="lg:col-span-5 flex flex-col gap-10">
|
||||
|
||||
{/* Header block */}
|
||||
<motion.div
|
||||
variants={slideLeftVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<p className="label-md text-primary">{t('philosophy.eyebrow')}</p>
|
||||
<h2 className="font-serif text-4xl md:text-[2.75rem] font-semibold text-on-surface leading-[1.1] tracking-[-0.02em]">
|
||||
{t('philosophy.title')}
|
||||
</h2>
|
||||
<p className="text-outline leading-relaxed text-[0.9375rem]">
|
||||
{t('philosophy.subtitle')}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Philosophy pillars */}
|
||||
<motion.div
|
||||
variants={pillarContainerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{PILLARS.map(({ key }, i) => (
|
||||
<PhilosophyPillar
|
||||
key={key}
|
||||
index={i}
|
||||
title={t(`philosophy.${key}.title`)}
|
||||
description={t(`philosophy.${key}.description`)}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Right Column: Decorative (7 cols) ── */}
|
||||
<div className="lg:col-span-7 relative">
|
||||
|
||||
{/* Main decorative canvas */}
|
||||
<motion.div
|
||||
variants={decorativeVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className={cn(
|
||||
'relative bg-surface-low rounded-xl',
|
||||
'min-h-[420px] lg:min-h-[500px]',
|
||||
'overflow-visible',
|
||||
)}
|
||||
>
|
||||
<AbstractGeometry />
|
||||
|
||||
{/* Subtle inner shadow rim */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-xl pointer-events-none"
|
||||
style={{
|
||||
boxShadow: 'inset 0 0 0 1px rgba(194,199,206,0.25)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Pull-Quote Card ── */}
|
||||
<motion.div
|
||||
variants={quoteCardVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className={cn(
|
||||
'absolute -bottom-8 -left-4 lg:-left-10',
|
||||
'bg-surface-high rounded-xl p-6 shadow-subtle',
|
||||
'max-w-[320px] w-[calc(100%-2.5rem)] lg:max-w-[340px]',
|
||||
'z-10',
|
||||
)}
|
||||
>
|
||||
{/* CornerBracket top-right decoration */}
|
||||
<div className="absolute top-4 right-4">
|
||||
<CornerBracket
|
||||
size={28}
|
||||
position="top-right"
|
||||
color="var(--color-primary)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quote text */}
|
||||
<blockquote className="font-serif italic text-lg text-on-surface leading-relaxed pr-8">
|
||||
“{t('philosophy.quote')}”
|
||||
</blockquote>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
className="w-8 h-px bg-primary/40 my-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Attribution */}
|
||||
<p className="label-md text-outline">Founded on the Côte d’Azur</p>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
140
src/components/sections/Process.tsx
Normal file
140
src/components/sections/Process.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { motion, type Variants } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Search, LayoutDashboard, PenTool, Rocket, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
staggerContainerWide,
|
||||
revealVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import SectionHeader from '@/components/ui/SectionHeader';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Step {
|
||||
numeral: string;
|
||||
key: 'discovery' | 'strategy' | 'build' | 'launch';
|
||||
Icon: LucideIcon;
|
||||
}
|
||||
|
||||
// ─── Data ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{ numeral: '01', key: 'discovery', Icon: Search },
|
||||
{ numeral: '02', key: 'strategy', Icon: LayoutDashboard },
|
||||
{ numeral: '03', key: 'build', Icon: PenTool },
|
||||
{ numeral: '04', key: 'launch', Icon: Rocket },
|
||||
];
|
||||
|
||||
// ─── Variants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Numeral scales up from 80% while fading in
|
||||
const numeralScaleVariants: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.8 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.7,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StepCard({ numeral, stepKey, Icon }: { numeral: string; stepKey: string; Icon: LucideIcon }) {
|
||||
const t = useTranslations();
|
||||
const title = t(`process.steps.${stepKey}.title`);
|
||||
const description = t(`process.steps.${stepKey}.description`);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={revealVariants}
|
||||
className={cn(
|
||||
'relative flex flex-col bg-surface-high rounded-xl p-6',
|
||||
'shadow-subtle',
|
||||
)}
|
||||
>
|
||||
{/* Ghosted numeral — scales up on scroll */}
|
||||
<motion.span
|
||||
variants={numeralScaleVariants}
|
||||
aria-hidden="true"
|
||||
className="font-serif text-6xl font-light leading-none text-on-surface/[0.06] select-none -ml-0.5 mb-3"
|
||||
>
|
||||
{numeral}
|
||||
</motion.span>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="mb-4">
|
||||
<Icon
|
||||
size={24}
|
||||
strokeWidth={1.5}
|
||||
className="text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-lg text-on-surface mb-2 leading-snug">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-outline leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function Process() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<section id="process" className="bg-surface-low py-24">
|
||||
<div className="container mx-auto px-6">
|
||||
|
||||
{/*
|
||||
Desktop layout:
|
||||
col 1 (lg:col-span-1) → SectionHeader, flush left
|
||||
col 2–4 (lg:col-span-3) → 2×2 grid of step cards
|
||||
|
||||
Mobile layout:
|
||||
header on top, steps in single column below
|
||||
*/}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-12 lg:gap-10 items-start">
|
||||
|
||||
{/* ── Header column ── */}
|
||||
<div className="lg:col-span-1 lg:sticky lg:top-32">
|
||||
<SectionHeader
|
||||
eyebrow={t('process.eyebrow')}
|
||||
title={t('process.title')}
|
||||
align="left"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Steps column ── */}
|
||||
<div className="lg:col-span-3">
|
||||
<motion.div
|
||||
variants={staggerContainerWide}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-5"
|
||||
>
|
||||
{STEPS.map((step) => (
|
||||
<StepCard key={step.key} numeral={step.numeral} stepKey={step.key} Icon={step.Icon} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
464
src/components/sections/SelectedWorks.tsx
Normal file
464
src/components/sections/SelectedWorks.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
staggerContainerWide,
|
||||
slideLeftVariants,
|
||||
fadeVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Lock, Clock, ArrowRight } from 'lucide-react';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Project {
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
slug: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
interface ComingSoonItem {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
// ─── Data ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PROJECTS: Project[] = [
|
||||
{
|
||||
title: 'Monaco Ocean Protection Challenge',
|
||||
description:
|
||||
"A comprehensive judging and analytics system with advanced AI jury integration for one of the Mediterranean's most prestigious conservation events.",
|
||||
tags: ['AI Integration', 'Platform'],
|
||||
slug: 'monaco-ocean',
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
title: 'Port Nimara',
|
||||
description: 'Scalable digital hub for maritime logistics.',
|
||||
tags: ['Website', 'Infrastructure'],
|
||||
slug: 'port-nimara',
|
||||
},
|
||||
{
|
||||
title: 'Port Amador',
|
||||
description: 'Premium digital experience for elite nautical services.',
|
||||
tags: ['Website', 'Infrastructure'],
|
||||
slug: 'port-amador',
|
||||
},
|
||||
];
|
||||
|
||||
const COMING_SOON: ComingSoonItem[] = [
|
||||
{ title: 'Confidential Riviera Project', subtitle: 'Coming Soon' },
|
||||
{ title: 'Sophia Antipolis AI Startup', subtitle: 'Launching Q4' },
|
||||
];
|
||||
|
||||
// ─── Animation Variants ───────────────────────────────────────────────────────
|
||||
|
||||
const ease = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
const featuredCardVariants = {
|
||||
hidden: { opacity: 0, y: 48 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.7, ease },
|
||||
},
|
||||
};
|
||||
|
||||
const smallCardVariants = {
|
||||
hidden: { opacity: 0, y: 32 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease },
|
||||
},
|
||||
};
|
||||
|
||||
const comingSoonVariants = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.55, ease },
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Geometric Placeholder ────────────────────────────────────────────────────
|
||||
|
||||
function GeometricPlaceholder({
|
||||
variant = 'featured',
|
||||
className,
|
||||
}: {
|
||||
variant?: 'featured' | 'small';
|
||||
className?: string;
|
||||
}) {
|
||||
const isFeatured = variant === 'featured';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden bg-gradient-to-br from-primary-dark/90 to-primary/70',
|
||||
className,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Abstract geometric shapes */}
|
||||
<div className="absolute inset-0">
|
||||
{/* Large circle top-right */}
|
||||
<div
|
||||
className="absolute rounded-full bg-white/[0.06]"
|
||||
style={{
|
||||
width: isFeatured ? '55%' : '70%',
|
||||
height: isFeatured ? '55%' : '70%',
|
||||
top: '-15%',
|
||||
right: '-10%',
|
||||
}}
|
||||
/>
|
||||
{/* Medium circle bottom-left */}
|
||||
<div
|
||||
className="absolute rounded-full bg-white/[0.08]"
|
||||
style={{
|
||||
width: isFeatured ? '40%' : '50%',
|
||||
height: isFeatured ? '40%' : '50%',
|
||||
bottom: '-20%',
|
||||
left: '-5%',
|
||||
}}
|
||||
/>
|
||||
{/* Diagonal stripes overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.04]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(-45deg, #fff 0, #fff 1px, transparent 0, transparent 50%)',
|
||||
backgroundSize: isFeatured ? '28px 28px' : '20px 20px',
|
||||
}}
|
||||
/>
|
||||
{/* Small accent rectangle */}
|
||||
<div
|
||||
className="absolute bg-white/[0.12] rounded-sm"
|
||||
style={{
|
||||
width: isFeatured ? '18%' : '24%',
|
||||
height: isFeatured ? '28%' : '36%',
|
||||
bottom: '18%',
|
||||
right: '15%',
|
||||
transform: 'rotate(-6deg)',
|
||||
}}
|
||||
/>
|
||||
{/* Thin horizontal line accent */}
|
||||
<div
|
||||
className="absolute bg-white/20 rounded-full"
|
||||
style={{
|
||||
width: isFeatured ? '30%' : '40%',
|
||||
height: '1px',
|
||||
top: '38%',
|
||||
left: '10%',
|
||||
}}
|
||||
/>
|
||||
{/* Grid-dot accent */}
|
||||
<div
|
||||
className="absolute opacity-[0.07]"
|
||||
style={{
|
||||
width: isFeatured ? '25%' : '30%',
|
||||
height: isFeatured ? '25%' : '30%',
|
||||
top: '50%',
|
||||
right: '22%',
|
||||
backgroundImage: 'radial-gradient(circle, #fff 1px, transparent 1px)',
|
||||
backgroundSize: '8px 8px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Bottom gradient fade */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-primary-dark/50 to-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tag Chip ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TagChip({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center bg-primary/10 text-primary-dark text-xs font-medium px-2.5 py-1 rounded-full leading-none">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Featured Card ────────────────────────────────────────────────────────────
|
||||
|
||||
function FeaturedCard({ project, readLabel }: { project: Project; readLabel: string }) {
|
||||
return (
|
||||
<motion.article
|
||||
variants={featuredCardVariants}
|
||||
className={cn(
|
||||
'group relative flex flex-col bg-surface-high rounded-2xl overflow-hidden',
|
||||
'shadow-subtle',
|
||||
'transition-shadow duration-300 hover:shadow-[0_24px_48px_rgba(25,28,29,0.10)]',
|
||||
)}
|
||||
>
|
||||
{/* Geometric image placeholder */}
|
||||
<GeometricPlaceholder
|
||||
variant="featured"
|
||||
className="w-full aspect-[16/9] md:aspect-[2/1]"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col flex-1 p-7 gap-4">
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<TagChip key={tag} label={tag} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-serif text-2xl font-semibold text-on-surface leading-snug">
|
||||
{project.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-outline leading-relaxed flex-1">
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
href={`/work/${project.slug}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 text-sm font-medium text-primary-dark',
|
||||
'transition-gap duration-200 group/link',
|
||||
'mt-1',
|
||||
)}
|
||||
>
|
||||
<span className="underline underline-offset-4 decoration-primary/40 group-hover/link:decoration-primary-dark transition-colors duration-200">
|
||||
{readLabel}
|
||||
</span>
|
||||
<ArrowRight
|
||||
size={14}
|
||||
className="transition-transform duration-200 group-hover/link:translate-x-1"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Small Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SmallCard({ project, readLabel }: { project: Project; readLabel: string }) {
|
||||
return (
|
||||
<motion.article
|
||||
variants={smallCardVariants}
|
||||
className={cn(
|
||||
'group relative flex flex-col bg-surface-high rounded-xl overflow-hidden',
|
||||
'shadow-card',
|
||||
'transition-all duration-300',
|
||||
'hover:shadow-subtle',
|
||||
)}
|
||||
>
|
||||
{/* Geometric placeholder — grayscale to color on hover */}
|
||||
<div className="relative overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'transition-all duration-500',
|
||||
'grayscale group-hover:grayscale-0',
|
||||
'opacity-80 group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<GeometricPlaceholder
|
||||
variant="small"
|
||||
className="w-full aspect-[16/7]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col flex-1 p-5 gap-3">
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{project.tags.map((tag) => (
|
||||
<TagChip key={tag} label={tag} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-serif text-lg font-semibold text-on-surface leading-snug">
|
||||
{project.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-outline leading-relaxed flex-1">
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
href={`/work/${project.slug}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-primary-dark group/link"
|
||||
>
|
||||
<span className="underline underline-offset-4 decoration-primary/40 group-hover/link:decoration-primary-dark transition-colors duration-200">
|
||||
{readLabel}
|
||||
</span>
|
||||
<ArrowRight
|
||||
size={12}
|
||||
className="transition-transform duration-200 group-hover/link:translate-x-0.5"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Coming Soon Card ─────────────────────────────────────────────────────────
|
||||
|
||||
function ComingSoonCard({ item }: { item: ComingSoonItem }) {
|
||||
const isConfidential = item.subtitle === 'Coming Soon';
|
||||
const Icon = isConfidential ? Lock : Clock;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={comingSoonVariants}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center gap-4',
|
||||
'rounded-xl p-8 min-h-[160px]',
|
||||
'border border-dashed border-outline-variant/30',
|
||||
'coming-soon-card',
|
||||
'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{/* Subtle animated pulse overlay */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-xl opacity-0 coming-soon-pulse"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center gap-3 text-center">
|
||||
<Icon
|
||||
size={20}
|
||||
className="text-outline/40"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-serif text-base font-medium text-on-surface/40 leading-snug">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="label-md text-outline/50 mt-1">{item.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function SelectedWorks() {
|
||||
const t = useTranslations();
|
||||
|
||||
const featuredProject = PROJECTS.find((p) => p.featured)!;
|
||||
const secondaryProjects = PROJECTS.filter((p) => !p.featured);
|
||||
|
||||
return (
|
||||
<section id="work" className="bg-surface-low py-24">
|
||||
<style>{`
|
||||
@keyframes dashed-drift {
|
||||
0% { background-position: 0 0, 100% 0, 100% 100%, 0 100%; }
|
||||
100% { background-position: 100% 0, 100% 100%, 0 100%, 0 0; }
|
||||
}
|
||||
@keyframes coming-soon-fade {
|
||||
0%, 100% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.coming-soon-pulse {
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(91, 164, 217, 0.04) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: coming-soon-fade 4s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="container mx-auto px-6">
|
||||
|
||||
{/* ── Section Header ── */}
|
||||
<motion.div
|
||||
variants={slideLeftVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-14"
|
||||
>
|
||||
<div>
|
||||
<p className="label-md text-primary mb-2">{t('work.eyebrow')}</p>
|
||||
<h2 className="font-serif text-4xl md:text-5xl font-semibold text-on-surface leading-[1.1] tracking-[-0.02em]">
|
||||
{t('work.title')}
|
||||
</h2>
|
||||
</div>
|
||||
<motion.div
|
||||
variants={fadeVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
>
|
||||
<Link
|
||||
href="/work"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-outline hover:text-primary-dark transition-colors duration-200 group"
|
||||
>
|
||||
<span>View all work</span>
|
||||
<ArrowRight
|
||||
size={14}
|
||||
className="transition-transform duration-200 group-hover:translate-x-1"
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Primary Grid: Featured + 2 Small ── */}
|
||||
<motion.div
|
||||
variants={staggerContainerWide}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="grid grid-cols-1 md:grid-cols-12 gap-5 mb-5"
|
||||
>
|
||||
{/* Featured card — 8 cols */}
|
||||
<div className="md:col-span-8">
|
||||
<FeaturedCard
|
||||
project={featuredProject}
|
||||
readLabel={t('work.readCaseStudy')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Secondary column — 4 cols, 2 stacked */}
|
||||
<div className="md:col-span-4 flex flex-col gap-5">
|
||||
{secondaryProjects.map((project) => (
|
||||
<SmallCard
|
||||
key={project.slug}
|
||||
project={project}
|
||||
readLabel={t('work.readCaseStudy')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Coming Soon Row ── */}
|
||||
<motion.div
|
||||
variants={staggerContainerWide}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-5"
|
||||
>
|
||||
{COMING_SOON.map((item) => (
|
||||
<ComingSoonCard key={item.title} item={item} />
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
140
src/components/sections/ServicesOverview.tsx
Normal file
140
src/components/sections/ServicesOverview.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
staggerContainerWide,
|
||||
revealVariants,
|
||||
fadeVariants,
|
||||
scaleVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import SectionHeader from '@/components/ui/SectionHeader';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ServicePillar {
|
||||
numeral: string;
|
||||
titleKey: 'web' | 'systems' | 'infrastructure';
|
||||
featuresKey: 'web' | 'systems' | 'infrastructure';
|
||||
}
|
||||
|
||||
// ─── Data ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PILLARS: ServicePillar[] = [
|
||||
{ numeral: '01', titleKey: 'web', featuresKey: 'web' },
|
||||
{ numeral: '02', titleKey: 'systems', featuresKey: 'systems' },
|
||||
{ numeral: '03', titleKey: 'infrastructure', featuresKey: 'infrastructure' },
|
||||
];
|
||||
|
||||
// ─── Variants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const numeralVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1] as const },
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ServicesOverview() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<section id="services" className="bg-surface py-24">
|
||||
<div className="container mx-auto px-6">
|
||||
|
||||
{/* Section header */}
|
||||
<div className="mb-16">
|
||||
<SectionHeader
|
||||
eyebrow={t('services.eyebrow')}
|
||||
title={t('services.title')}
|
||||
align="center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Three-pillar grid with gap-px separator trick */}
|
||||
<div className="bg-outline/10 rounded-xl overflow-hidden shadow-subtle">
|
||||
<motion.div
|
||||
variants={staggerContainerWide}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-px"
|
||||
>
|
||||
{PILLARS.map(({ numeral, titleKey, featuresKey }) => {
|
||||
const features = t.raw(`services.${featuresKey}.features`) as string[];
|
||||
const title = t(`services.${titleKey}.title`);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={numeral}
|
||||
variants={revealVariants}
|
||||
className={cn(
|
||||
'relative flex flex-col p-8 bg-surface',
|
||||
'transition-colors duration-200 ease-out',
|
||||
'hover:bg-surface-high',
|
||||
)}
|
||||
>
|
||||
{/* Ghosted numeral */}
|
||||
<motion.span
|
||||
variants={numeralVariants}
|
||||
aria-hidden="true"
|
||||
className="font-serif text-7xl font-light leading-none text-on-surface/[0.06] select-none mb-4 -ml-1"
|
||||
>
|
||||
{numeral}
|
||||
</motion.span>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-serif text-2xl font-semibold text-on-surface mb-5 leading-snug">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Feature list */}
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
{features.map((feature: string) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-start gap-2.5 text-sm text-outline leading-relaxed"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mt-[0.35em] shrink-0 w-1 h-1 rounded-full bg-primary/50"
|
||||
/>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* AI narrative callout */}
|
||||
<motion.div
|
||||
variants={fadeVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="mt-16 flex flex-col items-center gap-4"
|
||||
>
|
||||
{/* Decorative rule */}
|
||||
<motion.span
|
||||
variants={scaleVariants}
|
||||
className="block w-px h-10 bg-primary/30"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<p className="font-serif italic text-xl text-primary-dark text-center max-w-xl leading-relaxed">
|
||||
{t('services.aiNarrative')}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
125
src/components/sections/TrustBar.tsx
Normal file
125
src/components/sections/TrustBar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Compass, Shield, Brain, MapPin } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
revealVariants,
|
||||
staggerContainerWide,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
// Each item's icon scale-bounce on enter
|
||||
const iconBounceVariants = {
|
||||
hidden: { scale: 0.7, opacity: 0 },
|
||||
visible: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: [0.34, 1.56, 0.64, 1] as [number, number, number, number], // spring-like overshoot
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface TrustItem {
|
||||
key: string;
|
||||
Icon: LucideIcon;
|
||||
}
|
||||
|
||||
const ITEMS: TrustItem[] = [
|
||||
{ key: 'customBuilt', Icon: Compass },
|
||||
{ key: 'privateInfra', Icon: Shield },
|
||||
{ key: 'aiPowered', Icon: Brain },
|
||||
{ key: 'rivieraBased', Icon: MapPin },
|
||||
];
|
||||
|
||||
interface TrustCardProps {
|
||||
item: TrustItem;
|
||||
index: number;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
function TrustCard({ item, index, t }: TrustCardProps) {
|
||||
const { Icon, key } = item;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={revealVariants}
|
||||
className={cn(
|
||||
'group flex flex-col items-start gap-4 p-6',
|
||||
'rounded-2xl bg-surface-high shadow-subtle',
|
||||
'transition-shadow duration-300 hover:shadow-card',
|
||||
'cursor-default',
|
||||
)}
|
||||
>
|
||||
{/* Icon with scale-bounce on scroll reveal */}
|
||||
<motion.div
|
||||
variants={iconBounceVariants}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-12 h-12 rounded-xl',
|
||||
'bg-primary/8',
|
||||
'transition-transform duration-300 ease-out',
|
||||
'group-hover:-translate-y-1',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
size={28}
|
||||
className="text-primary transition-colors duration-300"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h3
|
||||
className={cn(
|
||||
'font-semibold text-on-surface text-base leading-snug mb-1',
|
||||
'transition-colors duration-300 group-hover:text-primary-dark',
|
||||
)}
|
||||
>
|
||||
{t(`${key}.title`)}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-outline leading-relaxed">
|
||||
{t(`${key}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrustBar() {
|
||||
const t = useTranslations('trustBar');
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Trust indicators"
|
||||
className="bg-surface-low py-16"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
{/* Stagger wrapper — triggers children revealVariants on scroll */}
|
||||
<motion.div
|
||||
variants={staggerContainerWide}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="grid grid-cols-2 md:grid-cols-4 gap-6 md:gap-8"
|
||||
>
|
||||
{ITEMS.map((item, index) => (
|
||||
<TrustCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
index={index}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
211
src/components/sections/services/AILayer.tsx
Normal file
211
src/components/sections/services/AILayer.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Users, MessageCircle, BarChart3 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
staggerContainer,
|
||||
revealVariants,
|
||||
slideLeftVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AiCapability {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface AILayerProps {
|
||||
capabilities: readonly AiCapability[];
|
||||
}
|
||||
|
||||
// ─── Icon map ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ICON_MAP: Record<string, typeof Users> = {
|
||||
'ai-teammate': Users,
|
||||
'customer-facing-ai': MessageCircle,
|
||||
'data-intelligence': BarChart3,
|
||||
};
|
||||
|
||||
// ─── Animation variants ────────────────────────────────────────────────────────
|
||||
|
||||
const ease = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
const sectionHeadVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.1 },
|
||||
},
|
||||
};
|
||||
|
||||
const cardContainerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.12,
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
hidden: { opacity: 0, y: 28 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease },
|
||||
},
|
||||
};
|
||||
|
||||
const lineVariants = {
|
||||
hidden: { scaleY: 0 },
|
||||
visible: {
|
||||
scaleY: 1,
|
||||
transition: { duration: 0.6, ease, delay: 0.15 },
|
||||
},
|
||||
};
|
||||
|
||||
// ─── AI Capability card ────────────────────────────────────────────────────────
|
||||
|
||||
function AICapabilityCard({ capability }: { capability: AiCapability }) {
|
||||
const Icon = ICON_MAP[capability.id] ?? Users;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
className={cn(
|
||||
'relative flex flex-col gap-4 p-6',
|
||||
'rounded-xl',
|
||||
'bg-white/5 backdrop-blur-glass',
|
||||
'border border-white/10',
|
||||
'transition-colors duration-200 hover:bg-white/[0.08]',
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex items-center justify-center w-10 h-10 rounded-xl"
|
||||
style={{
|
||||
background: 'rgba(91, 164, 217, 0.15)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
className="text-primary-light"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-serif font-semibold text-white text-xl leading-snug">
|
||||
{capability.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'rgba(255,255,255,0.6)' }}>
|
||||
{capability.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AILayer({ capabilities }: AILayerProps) {
|
||||
return (
|
||||
<section
|
||||
id="ai-automation"
|
||||
className="py-24"
|
||||
style={{ backgroundColor: '#1C2B3A' }}
|
||||
aria-labelledby="ai-layer-heading"
|
||||
>
|
||||
<div className="container mx-auto px-6">
|
||||
|
||||
{/* Section header */}
|
||||
<motion.div
|
||||
variants={sectionHeadVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="mb-14 flex flex-col items-center text-center"
|
||||
>
|
||||
{/* Eyebrow */}
|
||||
<motion.span
|
||||
variants={revealVariants}
|
||||
className="label-md text-primary mb-4"
|
||||
>
|
||||
Intelligent Layer
|
||||
</motion.span>
|
||||
|
||||
{/* Heading */}
|
||||
<motion.h2
|
||||
id="ai-layer-heading"
|
||||
variants={revealVariants}
|
||||
className="font-serif font-semibold tracking-headline text-white text-4xl md:text-5xl max-w-2xl leading-[1.1]"
|
||||
>
|
||||
The AI Layer
|
||||
</motion.h2>
|
||||
|
||||
{/* Vertical line */}
|
||||
<motion.div
|
||||
variants={lineVariants}
|
||||
className="origin-top w-px h-8 mt-5 mb-5"
|
||||
style={{ background: 'rgba(91,164,217,0.4)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Subtitle */}
|
||||
<motion.p
|
||||
variants={revealVariants}
|
||||
className="font-serif italic text-xl leading-relaxed max-w-xl"
|
||||
style={{ color: 'rgba(255,255,255,0.75)' }}
|
||||
>
|
||||
We build your ecosystem — then make it intelligent.
|
||||
</motion.p>
|
||||
|
||||
{/* Context paragraph */}
|
||||
<motion.p
|
||||
variants={revealVariants}
|
||||
className="mt-5 text-[0.9375rem] leading-relaxed max-w-2xl"
|
||||
style={{ color: 'rgba(255,255,255,0.5)' }}
|
||||
>
|
||||
AI is not a product we bolt on — it is the connective tissue of
|
||||
every system we build. Once your digital infrastructure is live, we
|
||||
layer language models, automation pipelines, and predictive analytics
|
||||
directly into your workflows, so your team operates with capabilities
|
||||
that were previously reserved for organisations ten times your size.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Capability cards */}
|
||||
<motion.div
|
||||
variants={cardContainerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
{capabilities.map((capability) => (
|
||||
<AICapabilityCard key={capability.id} capability={capability} />
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Bottom note */}
|
||||
<motion.p
|
||||
variants={revealVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="mt-10 text-center text-xs uppercase tracking-widest"
|
||||
style={{ color: 'rgba(255,255,255,0.25)' }}
|
||||
>
|
||||
Compatible with your existing stack — no data ever leaves your infrastructure
|
||||
</motion.p>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
202
src/components/sections/services/ServicePillar.tsx
Normal file
202
src/components/sections/services/ServicePillar.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Palette, Globe, ShoppingCart, Zap,
|
||||
Database, Code2, GitBranch, Wrench,
|
||||
Server, Shield, Lock, Settings,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Palette, Globe, ShoppingCart, Zap,
|
||||
Database, Code2, GitBranch, Wrench,
|
||||
Server, Shield, Lock, Settings,
|
||||
};
|
||||
import {
|
||||
revealVariants,
|
||||
staggerContainer,
|
||||
slideLeftVariants,
|
||||
slideRightVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal';
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Feature {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Pillar {
|
||||
id: string;
|
||||
numeral: string;
|
||||
title: string;
|
||||
description: string;
|
||||
background: 'bg-surface' | 'bg-surface-low';
|
||||
features: readonly Feature[];
|
||||
}
|
||||
|
||||
interface ServicePillarProps {
|
||||
pillar: Pillar;
|
||||
index: number;
|
||||
}
|
||||
|
||||
// ─── Animation variants ────────────────────────────────────────────────────────
|
||||
|
||||
const ease = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
const numeralVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { duration: 1, ease },
|
||||
},
|
||||
};
|
||||
|
||||
const featureCardVariants = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.55, ease },
|
||||
},
|
||||
};
|
||||
|
||||
const featureGridVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.09,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Feature card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function FeatureCard({ feature }: { feature: Feature }) {
|
||||
const Icon = ICON_MAP[feature.icon] ?? Globe;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={featureCardVariants}
|
||||
className={cn(
|
||||
'flex flex-col gap-3 p-6',
|
||||
'bg-surface-high rounded-xl',
|
||||
'shadow-card',
|
||||
'transition-shadow duration-200 hover:shadow-subtle',
|
||||
)}
|
||||
>
|
||||
{/* Icon container */}
|
||||
<div
|
||||
className="flex items-center justify-center w-10 h-10 rounded-xl"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(91,164,217,0.12), rgba(0,100,148,0.08))',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
className="text-primary-dark"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="font-semibold text-on-surface text-sm leading-snug">
|
||||
{feature.title}
|
||||
</h4>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-outline leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ServicePillar({ pillar, index }: ServicePillarProps) {
|
||||
const headingVariants = index % 2 === 0 ? slideLeftVariants : slideRightVariants;
|
||||
|
||||
return (
|
||||
<section
|
||||
id={pillar.id}
|
||||
className={cn('py-24', pillar.background)}
|
||||
aria-labelledby={`${pillar.id}-heading`}
|
||||
>
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-start">
|
||||
|
||||
{/* ── Left: Header copy (5 cols) ─────────────────────────────────── */}
|
||||
<div className="lg:col-span-5 flex flex-col gap-6">
|
||||
|
||||
{/* Ghosted numeral */}
|
||||
<motion.span
|
||||
variants={numeralVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'font-serif leading-none select-none',
|
||||
'text-[6rem] md:text-[7.5rem] font-light',
|
||||
'text-on-surface/[0.05]',
|
||||
'-ml-1 -mb-6',
|
||||
)}
|
||||
>
|
||||
{pillar.numeral}
|
||||
</motion.span>
|
||||
|
||||
{/* Heading */}
|
||||
<motion.h2
|
||||
id={`${pillar.id}-heading`}
|
||||
variants={headingVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="font-serif font-semibold tracking-headline text-on-surface text-4xl md:text-[2.75rem] leading-[1.1]"
|
||||
>
|
||||
{pillar.title}
|
||||
</motion.h2>
|
||||
|
||||
{/* Accent rule */}
|
||||
<ScrollReveal variant="fadeIn" delay={0.15}>
|
||||
<div
|
||||
className="w-12 h-0.5 rounded-full bg-gradient-cta"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Description */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.2}>
|
||||
<p className="text-outline leading-relaxed text-[0.9375rem]">
|
||||
{pillar.description}
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
{/* ── Right: Feature grid (7 cols) ───────────────────────────────── */}
|
||||
<motion.div
|
||||
variants={featureGridVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="lg:col-span-7 grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||
>
|
||||
{pillar.features.map((feature) => (
|
||||
<FeatureCard key={feature.title} feature={feature} />
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
146
src/components/sections/services/ServicesCTA.tsx
Normal file
146
src/components/sections/services/ServicesCTA.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
staggerContainer,
|
||||
revealVariants,
|
||||
scaleVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import Button from '@/components/ui/Button';
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal';
|
||||
|
||||
// ─── Animation variants ────────────────────────────────────────────────────────
|
||||
|
||||
const ease = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
const decorLineVariants = {
|
||||
hidden: { scaleX: 0 },
|
||||
visible: {
|
||||
scaleX: 1,
|
||||
transition: { duration: 0.7, ease },
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ServicesCTA() {
|
||||
return (
|
||||
<section
|
||||
className="bg-surface-low py-24"
|
||||
aria-label="Call to action"
|
||||
>
|
||||
<div className="container mx-auto px-6">
|
||||
|
||||
{/* Outer wrapper with subtle rim */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-xl bg-surface-high',
|
||||
'shadow-subtle',
|
||||
)}
|
||||
style={{
|
||||
boxShadow: 'inset 0 0 0 1px rgba(194,199,206,0.3), 0 20px 40px rgba(25,28,29,0.06)',
|
||||
}}
|
||||
>
|
||||
{/* Background geometry — purely decorative */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 overflow-hidden rounded-xl pointer-events-none"
|
||||
>
|
||||
{/* Primary radial glow */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '60%',
|
||||
height: '200%',
|
||||
top: '-50%',
|
||||
right: '-10%',
|
||||
background:
|
||||
'radial-gradient(ellipse, rgba(91,164,217,0.07) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
{/* Secondary glow */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '40%',
|
||||
height: '160%',
|
||||
bottom: '-60%',
|
||||
left: '-5%',
|
||||
background:
|
||||
'radial-gradient(ellipse, rgba(0,100,148,0.05) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
{/* Dot grid */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.025]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, var(--color-primary-dark) 1px, transparent 1px)',
|
||||
backgroundSize: '28px 28px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative px-8 py-16 md:px-16 md:py-20 flex flex-col items-center text-center gap-6">
|
||||
|
||||
{/* Eyebrow */}
|
||||
<ScrollReveal variant="fadeUp">
|
||||
<span className="label-md text-primary">
|
||||
Let's Talk
|
||||
</span>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Heading */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.08}>
|
||||
<h2 className="font-serif font-semibold tracking-headline text-on-surface text-4xl md:text-5xl max-w-2xl leading-[1.1]">
|
||||
Ready to scope your project?
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Subtitle */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.16}>
|
||||
<p className="text-lg text-outline leading-relaxed max-w-xl">
|
||||
Use our interactive configurator to define your requirements, select
|
||||
your services, and generate a personalised project brief — no
|
||||
commitment required, just clarity.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* CTA buttons */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.24}>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3 mt-2">
|
||||
<Button
|
||||
href="/#configure"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
arrow
|
||||
>
|
||||
Configure Your Project
|
||||
</Button>
|
||||
<Button
|
||||
href="mailto:hello@letsbe.biz"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
>
|
||||
hello@letsbe.biz
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Reassurance */}
|
||||
<ScrollReveal variant="fadeIn" delay={0.3}>
|
||||
<p className="text-sm text-outline/60 mt-1">
|
||||
No commitment required — just a conversation about what's possible.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
97
src/components/sections/services/ServicesHero.tsx
Normal file
97
src/components/sections/services/ServicesHero.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { staggerContainer, revealVariants, viewportOnce } from '@/lib/animations';
|
||||
|
||||
// ─── Animation variants ────────────────────────────────────────────────────────
|
||||
|
||||
const ease = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
const eyebrowVariants = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5, ease },
|
||||
},
|
||||
};
|
||||
|
||||
const headlineVariants = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.65, ease, delay: 0.1 },
|
||||
},
|
||||
};
|
||||
|
||||
const subtitleVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease, delay: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
const ruleVariants = {
|
||||
hidden: { scaleX: 0 },
|
||||
visible: {
|
||||
scaleX: 1,
|
||||
transition: { duration: 0.7, ease, delay: 0.35 },
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ServicesHero() {
|
||||
return (
|
||||
<section
|
||||
className="bg-surface pt-32 pb-20"
|
||||
aria-label="Services hero"
|
||||
>
|
||||
<div className="container mx-auto px-6">
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="flex flex-col items-center text-center"
|
||||
>
|
||||
{/* Eyebrow */}
|
||||
<motion.span
|
||||
variants={eyebrowVariants}
|
||||
className="label-md text-primary mb-5"
|
||||
>
|
||||
Our Capabilities
|
||||
</motion.span>
|
||||
|
||||
{/* Headline */}
|
||||
<motion.h1
|
||||
variants={headlineVariants}
|
||||
className="font-serif font-semibold tracking-headline text-on-surface text-5xl md:text-6xl lg:text-7xl max-w-4xl leading-[1.05]"
|
||||
>
|
||||
Three Pillars of{' '}
|
||||
<span className="text-gradient">Digital Excellence</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<motion.p
|
||||
variants={subtitleVariants}
|
||||
className="mt-6 text-lg text-outline leading-relaxed max-w-2xl"
|
||||
>
|
||||
We design, build, and operate complete digital ecosystems — from the
|
||||
first pixel to the server rack. Every discipline under one roof,
|
||||
every deliverable built to a standard most agencies never attempt.
|
||||
</motion.p>
|
||||
|
||||
{/* Decorative rule */}
|
||||
<motion.div
|
||||
variants={ruleVariants}
|
||||
className="mt-10 origin-left w-16 h-px bg-gradient-cta"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
141
src/components/ui/Button.tsx
Normal file
141
src/components/ui/Button.tsx
Normal 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 };
|
||||
53
src/components/ui/Card.tsx
Normal file
53
src/components/ui/Card.tsx
Normal 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 };
|
||||
84
src/components/ui/Chip.tsx
Normal file
84
src/components/ui/Chip.tsx
Normal 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 };
|
||||
82
src/components/ui/ScrollReveal.tsx
Normal file
82
src/components/ui/ScrollReveal.tsx
Normal 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 };
|
||||
73
src/components/ui/SectionHeader.tsx
Normal file
73
src/components/ui/SectionHeader.tsx
Normal 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
4
src/i18n/config.ts
Normal 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
146
src/i18n/messages/en.json
Normal 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
146
src/i18n/messages/fr.json
Normal 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
5
src/i18n/navigation.ts
Normal 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
15
src/i18n/request.ts
Normal 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
8
src/i18n/routing.ts
Normal 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
115
src/lib/animations.ts
Normal 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
5
src/lib/utils.ts
Normal 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
8
src/middleware.ts
Normal 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).*)'],
|
||||
}
|
||||
20
src/payload/collections/Media.ts
Normal file
20
src/payload/collections/Media.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
80
src/payload/collections/Projects.ts
Normal file
80
src/payload/collections/Projects.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
49
src/payload/collections/Services.ts
Normal file
49
src/payload/collections/Services.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
87
src/payload/collections/Submissions.ts
Normal file
87
src/payload/collections/Submissions.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
15
src/payload/collections/Users.ts
Normal file
15
src/payload/collections/Users.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
44
src/payload/payload.config.ts
Normal file
44
src/payload/payload.config.ts
Normal 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
62
src/styles/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
54
tailwind.config.ts
Normal file
54
tailwind.config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#5BA4D9',
|
||||
dark: '#006494',
|
||||
light: '#8EC5E8',
|
||||
},
|
||||
navy: '#1C2B3A',
|
||||
teal: '#2EC4A0',
|
||||
surface: {
|
||||
DEFAULT: '#f8f9fa',
|
||||
low: '#f3f4f5',
|
||||
high: '#ffffff',
|
||||
},
|
||||
'on-surface': '#191c1d',
|
||||
outline: {
|
||||
DEFAULT: '#72787e',
|
||||
variant: '#c2c7ce',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
serif: ['Cormorant Garamond', 'Georgia', 'serif'],
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
letterSpacing: {
|
||||
headline: '-0.02em',
|
||||
label: '0.1em',
|
||||
},
|
||||
borderRadius: {
|
||||
xl: '0.75rem',
|
||||
},
|
||||
boxShadow: {
|
||||
subtle: '0 20px 40px rgba(25, 28, 29, 0.06)',
|
||||
card: '0 4px 16px rgba(25, 28, 29, 0.04)',
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-cta': 'linear-gradient(135deg, #006494, #5BA4D9)',
|
||||
},
|
||||
backdropBlur: {
|
||||
glass: '20px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
export default config
|
||||
44
tsconfig.json
Normal file
44
tsconfig.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@payload-config": [
|
||||
"./src/payload/payload.config.ts"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user