feat: configurator overhaul — full i18n, AI brief generation, redesigned UI
Configurator:
- Full i18n: industries, timelines, AI types, field labels, error messages,
complete screen — all translated to French
- Real AI brief generation via OpenRouter (DeepSeek V3.2) with fallback template
- Fixed email notification bug (was sending to client instead of admin)
- Added wizard reset capability ("Start Over" button on success screen)
- Redesigned section shell with refined card, accent line, step indicators
- All step components use translation keys instead of hardcoded strings
Hero:
- Tighter spacing to keep CTAs above the fold
- Reduced bottom gradient height
Footer:
- Removed GitHub link
- Legal name in copyright
- "American-founded" location text
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,28 +2,25 @@
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar, Mail } from 'lucide-react';
|
||||
import { Calendar, Mail, RotateCcw } from 'lucide-react';
|
||||
import AnimatedCheckmark from '@/components/icons/AnimatedCheckmark';
|
||||
import Button from '@/components/ui/Button';
|
||||
import CalButton from '@/components/ui/CalButton';
|
||||
import type { WizardFormData } from './WizardContainer';
|
||||
|
||||
// ─── Brief Renderer ───────────────────────────────────────────────────────────
|
||||
// ─── 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">
|
||||
@@ -38,16 +35,13 @@ function renderBrief(brief: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// 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' : ''}>
|
||||
@@ -68,37 +62,15 @@ function renderBrief(brief: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Cal.com Embed / Booking ──────────────────────────────────────────────────
|
||||
|
||||
function BookingSection() {
|
||||
return (
|
||||
<div className="rounded-xl 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>
|
||||
<CalButton
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-medium text-white transition-all hover:-translate-y-px active:translate-y-0"
|
||||
style={{ background: 'linear-gradient(135deg, #006494, #5BA4D9)' }}
|
||||
>
|
||||
<Calendar size={16} />
|
||||
Book a Call
|
||||
</CalButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
interface StepCompleteProps {
|
||||
formData: WizardFormData;
|
||||
brief: string;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export default function StepComplete({ formData, brief }: StepCompleteProps) {
|
||||
export default function StepComplete({ formData, brief, onReset }: StepCompleteProps) {
|
||||
const t = useTranslations('configurator');
|
||||
|
||||
const displayEmail = formData.email || 'your inbox';
|
||||
@@ -106,9 +78,7 @@ export default function StepComplete({ formData, brief }: StepCompleteProps) {
|
||||
const containerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.12,
|
||||
},
|
||||
transition: { staggerChildren: 0.12 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -151,7 +121,7 @@ export default function StepComplete({ formData, brief }: StepCompleteProps) {
|
||||
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
|
||||
{t('complete.briefPreview')}
|
||||
</p>
|
||||
<div className="space-y-1 max-h-72 overflow-y-auto pr-1 scrollbar-thin">
|
||||
{renderBrief(brief)}
|
||||
@@ -161,16 +131,32 @@ export default function StepComplete({ formData, brief }: StepCompleteProps) {
|
||||
|
||||
{/* Booking */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<p className="text-xs font-semibold uppercase tracking-label text-outline mb-3">
|
||||
{t('complete.bookTitle')}
|
||||
</p>
|
||||
<BookingSection />
|
||||
<div className="rounded-xl 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">
|
||||
{t('complete.bookTitle')}
|
||||
</p>
|
||||
<p className="text-xs text-outline mb-4">
|
||||
{t('complete.bookSubtitle')}
|
||||
</p>
|
||||
<CalButton
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-medium text-white transition-all hover:-translate-y-px active:translate-y-0"
|
||||
style={{ background: 'linear-gradient(135deg, #006494, #5BA4D9)' }}
|
||||
>
|
||||
<Calendar size={16} />
|
||||
{t('complete.bookCall')}
|
||||
</CalButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Fallback contact */}
|
||||
<motion.div variants={itemVariants}>
|
||||
{/* Fallback contact + reset */}
|
||||
<motion.div variants={itemVariants} className="flex flex-col gap-3">
|
||||
<p className="text-center text-xs text-outline">
|
||||
Or reach us directly at{' '}
|
||||
{t('complete.reachDirectly')}{' '}
|
||||
<a
|
||||
href="mailto:hello@letsbe.biz"
|
||||
className="text-primary-dark underline underline-offset-2 hover:text-primary transition-colors"
|
||||
@@ -178,6 +164,17 @@ export default function StepComplete({ formData, brief }: StepCompleteProps) {
|
||||
hello@letsbe.biz
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{onReset && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="flex items-center justify-center gap-1.5 text-xs text-outline hover:text-on-surface transition-colors mx-auto"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
{t('startOver')}
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -7,51 +7,8 @@ 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,
|
||||
@@ -61,7 +18,16 @@ function InputField({
|
||||
placeholder,
|
||||
required,
|
||||
autoComplete,
|
||||
}: InputFieldProps) {
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
autoComplete?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
@@ -90,12 +56,7 @@ function InputField({
|
||||
);
|
||||
}
|
||||
|
||||
interface SummaryTagProps {
|
||||
label: string;
|
||||
variant?: 'primary' | 'neutral';
|
||||
}
|
||||
|
||||
function SummaryTag({ label, variant = 'neutral' }: SummaryTagProps) {
|
||||
function SummaryTag({ label, variant = 'neutral' }: { label: string; variant?: 'primary' | 'neutral' }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -110,8 +71,6 @@ function SummaryTag({ label, variant = 'neutral' }: SummaryTagProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Loading Dots ─────────────────────────────────────────────────────────────
|
||||
|
||||
function LoadingDots() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1" aria-label="Generating brief">
|
||||
@@ -132,14 +91,14 @@ function LoadingDots() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Extended StepContact Props ───────────────────────────────────────────────
|
||||
// ─── Extended Props ───────────────────────────────────────────────────────────
|
||||
|
||||
interface StepContactProps extends StepProps {
|
||||
isSubmitting: boolean;
|
||||
submitError: string | null;
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function StepContact({
|
||||
formData,
|
||||
@@ -157,30 +116,40 @@ export default function StepContact({
|
||||
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;
|
||||
// Build summary tags using translation keys
|
||||
const serviceTags = formData.services.map((id) => ({
|
||||
label: t(`services.${id}.title`),
|
||||
variant: 'primary' as const,
|
||||
}));
|
||||
|
||||
const aiTag = formData.aiEnabled
|
||||
? {
|
||||
label: formData.aiType
|
||||
? t(`aiTypes.${formData.aiType}.title`)
|
||||
: t('summary.aiEnhancement'),
|
||||
variant: 'primary' as const,
|
||||
}
|
||||
: null;
|
||||
|
||||
const industryTag = formData.industry
|
||||
? { label: t(`industries.${formData.industry}`), variant: 'neutral' as const }
|
||||
: null;
|
||||
|
||||
const timelineTag = formData.timeline
|
||||
? { label: t(`timelines.${formData.timeline}`), variant: 'neutral' as const }
|
||||
: 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 }] : []),
|
||||
...serviceTags,
|
||||
...(aiTag ? [aiTag] : []),
|
||||
...(industryTag ? [industryTag] : []),
|
||||
...(timelineTag ? [timelineTag] : []),
|
||||
];
|
||||
|
||||
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')}
|
||||
@@ -192,7 +161,7 @@ export default function StepContact({
|
||||
{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
|
||||
{t('summary.heading')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{allTags.map((tag, i) => (
|
||||
@@ -213,28 +182,25 @@ export default function StepContact({
|
||||
<div className="flex flex-col gap-4">
|
||||
<InputField
|
||||
id="contact-name"
|
||||
label="Your name"
|
||||
label={t('fields.name')}
|
||||
value={formData.name}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, name: v }))}
|
||||
placeholder="Sophie Laurent"
|
||||
required
|
||||
autoComplete="name"
|
||||
/>
|
||||
<InputField
|
||||
id="contact-company"
|
||||
label="Company"
|
||||
label={t('fields.company')}
|
||||
value={formData.company}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, company: v }))}
|
||||
placeholder="Maison Laurent Group"
|
||||
autoComplete="organization"
|
||||
/>
|
||||
<InputField
|
||||
id="contact-email"
|
||||
label="Email address"
|
||||
label={t('fields.email')}
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, email: v }))}
|
||||
placeholder="sophie@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
@@ -273,7 +239,7 @@ export default function StepContact({
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
Generating
|
||||
{t('generating')}
|
||||
<LoadingDots />
|
||||
</span>
|
||||
) : (
|
||||
@@ -283,7 +249,7 @@ export default function StepContact({
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-outline">
|
||||
Your information is private and will never be shared.
|
||||
{t('privacy')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,32 +10,8 @@ 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' },
|
||||
];
|
||||
const INDUSTRY_IDS = ['maritime', 'hospitality', 'technology', 'realestate', 'finance', 'ngo', 'other'] as const;
|
||||
const TIMELINE_IDS = ['asap', '1-3months', '3-6months', 'exploring'] as const;
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -56,14 +32,10 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
|
||||
}));
|
||||
};
|
||||
|
||||
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')}
|
||||
@@ -74,12 +46,12 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
|
||||
{/* Industry */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-label text-outline">
|
||||
Your industry
|
||||
{t('fields.industry')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{INDUSTRIES.map((option, index) => (
|
||||
{INDUSTRY_IDS.map((id, index) => (
|
||||
<motion.div
|
||||
key={option.id}
|
||||
key={id}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
@@ -89,10 +61,10 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
active={formData.industry === option.id}
|
||||
onClick={() => selectIndustry(option.id)}
|
||||
active={formData.industry === id}
|
||||
onClick={() => selectIndustry(id)}
|
||||
>
|
||||
{option.label}
|
||||
{t(`industries.${id}`)}
|
||||
</Chip>
|
||||
</motion.div>
|
||||
))}
|
||||
@@ -105,8 +77,10 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
|
||||
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>
|
||||
{t('fields.scope')}
|
||||
<span className="ml-1.5 normal-case font-normal text-outline/70">
|
||||
{t('fields.scopeOptional')}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="scope-textarea"
|
||||
@@ -114,7 +88,7 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
|
||||
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…"
|
||||
placeholder={t('fields.scopePlaceholder')}
|
||||
rows={4}
|
||||
className={cn(
|
||||
'w-full resize-none rounded-xl border border-outline-variant/60 bg-surface-high',
|
||||
@@ -129,12 +103,12 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
|
||||
{/* Timeline */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-label text-outline">
|
||||
Timeline
|
||||
{t('fields.timeline')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TIMELINES.map((option, index) => (
|
||||
{TIMELINE_IDS.map((id, index) => (
|
||||
<motion.div
|
||||
key={option.id}
|
||||
key={id}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
@@ -144,10 +118,10 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
active={formData.timeline === option.id}
|
||||
onClick={() => selectTimeline(option.id)}
|
||||
active={formData.timeline === id}
|
||||
onClick={() => selectTimeline(id)}
|
||||
>
|
||||
{option.label}
|
||||
{t(`timelines.${id}`)}
|
||||
</Chip>
|
||||
</motion.div>
|
||||
))}
|
||||
@@ -162,7 +136,6 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
|
||||
<Button
|
||||
variant="primary"
|
||||
arrow
|
||||
disabled={!canProceed}
|
||||
onClick={onNext}
|
||||
className="flex-1"
|
||||
>
|
||||
|
||||
@@ -20,95 +20,55 @@ interface ServiceOption {
|
||||
}
|
||||
|
||||
const SERVICES: ServiceOption[] = [
|
||||
{
|
||||
id: 'web',
|
||||
icon: Globe,
|
||||
titleKey: 'services.web.title',
|
||||
descriptionKey: 'services.web.description',
|
||||
},
|
||||
{
|
||||
id: 'systems',
|
||||
icon: Cog,
|
||||
titleKey: 'services.systems.title',
|
||||
descriptionKey: 'services.systems.description',
|
||||
},
|
||||
{
|
||||
id: 'infrastructure',
|
||||
icon: Server,
|
||||
titleKey: 'services.infrastructure.title',
|
||||
descriptionKey: 'services.infrastructure.description',
|
||||
},
|
||||
{ id: 'web', icon: Globe, titleKey: 'services.web.title', descriptionKey: 'services.web.description' },
|
||||
{ id: 'systems', icon: Cog, titleKey: 'services.systems.title', descriptionKey: 'services.systems.description' },
|
||||
{ id: 'infrastructure', icon: Server, titleKey: 'services.infrastructure.title', descriptionKey: 'services.infrastructure.description' },
|
||||
];
|
||||
|
||||
interface AITypeOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
const AI_TYPE_IDS = ['teammate', 'customer-facing', 'data-intelligence', 'notsure'] as const;
|
||||
|
||||
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.',
|
||||
},
|
||||
];
|
||||
// ─── Service Card ────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
interface ServiceCardProps {
|
||||
function ServiceCard({
|
||||
option,
|
||||
selected,
|
||||
onToggle,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
option: ServiceOption;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function ServiceCard({ option, selected, onToggle, title, description }: ServiceCardProps) {
|
||||
}) {
|
||||
const Icon = option.icon;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
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',
|
||||
? 'bg-primary/6 border-2 border-primary/30 shadow-card'
|
||||
: 'bg-surface-high border-2 border-transparent shadow-subtle hover:shadow-card hover:border-outline-variant/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center transition-colors duration-200',
|
||||
'flex-shrink-0 w-11 h-11 rounded-xl flex items-center justify-center transition-colors duration-200',
|
||||
selected
|
||||
? 'bg-primary/15 text-primary-dark'
|
||||
: 'bg-primary/8 text-primary group-hover:bg-primary/15 group-hover:text-primary-dark',
|
||||
: 'bg-primary/8 text-primary group-hover:bg-primary/12',
|
||||
)}
|
||||
>
|
||||
<Icon size={20} strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
@@ -121,7 +81,6 @@ function ServiceCard({ option, selected, onToggle, title, description }: Service
|
||||
<p className="text-xs text-outline leading-relaxed">{description}</p>
|
||||
</div>
|
||||
|
||||
{/* Checkbox */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<motion.div
|
||||
className={cn(
|
||||
@@ -154,16 +113,19 @@ function ServiceCard({ option, selected, onToggle, title, description }: Service
|
||||
);
|
||||
}
|
||||
|
||||
// ─── AI Toggle ─────────────────────────────────────────────────────────────
|
||||
// ─── AI Toggle ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface AIToggleProps {
|
||||
function AIToggle({
|
||||
enabled,
|
||||
onToggle,
|
||||
label,
|
||||
description,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function AIToggle({ enabled, onToggle, label, description }: AIToggleProps) {
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -195,11 +157,8 @@ function AIToggle({ enabled, onToggle, label, description }: AIToggleProps) {
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
<p className="text-xs text-outline mt-0.5">
|
||||
{description}
|
||||
</p>
|
||||
<p className="text-xs text-outline mt-0.5">{description}</p>
|
||||
</div>
|
||||
{/* Switch */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0 w-10 h-6 rounded-full relative transition-colors duration-300',
|
||||
@@ -216,7 +175,7 @@ function AIToggle({ enabled, onToggle, label, description }: AIToggleProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function StepServices({ formData, setFormData, onNext }: StepProps) {
|
||||
const t = useTranslations('configurator');
|
||||
@@ -247,14 +206,10 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
|
||||
|
||||
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')}
|
||||
@@ -263,7 +218,7 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
|
||||
</div>
|
||||
|
||||
{/* Service cards */}
|
||||
<div className="flex flex-col gap-0 divide-y divide-outline-variant/10">
|
||||
<div className="flex flex-col gap-3">
|
||||
{SERVICES.map((option) => (
|
||||
<ServiceCard
|
||||
key={option.id}
|
||||
@@ -274,7 +229,6 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
|
||||
description={t(option.descriptionKey)}
|
||||
/>
|
||||
))}
|
||||
{/* Empty-state hint */}
|
||||
<AnimatePresence>
|
||||
{formData.services.length === 0 && (
|
||||
<motion.p
|
||||
@@ -282,7 +236,7 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="text-xs text-outline/60 text-center pt-1 pb-0.5 select-none"
|
||||
className="text-xs text-outline/60 text-center pt-1 select-none"
|
||||
>
|
||||
{t('selectService')}
|
||||
</motion.p>
|
||||
@@ -290,7 +244,7 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* AI Toggle */}
|
||||
{/* AI Toggle + Type Selection */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<AIToggle
|
||||
enabled={formData.aiEnabled}
|
||||
@@ -299,7 +253,6 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
|
||||
description={t('aiDescription')}
|
||||
/>
|
||||
|
||||
{/* AI type chips — stagger in */}
|
||||
<AnimatePresence>
|
||||
{formData.aiEnabled && (
|
||||
<motion.div
|
||||
@@ -310,11 +263,10 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
|
||||
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) => (
|
||||
{AI_TYPE_IDS.map((aiId, index) => (
|
||||
<motion.div
|
||||
key={aiOption.id}
|
||||
key={aiId}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
@@ -324,27 +276,26 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
active={formData.aiType === aiOption.id}
|
||||
onClick={() => selectAIType(aiOption.id)}
|
||||
active={formData.aiType === aiId}
|
||||
onClick={() => selectAIType(aiId)}
|
||||
>
|
||||
{aiOption.label}
|
||||
{t(`aiTypes.${aiId}.title`)}
|
||||
</Chip>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AI type description */}
|
||||
<AnimatePresence mode="wait">
|
||||
{selectedAIType && (
|
||||
{formData.aiType && (
|
||||
<motion.p
|
||||
key={selectedAIType.id}
|
||||
key={formData.aiType}
|
||||
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}
|
||||
{t(`aiTypes.${formData.aiType}.description`)}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -354,7 +305,6 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<Button
|
||||
variant="primary"
|
||||
arrow
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import StepServices from './StepServices';
|
||||
import StepDetails from './StepDetails';
|
||||
@@ -68,6 +69,7 @@ const DEFAULT_FORM_DATA: WizardFormData = {
|
||||
};
|
||||
|
||||
export default function WizardContainer() {
|
||||
const t = useTranslations('configurator');
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2 | 3 | 4>(1);
|
||||
const [direction, setDirection] = useState<1 | -1>(1);
|
||||
const [formData, setFormData] = useState<WizardFormData>(DEFAULT_FORM_DATA);
|
||||
@@ -85,6 +87,14 @@ export default function WizardContainer() {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1) as 1 | 2 | 3 | 4);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setDirection(-1);
|
||||
setFormData(DEFAULT_FORM_DATA);
|
||||
setBrief('');
|
||||
setSubmitError(null);
|
||||
setCurrentStep(1);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
@@ -98,7 +108,7 @@ export default function WizardContainer() {
|
||||
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.');
|
||||
setSubmitError(data.error ?? t('errors.general'));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -107,7 +117,7 @@ export default function WizardContainer() {
|
||||
setDirection(1);
|
||||
setCurrentStep(4);
|
||||
} catch {
|
||||
setSubmitError('Network error. Please check your connection and try again.');
|
||||
setSubmitError(t('errors.network'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -174,7 +184,11 @@ export default function WizardContainer() {
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
>
|
||||
<StepComplete formData={formData} brief={brief} />
|
||||
<StepComplete
|
||||
formData={formData}
|
||||
brief={brief}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -6,20 +6,20 @@ import { ShieldCheck } from 'lucide-react';
|
||||
import { revealVariants, staggerContainer, viewportOnce } from '@/lib/animations';
|
||||
import WizardContainer from '@/components/configurator/WizardContainer';
|
||||
|
||||
// ─── Step indicator dot ───────────────────────────────────────────────────────
|
||||
// ─── Step indicator ──────────────────────────────────────────────────────────
|
||||
|
||||
interface StepDotProps {
|
||||
index: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function StepDot({ index, label }: StepDotProps) {
|
||||
function StepIndicator({ index, label, isLast }: { index: number; label: string; isLast: boolean }) {
|
||||
return (
|
||||
<motion.div variants={revealVariants} className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-semibold text-primary-dark leading-none">{index}</span>
|
||||
<motion.div variants={revealVariants} className="flex items-start gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-7 h-7 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-semibold text-primary-dark leading-none">{index}</span>
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className="w-px h-5 bg-primary/15 mt-1" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-outline">{label}</span>
|
||||
<span className="text-sm text-outline pt-1">{label}</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -36,11 +36,20 @@ export default function Configurator() {
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="configure" className="bg-surface-low py-20">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="grid grid-cols-1 gap-12 lg:grid-cols-12">
|
||||
<section id="configure" className="relative bg-surface py-24 overflow-hidden">
|
||||
{/* Subtle diagonal accent line */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-px pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent 10%, rgba(91,164,217,0.15) 50%, transparent 90%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* ── Left: Sticky context panel ─────────────────────────────── */}
|
||||
<div className="relative z-10 container mx-auto px-6">
|
||||
<div className="grid grid-cols-1 gap-12 lg:grid-cols-12 lg:gap-16 items-start">
|
||||
|
||||
{/* ── Left: Context panel ──────────────────────────────────────── */}
|
||||
<div className="lg:col-span-5">
|
||||
<div className="lg:sticky lg:top-24">
|
||||
<motion.div
|
||||
@@ -50,7 +59,6 @@ export default function Configurator() {
|
||||
viewport={viewportOnce}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{/* Eyebrow */}
|
||||
<motion.span
|
||||
variants={revealVariants}
|
||||
className="label-md text-primary"
|
||||
@@ -58,7 +66,6 @@ export default function Configurator() {
|
||||
{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"
|
||||
@@ -66,7 +73,6 @@ export default function Configurator() {
|
||||
{t('title')}
|
||||
</motion.h2>
|
||||
|
||||
{/* Description */}
|
||||
<motion.p
|
||||
variants={revealVariants}
|
||||
className="text-base text-outline leading-relaxed max-w-sm"
|
||||
@@ -74,50 +80,79 @@ export default function Configurator() {
|
||||
{t('description')}
|
||||
</motion.p>
|
||||
|
||||
{/* Divider */}
|
||||
<motion.div
|
||||
variants={revealVariants}
|
||||
className="w-12 h-px bg-outline-variant/40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Step indicators */}
|
||||
<motion.div
|
||||
variants={revealVariants}
|
||||
className="flex flex-col gap-3 pt-2"
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-label text-outline/70">
|
||||
<p className="text-xs font-semibold uppercase tracking-label text-outline/60 mb-3">
|
||||
{t('howItWorks')}
|
||||
</p>
|
||||
{/* Vertical accent line + steps */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-px bg-primary/20 ml-3 rounded-full" aria-hidden="true" />
|
||||
<div className="flex flex-col gap-2.5 flex-1">
|
||||
{steps.map((step, i) => (
|
||||
<StepDot key={i} index={i + 1} label={step} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{steps.map((step, i) => (
|
||||
<StepIndicator
|
||||
key={i}
|
||||
index={i + 1}
|
||||
label={step}
|
||||
isLast={i === steps.length - 1}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Trust signal */}
|
||||
<motion.div
|
||||
variants={revealVariants}
|
||||
className="pt-1 flex items-center gap-2"
|
||||
className="flex items-center gap-2 pt-2"
|
||||
>
|
||||
<ShieldCheck size={14} strokeWidth={1.75} className="text-primary flex-shrink-0" aria-hidden="true" />
|
||||
<ShieldCheck
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
className="text-primary flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-xs text-outline">{t('noCommitment')}</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: Wizard ───────────────────────────────────────────── */}
|
||||
{/* ── Right: Wizard card ────────────────────────────────────────── */}
|
||||
<div className="lg:col-span-7">
|
||||
<div className="relative rounded-2xl bg-surface-high shadow-subtle p-6 sm:p-8 overflow-hidden">
|
||||
{/* Radial gradient glow — top-left warmth */}
|
||||
<div
|
||||
className="pointer-events-none absolute -top-16 -left-16 w-72 h-72 rounded-full"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(91,164,217,0.07) 0%, transparent 70%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<WizardContainer />
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={viewportOnce}
|
||||
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="relative rounded-2xl bg-surface-high shadow-[0_20px_50px_rgba(25,28,29,0.08)] p-6 sm:p-8 overflow-hidden border border-outline-variant/20">
|
||||
{/* Top-edge accent line */}
|
||||
<div
|
||||
className="absolute top-0 left-6 right-6 h-[2px] rounded-full pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, #006494, #5BA4D9, transparent)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Soft radial glow */}
|
||||
<div
|
||||
className="pointer-events-none absolute -top-20 -left-20 w-80 h-80 rounded-full"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(91,164,217,0.05) 0%, transparent 70%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<WizardContainer />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function Hero() {
|
||||
<div className="relative z-10 w-full max-w-screen-xl mx-auto px-6 lg:px-12 xl:px-16 flex flex-col lg:flex-row lg:items-center min-h-screen">
|
||||
|
||||
{/* ── LEFT COLUMN — text content (55% on desktop) ─────────────── */}
|
||||
<div className="flex-1 lg:max-w-[58%] flex flex-col justify-center pt-24 pb-16 lg:pt-28 lg:pb-0">
|
||||
<div className="flex-1 lg:max-w-[58%] flex flex-col justify-center pt-24 pb-16 lg:pt-20 lg:pb-16">
|
||||
|
||||
{/* Headline — word-by-word stagger */}
|
||||
<motion.h1
|
||||
@@ -160,7 +160,7 @@ export default function Hero() {
|
||||
|
||||
{/* Subtitle */}
|
||||
<motion.p
|
||||
className="text-lg text-outline leading-relaxed max-w-lg mb-8"
|
||||
className="text-lg text-outline leading-relaxed max-w-lg mb-6"
|
||||
variants={subtitleVariant}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
@@ -170,7 +170,7 @@ export default function Hero() {
|
||||
|
||||
{/* Gradient separator — expands from left */}
|
||||
<motion.span
|
||||
className="block w-20 h-px mb-10 origin-left"
|
||||
className="block w-20 h-px mb-6 origin-left"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to right, rgba(0,100,148,0.5), rgba(91,164,217,0.2), transparent)',
|
||||
@@ -341,7 +341,7 @@ export default function Hero() {
|
||||
|
||||
{/* ─── Bottom fade into next section ────────────────────────────── */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-40 pointer-events-none z-10"
|
||||
className="absolute bottom-0 left-0 right-0 h-32 pointer-events-none z-10"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, transparent, #f3f4f5)',
|
||||
|
||||
Reference in New Issue
Block a user