/** * Landing Page Primitives * * Shared animation and UI primitives used by the landing page shell * and all landing section components. */ import { useEffect, useState, useRef, type ReactNode } from 'react' import { motion, AnimatePresence, useInView } from 'framer-motion' import { ImageIcon, ChevronDown } from 'lucide-react' import { cn } from '@/components/ui' // ============================================================================ // Smooth Typewriter // ============================================================================ function useTypewriter( text: string, options: { baseSpeed?: number; variance?: number; startDelay?: number } = {}, ) { const { baseSpeed = 65, variance = 0.4, startDelay = 300 } = options const [displayedCount, setDisplayedCount] = useState(0) const [isComplete, setIsComplete] = useState(false) const [hasStarted, setHasStarted] = useState(false) useEffect(() => { setDisplayedCount(0) setIsComplete(false) setHasStarted(false) const startTimer = setTimeout(() => setHasStarted(true), startDelay) return () => clearTimeout(startTimer) }, [text, startDelay]) useEffect(() => { if (!hasStarted || displayedCount >= text.length) { if (hasStarted && displayedCount >= text.length) setIsComplete(true) return } const char = text[displayedCount] const nextChar = text[displayedCount + 1] let delay = baseSpeed if (char === ' ') delay = baseSpeed * 0.3 else if ('.!?,;:'.includes(char)) delay = baseSpeed * 2.5 else if (nextChar === ' ' || displayedCount === text.length - 1) delay = baseSpeed * 1.3 const varianceFactor = 1 + (Math.random() - 0.5) * 2 * variance delay *= varianceFactor const timer = setTimeout(() => setDisplayedCount(prev => prev + 1), delay) return () => clearTimeout(timer) }, [hasStarted, displayedCount, text, baseSpeed, variance]) return { displayedText: text.slice(0, displayedCount), isComplete } } export function Typewriter({ text, className, cursorClassName, baseSpeed, variance, startDelay, onComplete, }: { text: string className?: string cursorClassName?: string baseSpeed?: number variance?: number startDelay?: number onComplete?: () => void }) { const { displayedText, isComplete } = useTypewriter(text, { baseSpeed, variance, startDelay }) useEffect(() => { if (isComplete && onComplete) onComplete() }, [isComplete, onComplete]) return ( {displayedText} ) } // ============================================================================ // Scroll-triggered animation primitives // ============================================================================ export function ScrollReveal({ children, className, delay = 0, direction = 'up', }: { children: ReactNode className?: string delay?: number direction?: 'up' | 'down' | 'left' | 'right' }) { const ref = useRef(null) const isInView = useInView(ref, { once: true, margin: '-80px 0px' }) const directionMap = { up: { y: 40, x: 0 }, down: { y: -40, x: 0 }, left: { x: 40, y: 0 }, right: { x: -40, y: 0 }, } const offset = directionMap[direction] return ( {children} ) } export function StaggerContainer({ children, className, staggerDelay = 0.1, }: { children: ReactNode className?: string staggerDelay?: number }) { const ref = useRef(null) const isInView = useInView(ref, { once: true, margin: '-60px 0px' }) return ( {children} ) } export const staggerChild = { hidden: { opacity: 0, y: 30, scale: 0.97 }, visible: { opacity: 1, y: 0, scale: 1, transition: { duration: 0.5, ease: 'easeOut' as const }, }, } // ============================================================================ // Animated counter // ============================================================================ function parseStatValue(value: string): { prefix: string; number: number; suffix: string } { const match = value.match(/^([<>]?)(\d+(?:\.\d+)?)(.*)$/) if (!match) return { prefix: '', number: 0, suffix: value } return { prefix: match[1], number: parseFloat(match[2]), suffix: match[3] } } function useCountUp(target: number, duration: number = 1800, shouldStart: boolean = false): number { const [current, setCurrent] = useState(0) const startTimeRef = useRef(null) const rafRef = useRef(0) useEffect(() => { if (!shouldStart) return startTimeRef.current = null function tick(timestamp: number) { if (startTimeRef.current === null) startTimeRef.current = timestamp const elapsed = timestamp - startTimeRef.current const progress = Math.min(elapsed / duration, 1) const eased = 1 - Math.pow(1 - progress, 3) setCurrent(eased * target) if (progress < 1) rafRef.current = requestAnimationFrame(tick) } rafRef.current = requestAnimationFrame(tick) return () => cancelAnimationFrame(rafRef.current) }, [target, duration, shouldStart]) return current } export function AnimatedStat({ value, label }: { value: string; label: string }) { const ref = useRef(null) const isInView = useInView(ref, { once: true, margin: '-40px 0px' }) const { prefix, number, suffix } = parseStatValue(value) const animated = useCountUp(number, 1800, isInView) const hasDecimal = number % 1 !== 0 const displayed = hasDecimal ? animated.toFixed(1) : Math.round(animated).toString() return ( {prefix}{displayed}{suffix} {label} ) } // ============================================================================ // Glassmorphic Card // ============================================================================ export function GlassCard({ children, className, hoverEffect = true, }: { children: ReactNode className?: string hoverEffect?: boolean }) { return ( {children} ) } // ============================================================================ // Placeholder Image // ============================================================================ export function PlaceholderImage({ className, label, src, aspectRatio = 'aspect-video', }: { className?: string label?: string src?: string aspectRatio?: string }) { if (src) { return ( ) } return ( {label && ( {label} )} ) } // ============================================================================ // Browser Mockup // ============================================================================ export function BrowserMockup({ className, label, src, glowBorder = false, }: { className?: string label?: string src?: string glowBorder?: boolean }) { return ( myapp.com ) } // ============================================================================ // Section Heading // ============================================================================ export function SectionHeading({ tag, title, titleHighlight, subtitle, }: { tag: string title: string titleHighlight?: string subtitle?: string }) { return ( {tag} {title} {titleHighlight && ( <> {titleHighlight} > )} {subtitle && ( {subtitle} )} ) } export { cn, AnimatePresence, motion, useInView, ChevronDown }
{subtitle}