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:
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 };
|
||||
Reference in New Issue
Block a user