"use client"; import React, { useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { AnimatePresence, motion, useReducedMotion, type Transition, } from "motion/react"; import { cn } from "@/lib/utils"; export interface MorphSearchProps { value?: string; defaultValue?: string; onChange?: (value: string) => void; onSubmit?: (value: string, intent?: string) => void; onClear?: () => void; placeholder?: string; className?: string; autoFocus?: boolean; } type IntentType = "poll" | "event" | "todo" | "timer" | "split" | "search"; interface PollData { title: string; options: string[]; } interface EventData { title: string; time: string; attendee: string; } interface TodoData { items: string[]; } interface TimerData { seconds: number; label: string; } interface SplitData { total: number; people: number; perPerson: number; } interface DetectedIntent { type: IntentType; poll?: PollData; event?: EventData; todo?: TodoData; timer?: TimerData; split?: SplitData; } interface SavedEntry { id: number; intent: IntentType; text: string; summary: string; timestamp: number; } const PLACEHOLDERS = [ "pizza or burgers for friday?", "dinner with priya friday 8pm", "buy milk, eggs, bread and coffee", "25 min focus", "split 2400 between 3", "explore components and hooks", ]; const springMorph: Transition = { type: "spring", stiffness: 440, damping: 38, mass: 0.8, }; const fadeTween: Transition = { duration: 0.14, ease: [0.23, 1, 0.32, 1], }; function parseQuery(text: string): DetectedIntent { const clean = text.trim(); const lower = clean.toLowerCase(); if (!clean) { return { type: "search" }; } if ( lower.includes(" or ") || lower.includes(" vs ") || (lower.endsWith("?") && (lower.includes("pizza") || lower.includes("burger"))) ) { const rawNoQ = clean.replace(/\?+$/, ""); const parts = rawNoQ.split(/\s+(?:or|vs\.?)\s+/i).map((s) => s.trim()); if (parts.length >= 2) { const opts = parts.map((p) => { const words = p.split(/\s+/); return words.length > 3 ? words.slice(-2).join(" ") : p; }); return { type: "poll", poll: { title: clean.endsWith("?") ? clean : `${clean}?`, options: opts.slice(0, 4), }, }; } } if ( /^(?:split|divide|share)\b/i.test(lower) || (/\bbetween\s+\d+/i.test(lower) && /\d+/.test(lower)) ) { const numbers = lower.match(/\d+(?:\.\d+)?/g); if (numbers && numbers.length >= 2) { const total = parseFloat(numbers[0]); const people = parseInt(numbers[1], 10) || 1; const perPerson = Math.round((total / Math.max(1, people)) * 100) / 100; return { type: "split", split: { total, people, perPerson }, }; } } if ( /\b(?:timer|focus|break|pomodoro)\b/i.test(lower) || /^\d+\s*(?:m|min|mins|minutes|s|sec|seconds|h|hr|hrs|hours)\b/i.test(lower) ) { let secs = 25 * 60; const match = lower.match( /(\d+)\s*(m|min|mins|minutes|s|sec|seconds|h|hr|hrs|hours)?/i, ); if (match) { const num = parseInt(match[1], 10); const unit = match[2]?.charAt(0) || "m"; if (unit === "h") secs = num * 3600; else if (unit === "s") secs = num; else secs = num * 60; } const label = lower .replace( /\d+\s*(?:m|min|mins|minutes|s|sec|seconds|h|hr|hrs|hours)?/i, "", ) .replace(/timer|for/gi, "") .trim(); return { type: "timer", timer: { seconds: Math.max(10, secs), label: label ? label.charAt(0).toUpperCase() + label.slice(1) : "Focus Session", }, }; } if ( /^(?:buy|get|todo|tasks|groceries|shopping list)\b/i.test(lower) || (clean.includes(",") && (clean.includes("and") || clean.includes("&"))) ) { const sanitized = clean .replace(/^(?:buy|get|todo|tasks|groceries|shopping list):?\s*/i, "") .replace(/\band\b/gi, ","); const rawItems = sanitized .split(/[,;\n]/) .map((i) => i.trim()) .filter((i) => i.length > 0); if (rawItems.length >= 2) { return { type: "todo", todo: { items: rawItems.map( (item) => item.charAt(0).toUpperCase() + item.slice(1), ), }, }; } } if ( /\b(?:dinner|lunch|breakfast|coffee|meeting|sync|call|party|interview|session)\b/i.test( lower, ) || /\b(?:today|tomorrow|tonight|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/i.test( lower, ) ) { const withMatch = clean.match(/\bwith\s+([A-Za-z]+)/i); const timeMatch = clean.match( /\b(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm)?|\d{1,2}\s*(?:am|pm)|noon|midnight)\b/i, ); const dayMatch = clean.match( /\b(today|tomorrow|tonight|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/i, ); const timeStr = [ dayMatch ? dayMatch[1].charAt(0).toUpperCase() + dayMatch[1].slice(1) : "Upcoming", timeMatch ? timeMatch[1].toUpperCase() : "8:00 PM", ].join(", "); return { type: "event", event: { title: clean.charAt(0).toUpperCase() + clean.slice(1), time: timeStr, attendee: withMatch ? withMatch[1].charAt(0).toUpperCase() + withMatch[1].slice(1) : "Team", }, }; } return { type: "search" }; } function formatTimer(secs: number): string { const m = Math.floor(secs / 60); const s = secs % 60; return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; } export function MorphSearch({ value: controlledValue, defaultValue = "", onChange, onSubmit, onClear, placeholder, className, autoFocus = false, }: MorphSearchProps) { const isControlled = controlledValue !== undefined; const [internalValue, setInternalValue] = useState(defaultValue); const query = isControlled ? controlledValue : internalValue; const [isOpen, setIsOpen] = useState(false); const [placeholderIndex, setPlaceholderIndex] = useState(0); const [activeCategory, setActiveCategory] = useState("all"); const [savedItems, setSavedItems] = useState([]); const [pollVotes, setPollVotes] = useState>({}); const [completedTodos, setCompletedTodos] = useState>( {}, ); const [timerRunning, setTimerRunning] = useState(false); const [timerTimeLeft, setTimerTimeLeft] = useState(null); const containerRef = useRef(null); const inputRef = useRef(null); const triggerRef = useRef(null); const reduceMotion = useReducedMotion(); const inputId = useId(); const detected = useMemo(() => parseQuery(query), [query]); useEffect(() => { if (reduceMotion) return; const interval = setInterval(() => { setPlaceholderIndex((prev) => (prev + 1) % PLACEHOLDERS.length); }, 2800); return () => clearInterval(interval); }, [reduceMotion]); const [prevQuery, setPrevQuery] = useState(query); if (query !== prevQuery) { setPrevQuery(query); setPollVotes({}); setCompletedTodos({}); setTimerRunning(false); setTimerTimeLeft( detected.type === "timer" && detected.timer ? detected.timer.seconds : null, ); } useEffect(() => { if (!timerRunning || timerTimeLeft === null || timerTimeLeft <= 0) return; const interval = setInterval(() => { setTimerTimeLeft((prev) => { if (prev === null || prev <= 1) { setTimerRunning(false); return 0; } return prev - 1; }); }, 1000); return () => clearInterval(interval); }, [timerRunning, timerTimeLeft]); const updateQuery = useCallback( (newVal: string) => { if (!isControlled) { setInternalValue(newVal); } onChange?.(newVal); }, [isControlled, onChange], ); const handleOpen = useCallback(() => { setIsOpen(true); requestAnimationFrame(() => { inputRef.current?.focus(); }); }, []); const handleClose = useCallback(() => { setIsOpen(false); inputRef.current?.blur(); }, []); const closeToTrigger = useCallback(() => { handleClose(); triggerRef.current?.focus(); }, [handleClose]); const handleClear = useCallback(() => { updateQuery(""); onClear?.(); inputRef.current?.focus(); }, [updateQuery, onClear]); const saveCurrentItem = useCallback(() => { const trimmed = query.trim(); if (!trimmed) return; let summary = trimmed; if (detected.type === "poll" && detected.poll) { summary = detected.poll.title; } else if (detected.type === "event" && detected.event) { summary = `${detected.event.title} · ${detected.event.time}`; } else if (detected.type === "todo" && detected.todo) { summary = detected.todo.items.join(", "); } else if (detected.type === "timer" && detected.timer) { summary = `${detected.timer.label} · ${formatTimer(detected.timer.seconds)}`; } else if (detected.type === "split" && detected.split) { summary = `₹${detected.split.total} among ${detected.split.people} (₹${detected.split.perPerson}/person)`; } const newItem: SavedEntry = { id: Date.now() + Math.random(), intent: detected.type, text: trimmed, summary, timestamp: Date.now(), }; setSavedItems((prev) => [newItem, ...prev.slice(0, 8)]); onSubmit?.(trimmed, detected.type); updateQuery(""); setIsOpen(false); }, [query, detected, onSubmit, updateQuery]); const reopenItem = (item: SavedEntry) => { updateQuery(item.text); setIsOpen(true); requestAnimationFrame(() => { inputRef.current?.focus(); }); }; const removeItem = (id: number) => { setSavedItems((prev) => prev.filter((item) => item.id !== id)); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); if (query) { handleClear(); } else { closeToTrigger(); } } else if (e.key === "Enter" && !e.nativeEvent.isComposing) { e.preventDefault(); saveCurrentItem(); } }; useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { const activeEl = document.activeElement; const isInput = activeEl instanceof HTMLInputElement || activeEl instanceof HTMLTextAreaElement || activeEl?.getAttribute("contenteditable") === "true"; if ( (e.key === "/" || (e.key === "k" && (e.metaKey || e.ctrlKey))) && !isInput ) { e.preventDefault(); handleOpen(); } }; window.addEventListener("keydown", handleGlobalKeyDown); return () => window.removeEventListener("keydown", handleGlobalKeyDown); }, [handleOpen]); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(e.target as Node) ) { handleClose(); } }; if (isOpen) { document.addEventListener("mousedown", handleClickOutside); } return () => document.removeEventListener("mousedown", handleClickOutside); }, [isOpen, handleClose]); const voteOnPoll = (index: number) => { setPollVotes((prev) => ({ ...prev, [index]: (prev[index] || 0) + 1, })); }; const toggleTodo = (index: number) => { setCompletedTodos((prev) => ({ ...prev, [index]: !prev[index], })); }; const sampleChips = [ { label: "All", id: "all" }, { label: "Poll", id: "poll", sample: "pizza or burgers for friday?" }, { label: "Event", id: "event", sample: "dinner with priya friday 8pm" }, { label: "Todo", id: "todo", sample: "buy milk, eggs, bread and coffee" }, { label: "Timer", id: "timer", sample: "25 min focus" }, { label: "Split", id: "split", sample: "split 2400 between 3" }, ]; const searchResults = useMemo(() => { if (detected.type !== "search" || !query.trim()) return []; const pool = [ { title: "Navigation Components", category: "Components", desc: "Gooey navbar and fluid floating tab selectors", }, { title: "Action Controls", category: "Inputs", desc: "Tactile buttons, liquid toggles, and switches", }, { title: "Micro Physics Engines", category: "Motion", desc: "Spring solvers, collision dynamics, and kinetic easing", }, { title: "Theming & Palette Matrix", category: "Tokens", desc: "Adaptive dark mode palettes and high contrast curves", }, ]; return pool.filter( (item) => item.title.toLowerCase().includes(query.toLowerCase()) || item.desc.toLowerCase().includes(query.toLowerCase()) || item.category.toLowerCase().includes(query.toLowerCase()), ); }, [detected.type, query]); return (
updateQuery(e.target.value)} onKeyDown={handleKeyDown} className="w-full bg-transparent text-[17px] font-normal tracking-[-0.01em] text-zinc-900 caret-zinc-900 dark:text-white dark:caret-white outline-none placeholder:text-transparent" aria-label="Search or enter prompt" /> {!query && ( {placeholder ?? PLACEHOLDERS[placeholderIndex]} )}
{query && ( )}
{isOpen && (
{detected.type === "poll" && detected.poll && (
Poll Select an option to vote

{detected.poll.title}

{detected.poll.options.map((opt, idx) => { const totalVotes = Object.values(pollVotes).reduce( (a, b) => a + b, 0, ); const votes = pollVotes[idx] || 0; const pct = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0; return ( ); })}
{Object.values(pollVotes).reduce((a, b) => a + b, 0)}{" "} total votes
)} {detected.type === "event" && detected.event && (
Event Scheduled Meeting

{detected.event.title}

{detected.event.time}
{detected.event.attendee}
)} {detected.type === "todo" && detected.todo && (
Checklist {Object.values(completedTodos).filter(Boolean).length}/ {detected.todo.items.length} completed
{detected.todo.items.map((item, idx) => { const done = Boolean(completedTodos[idx]); return ( ); })}
)} {detected.type === "timer" && detected.timer && (
Timer {detected.timer.label}
{formatTimer(timerTimeLeft ?? detected.timer.seconds)}
)} {detected.type === "split" && detected.split && (
Split Bill {detected.split.total} among {detected.split.people}
Each pays ₹{detected.split.perPerson}
{(() => { const splitData = detected.split; return Array.from({ length: Math.min(6, splitData.people), }).map((_, i) => (
Person {i + 1} {splitData.perPerson}
)); })()}
)} {detected.type === "search" && query.trim() && (
Search Results
{searchResults.length > 0 ? ( searchResults.map((item, idx) => ( )) ) : (
No matches found for "{query}"
)}
)} {!query.trim() && (
{sampleChips.map((chip) => { const isSelected = activeCategory === chip.id; return ( ); })}
Suggested Prompts {PLACEHOLDERS.slice(0, 4).map((p, i) => ( ))}
)}
)}
{savedItems.length > 0 && ( {savedItems.map((item) => ( reopenItem(item)} >
{item.intent} {item.summary}
))}
)}
); }