Spaces:
Running
Running
| 'use client'; | |
| import * as React from 'react'; | |
| import Link from 'next/link'; | |
| import { motion, AnimatePresence } from 'motion/react'; | |
| import gsap from 'gsap'; | |
| import FloatingChatInput from './FloatingChatInput'; | |
| import MessageBubble from './MessageBubble'; | |
| import { useChatSession } from '@/lib/use-chat-session'; | |
| export default function HeroSection() { | |
| const bloomRef = React.useRef<HTMLDivElement>(null); | |
| const threadRef = React.useRef<HTMLDivElement>(null); | |
| const outsideTouchYRef = React.useRef<number | null>(null); | |
| // Inline conversation β runs the chat right here on `/` instead of routing | |
| // away to the dedicated /chat page. | |
| const { messages, isTyping, sendMessage, reset } = useChatSession(); | |
| const active = messages.length > 0 || isTyping; | |
| // Follow-up input shown inside the chat view (separate from the big hero input). | |
| const [chatInput, setChatInput] = React.useState(''); | |
| const handleChatSubmit = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| const text = chatInput.trim(); | |
| if (!text || isTyping) return; | |
| setChatInput(''); | |
| sendMessage(text); | |
| }; | |
| // Auto-scroll the *thread container only* (not the window) to the latest | |
| // message. Using scrollIntoView here would scroll the whole page and push | |
| // the header off-screen. | |
| React.useEffect(() => { | |
| const el = threadRef.current; | |
| if (active && el) { | |
| el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); | |
| } | |
| }, [messages, isTyping, active]); | |
| const handleChatWheel = React.useCallback( | |
| (e: React.WheelEvent<HTMLElement>) => { | |
| const el = threadRef.current; | |
| if (!active || !el || el.contains(e.target as Node)) return; | |
| if (el.scrollHeight <= el.clientHeight) return; | |
| el.scrollTop += e.deltaY; | |
| e.preventDefault(); | |
| }, | |
| [active] | |
| ); | |
| const handleChatTouchStart = React.useCallback( | |
| (e: React.TouchEvent<HTMLElement>) => { | |
| const el = threadRef.current; | |
| if (!active || !el || el.contains(e.target as Node)) { | |
| outsideTouchYRef.current = null; | |
| return; | |
| } | |
| outsideTouchYRef.current = e.touches[0]?.clientY ?? null; | |
| }, | |
| [active] | |
| ); | |
| const handleChatTouchMove = React.useCallback((e: React.TouchEvent<HTMLElement>) => { | |
| const el = threadRef.current; | |
| const previousY = outsideTouchYRef.current; | |
| const currentY = e.touches[0]?.clientY; | |
| if (!el || previousY === null || currentY === undefined) return; | |
| if (el.scrollHeight <= el.clientHeight) return; | |
| el.scrollTop += previousY - currentY; | |
| outsideTouchYRef.current = currentY; | |
| e.preventDefault(); | |
| }, []); | |
| // GSAP-driven entrance + breathing on the bloom container. | |
| // The two pseudo-element layers keep drifting via CSS, so this composes on | |
| // top of the liquid effect rather than replacing it. | |
| React.useEffect(() => { | |
| const bloom = bloomRef.current; | |
| if (!bloom) return; | |
| const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; | |
| if (reduced) { | |
| // Skip animation; just reveal it in its resting state. | |
| gsap.set(bloom, { opacity: 1, clearProps: 'transform,filter' }); | |
| return; | |
| } | |
| const ctx = gsap.context(() => { | |
| const tl = gsap.timeline(); | |
| // 1. Cinematic bottom-to-top power-up on load. | |
| tl.fromTo( | |
| bloom, | |
| { | |
| scaleY: 0.1, | |
| scaleX: 0.85, | |
| y: 100, | |
| opacity: 0, | |
| filter: 'blur(20px) brightness(0.4)', | |
| }, | |
| { | |
| scaleY: 1, | |
| scaleX: 1, | |
| y: 0, | |
| opacity: 1, | |
| filter: 'blur(0px) brightness(1)', | |
| duration: 2.4, | |
| ease: 'power4.out', | |
| } | |
| ); | |
| // 2. Organic breathing loop once the entrance settles. | |
| tl.to(bloom, { | |
| scaleY: 1.02, | |
| scaleX: 1.01, | |
| y: -8, | |
| filter: 'blur(0px) brightness(1.04)', | |
| duration: 6, | |
| ease: 'sine.inOut', | |
| repeat: -1, | |
| yoyo: true, | |
| }); | |
| }, bloom); | |
| return () => ctx.revert(); | |
| }, []); | |
| // Drive CSS variables so the gradient's bright core follows the cursor. | |
| // (We set variables, not transforms, so this never fights the GSAP tweens.) | |
| React.useEffect(() => { | |
| const handleMouseMove = (e: MouseEvent) => { | |
| const bloom = bloomRef.current; | |
| if (!bloom) return; | |
| const x = e.clientX / window.innerWidth - 0.5; | |
| const y = e.clientY / window.innerHeight - 0.5; | |
| bloom.style.setProperty('--bloom-mx', String(x)); | |
| bloom.style.setProperty('--bloom-my', String(y)); | |
| }; | |
| window.addEventListener('mousemove', handleMouseMove); | |
| return () => window.removeEventListener('mousemove', handleMouseMove); | |
| }, []); | |
| return ( | |
| <section | |
| id="hero" | |
| onWheel={active ? handleChatWheel : undefined} | |
| onTouchStart={active ? handleChatTouchStart : undefined} | |
| onTouchMove={active ? handleChatTouchMove : undefined} | |
| onTouchEnd={active ? () => { outsideTouchYRef.current = null; } : undefined} | |
| className={`relative min-h-screen flex flex-col items-center overflow-hidden transition-colors duration-700 ${ | |
| active ? 'bg-white' : '' | |
| }`} | |
| > | |
| {/* Radial multi-gradient bloom rising from the bottom-center. | |
| Starts hidden; GSAP fades/sweeps it in (see effect above). | |
| In chat mode it drops lower to sit just above the input. */} | |
| <div | |
| ref={bloomRef} | |
| className={`hero-bloom ${active ? 'is-chatting' : ''}`} | |
| style={{ opacity: 0 }} | |
| /> | |
| {/* White wash behind the headline β only while in landing (idle) mode. */} | |
| {!active && <div className="hero-top-wash" />} | |
| <AnimatePresence mode="wait" initial={false}> | |
| {active ? ( | |
| /* βββββββββββββββ Inline chat view βββββββββββββββ */ | |
| <motion.div | |
| key="chat" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }} | |
| className="relative z-10 w-full max-w-3xl mx-auto flex flex-col h-screen px-4 sm:px-6" | |
| > | |
| {/* Minimalist navbar β controls only, no logo */} | |
| <header className="flex items-center justify-end gap-1.5 py-4 select-none flex-shrink-0"> | |
| {/* New chat β subtle text+icon button */} | |
| <button | |
| onClick={reset} | |
| className="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 transition-colors duration-150 cursor-pointer" | |
| > | |
| <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> | |
| </svg> | |
| New chat | |
| </button> | |
| {/* Admin β subtle outlined pill */} | |
| <Link | |
| href="/admin" | |
| className="inline-flex items-center rounded-full border border-neutral-200 bg-white/60 backdrop-blur px-3.5 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-100 hover:border-neutral-300 transition-all duration-150" | |
| > | |
| Admin | |
| </Link> | |
| </header> | |
| {/* Thread β only this scrolls. The inner wrapper is min-h-full with | |
| justify-end so a few messages rest just above the input, while a | |
| long conversation still scrolls naturally from the top. */} | |
| <div ref={threadRef} className="flex-1 overflow-y-auto no-scrollbar px-1 min-h-0"> | |
| <div className="min-h-full flex flex-col justify-end py-8"> | |
| {messages.map((item) => ( | |
| <MessageBubble key={item.id} message={item} /> | |
| ))} | |
| {/* Thinking indicator β minimal, document-style (no avatar/bubble) */} | |
| {isTyping && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 6 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="w-full mb-10 flex items-center gap-2 text-[15px] text-neutral-400" | |
| > | |
| <span>Searching your knowledge base</span> | |
| <span className="flex items-center gap-1"> | |
| <span className="h-1.5 w-1.5 bg-neutral-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} /> | |
| <span className="h-1.5 w-1.5 bg-neutral-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} /> | |
| <span className="h-1.5 w-1.5 bg-neutral-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} /> | |
| </span> | |
| </motion.div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Follow-up input pinned to the bottom β dark, matching the home input */} | |
| <div className="flex-shrink-0 pb-5 pt-2"> | |
| <form | |
| onSubmit={handleChatSubmit} | |
| className="relative flex items-center gap-2 rounded-full bg-[#262626] border border-white/10 shadow-xl shadow-black/25 pl-5 pr-2 py-2 focus-within:border-white/20 transition-all duration-200" | |
| > | |
| <input | |
| type="text" | |
| value={chatInput} | |
| onChange={(e) => setChatInput(e.target.value)} | |
| disabled={isTyping} | |
| placeholder="Ask a follow-up question..." | |
| className="flex-1 bg-transparent text-[15px] text-white placeholder-white/40 focus:outline-none py-2 disabled:opacity-60" | |
| /> | |
| <button | |
| type="submit" | |
| disabled={!chatInput.trim() || isTyping} | |
| aria-label="Send message" | |
| className={`flex-shrink-0 h-10 w-10 rounded-full flex items-center justify-center transition-all duration-200 ${ | |
| chatInput.trim() && !isTyping | |
| ? 'bg-white text-black hover:scale-105 active:scale-95 cursor-pointer' | |
| : 'bg-white/10 text-white/30 cursor-not-allowed' | |
| }`} | |
| > | |
| <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M12 19V5m0 0l-6 6m6-6l6 6" /> | |
| </svg> | |
| </button> | |
| </form> | |
| <p className="text-[10px] text-white/45 text-center mt-2.5"> | |
| Query Bot prioritizes your custom Q&A over document matches and cites its sources. | |
| </p> | |
| </div> | |
| </motion.div> | |
| ) : ( | |
| /* βββββββββββββββ Landing (idle) view βββββββββββββββ */ | |
| <motion.div | |
| key="hero" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }} | |
| className="relative z-10 w-full flex flex-col items-center" | |
| > | |
| {/* ββ Headline content (sits on the white wash) ββ */} | |
| <div className="w-full flex flex-col items-center pt-28 px-6"> | |
| {/* Large Headline */} | |
| <motion.h1 | |
| initial={{ opacity: 0, y: 15 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }} | |
| className="text-[40px] sm:text-5xl md:text-6xl lg:text-7xl font-sans font-bold tracking-tight text-neutral-900 leading-[1.05] max-w-3xl text-center" | |
| > | |
| Chat with your{' '} | |
| <span className="gif-clipped-word italic select-all">documents</span> | |
| </motion.h1> | |
| {/* Subtitle */} | |
| <motion.p | |
| initial={{ opacity: 0, y: 15 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ duration: 0.8, delay: 0.12, ease: [0.16, 1, 0.3, 1] }} | |
| className="mt-6 text-[15px] sm:text-base md:text-lg text-neutral-500 max-w-2xl font-normal leading-relaxed mx-auto text-center" | |
| > | |
| Upload PDFs, Word docs, and spreadsheets. Add custom Q&A. Get fast, grounded answers with sources. | |
| </motion.p> | |
| {/* Premium Dual CTA Pill Container */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 15 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ duration: 0.8, delay: 0.24, ease: [0.16, 1, 0.3, 1] }} | |
| className="mt-8 flex flex-col items-center gap-3 w-full" | |
| > | |
| <div className="flex items-center justify-between bg-neutral-100 backdrop-blur-md pl-5 pr-1 py-1 rounded-full border border-neutral-200 shadow-xs max-w-md w-full sm:w-auto gap-4"> | |
| <span className="text-xs sm:text-[13px] font-sans font-normal text-neutral-500 tracking-tight text-left"> | |
| Build your knowledge base | |
| </span> | |
| <Link href="/admin" className="flex-shrink-0"> | |
| <button className="bg-neutral-900 hover:bg-neutral-800 text-white font-sans font-semibold text-xs px-4.5 py-2 rounded-full transition-all duration-150 active:scale-95 shadow-2xl cursor-pointer"> | |
| Open Admin | |
| </button> | |
| </Link> | |
| </div> | |
| </motion.div> | |
| </div> | |
| {/* ββ Floating AI Chat Input (sits in the dark, above the bloom) ββ */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 25 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ duration: 0.8, delay: 0.36, ease: [0.16, 1, 0.3, 1] }} | |
| className="relative z-20 w-full max-w-4xl px-6 mt-12" | |
| > | |
| <FloatingChatInput onSubmitText={sendMessage} /> | |
| </motion.div> | |
| {/* Spacer so the bloom has room to breathe below the input */} | |
| <div className="min-h-[18vh]" /> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </section> | |
| ); | |
| } | |