feat: create Discovery section component with voice panel

Standalone landing page section with warm copy, CTA, and expandable
inline voice conversation panel. Shows StepComplete on brief completion.
Only renders if voice support is detected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 14:45:17 -04:00
parent 3cdb95e488
commit 896f0eb5f4

View File

@@ -0,0 +1,162 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { motion, AnimatePresence } from 'framer-motion';
import { MessageCircle } from 'lucide-react';
import { revealVariants, staggerContainer, viewportOnce } from '@/lib/animations';
import VoiceAgentProvider from '@/components/configurator/VoiceAgentProvider';
import VoiceAgent from '@/components/configurator/VoiceAgent';
import StepComplete from '@/components/configurator/StepComplete';
import type { WizardFormData } from '@/components/configurator/WizardContainer';
export default function Discovery() {
const t = useTranslations('discovery');
const locale = useLocale();
const [isOpen, setIsOpen] = useState(false);
const [completed, setCompleted] = useState<{ brief: string; formData: WizardFormData } | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [voiceSupported, setVoiceSupported] = useState(false);
// Check if voice is available (same logic as old ModeToggle)
useEffect(() => {
async function check() {
if (typeof WebSocket === 'undefined') return;
if (!navigator.mediaDevices?.getUserMedia) return;
try {
const res = await fetch('/api/gemini-token');
const data = (await res.json()) as { success: boolean };
if (data.success) setVoiceSupported(true);
} catch {
// silent — section stays hidden
}
}
void check();
}, []);
const handleOpen = () => {
setIsOpen(true);
// Scroll to panel after it renders
requestAnimationFrame(() => {
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
};
const handleComplete = (brief: string, formData: WizardFormData) => {
setCompleted({ brief, formData });
};
const handleReset = () => {
setCompleted(null);
setIsOpen(false);
};
if (!voiceSupported) return null;
return (
<section id="discover" className="relative bg-surface-high py-24 overflow-hidden">
{/* Top 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"
/>
<div className="relative z-10 container mx-auto px-6">
<AnimatePresence mode="wait">
{completed ? (
<motion.div
key="completed"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="max-w-2xl mx-auto"
>
<StepComplete
formData={completed.formData}
brief={completed.brief}
onReset={handleReset}
/>
</motion.div>
) : (
<motion.div
key="discovery"
variants={staggerContainer}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
className="flex flex-col items-center text-center"
>
<motion.span
variants={revealVariants}
className="label-md text-primary"
>
{t('eyebrow')}
</motion.span>
<motion.h2
variants={revealVariants}
className="font-serif text-4xl font-semibold tracking-headline text-on-surface leading-tight md:text-5xl mt-4 max-w-lg"
>
{t('title')}
</motion.h2>
<motion.p
variants={revealVariants}
className="text-base text-outline leading-relaxed max-w-md mt-4"
>
{t('description')}
</motion.p>
{!isOpen && (
<motion.div variants={revealVariants} className="mt-8">
<button
type="button"
onClick={handleOpen}
className="flex items-center gap-2.5 px-7 py-3.5 rounded-xl text-sm font-medium text-white transition-all hover:-translate-y-px active:translate-y-0 shadow-lg shadow-primary/20"
style={{ background: 'linear-gradient(135deg, #006494, #5BA4D9)' }}
>
<MessageCircle size={16} />
{t('cta')}
</button>
<p className="text-[11px] text-outline/60 mt-3">{t('privacy')}</p>
</motion.div>
)}
{/* Voice panel */}
<AnimatePresence>
{isOpen && (
<motion.div
ref={panelRef}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-xl mt-10 overflow-hidden"
>
<div className="relative rounded-2xl bg-surface-high shadow-[0_20px_50px_rgba(25,28,29,0.08)] p-6 sm:p-8 border border-outline-variant/20">
{/* Top 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"
/>
<VoiceAgentProvider locale={locale}>
<VoiceAgent locale={locale} onComplete={handleComplete} />
</VoiceAgentProvider>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</div>
</section>
);
}