From 573618cad8719e4488a7da7c3148046d12d6dcb0 Mon Sep 17 00:00:00 2001 From: Aravind Date: Tue, 18 Aug 2026 13:41:34 +0530 Subject: [PATCH] update the orders page ui --- .claude/launch.json | 12 + src/Layout.jsx | 108 +- src/components/navigation/SidebarNav.jsx | 373 ++++++ src/components/orders/orderPresentation.jsx | 174 +++ src/components/owliver/OwliverAnswer.jsx | 369 ------ src/components/owliver/OwliverComposer.jsx | 144 +++ src/components/owliver/OwliverContextBar.jsx | 59 + src/components/owliver/OwliverEmptyState.jsx | 92 ++ src/components/owliver/OwliverHeader.jsx | 132 +- src/components/owliver/OwliverInsight.jsx | 58 - src/components/owliver/OwliverProvider.jsx | 290 +++-- src/components/owliver/OwliverShell.jsx | 287 +++-- src/components/owliver/OwliverSuggestions.jsx | 148 +-- src/components/owliver/OwliverThinking.jsx | 25 + .../owliver/blocks/OwliverActions.jsx | 48 + .../owliver/blocks/OwliverCards.jsx | 132 ++ .../owliver/blocks/OwliverChart.jsx | 83 ++ src/components/owliver/blocks/OwliverFlow.jsx | 121 ++ .../owliver/blocks/OwliverResponse.jsx | 56 + .../owliver/blocks/OwliverTable.jsx | 145 +++ src/components/owliver/blocks/primitives.jsx | 248 ++++ src/components/reports/ClientTrendsReport.jsx | 23 + src/index.css | 112 +- src/lib/owliver/answerModel.js | 273 ++++ src/lib/owliver/blocks.js | 96 ++ src/lib/owliver/knowledge/analytics.js | 550 ++++++++ .../owliver/knowledge/analyticsWorkspace.js | 216 ++++ src/lib/owliver/knowledge/fallback.js | 115 ++ src/lib/owliver/knowledge/index.js | 45 + src/lib/owliver/knowledge/orders.js | 369 ++++++ src/lib/owliver/knowledge/savingEngine.js | 881 +++++++++++++ src/lib/owliver/knowledge/savingsWorkspace.js | 673 ++++++++++ src/lib/owliver/orderDemoData.js | 164 --- src/lib/owliver/owliverEngine.js | 6 +- src/lib/owliver/pageRegistry.js | 169 ++- src/lib/owliver/responseEngine.js | 75 ++ src/main.jsx | 5 - src/pages/Events.jsx | 1133 +++++++++-------- src/pages/SavingsEngine.jsx | 13 + 39 files changed, 6285 insertions(+), 1737 deletions(-) create mode 100644 .claude/launch.json create mode 100644 src/components/navigation/SidebarNav.jsx create mode 100644 src/components/orders/orderPresentation.jsx delete mode 100644 src/components/owliver/OwliverAnswer.jsx create mode 100644 src/components/owliver/OwliverComposer.jsx create mode 100644 src/components/owliver/OwliverContextBar.jsx create mode 100644 src/components/owliver/OwliverEmptyState.jsx delete mode 100644 src/components/owliver/OwliverInsight.jsx create mode 100644 src/components/owliver/OwliverThinking.jsx create mode 100644 src/components/owliver/blocks/OwliverActions.jsx create mode 100644 src/components/owliver/blocks/OwliverCards.jsx create mode 100644 src/components/owliver/blocks/OwliverChart.jsx create mode 100644 src/components/owliver/blocks/OwliverFlow.jsx create mode 100644 src/components/owliver/blocks/OwliverResponse.jsx create mode 100644 src/components/owliver/blocks/OwliverTable.jsx create mode 100644 src/components/owliver/blocks/primitives.jsx create mode 100644 src/lib/owliver/answerModel.js create mode 100644 src/lib/owliver/blocks.js create mode 100644 src/lib/owliver/knowledge/analytics.js create mode 100644 src/lib/owliver/knowledge/analyticsWorkspace.js create mode 100644 src/lib/owliver/knowledge/fallback.js create mode 100644 src/lib/owliver/knowledge/index.js create mode 100644 src/lib/owliver/knowledge/orders.js create mode 100644 src/lib/owliver/knowledge/savingEngine.js create mode 100644 src/lib/owliver/knowledge/savingsWorkspace.js delete mode 100644 src/lib/owliver/orderDemoData.js create mode 100644 src/lib/owliver/responseEngine.js diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..efde3dd --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "tower", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173, + "autoPort": true + } + ] +} diff --git a/src/Layout.jsx b/src/Layout.jsx index 2827ff0..4ff9b8c 100644 --- a/src/Layout.jsx +++ b/src/Layout.jsx @@ -8,8 +8,7 @@ import { DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare, Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff, Megaphone, RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical, - Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft, FolderOpen, Brain, Zap, Network, Cpu, Command, BadgeCheck, - ChevronDown, ChevronRight + Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft, FolderOpen, Brain, Zap, Network, Cpu, Command, BadgeCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -34,6 +33,7 @@ import RoleSwitcher from "./components/dev/RoleSwitcher"; import NotificationPanel from "./components/notifications/NotificationPanel"; import { NotificationEngine } from "./components/notifications/NotificationEngine"; import { Toaster } from "./components/ui/toaster"; +import SidebarNav, { NavigationMenu, useSidebarCollapsed } from "./components/navigation/SidebarNav"; import OwliverProvider from "./components/owliver/OwliverProvider"; import OwliverShell from "./components/owliver/OwliverShell"; @@ -381,91 +381,13 @@ const secondaryItemsFor = (userRole) => .flatMap((group) => group.items) .filter((item) => !EXISTING_MENU_URLS.has(item.url)); -/** - * Category-first sidebar: the top level is the work areas, and a category's - * pages appear only once it is open. One category is open at a time, so the - * sidebar stays about as tall as its category list however many pages a role - * has. - */ -function NavigationMenu({ location, userRole, closeSheet }) { - const categories = primaryCategoriesFor(userRole); - const currentPath = location.pathname; - - const activeCategory = - categories.find((group) => group.items.some((item) => item.url === currentPath)) - ?.category || null; - - const [openCategory, setOpenCategory] = React.useState(activeCategory); - - // Navigating re-opens whichever category owns the new page, so the current - // page is never hidden behind a closed category. Between navigations the - // user's own choice wins — this only re-syncs when the route actually - // changes. (React's documented "adjust state when a prop changes" pattern: - // it re-renders immediately rather than painting a stale frame.) - const syncedPath = React.useRef(currentPath); - if (syncedPath.current !== currentPath) { - syncedPath.current = currentPath; - setOpenCategory(activeCategory); - } - - return ( - - ); -} - function LayoutChrome({ children }) { const location = useLocation(); const navigate = useNavigate(); const [showNotifications, setShowNotifications] = React.useState(false); const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false); + // Remembered across navigation and reloads; see useSidebarCollapsed. + const [sidebarCollapsed, toggleSidebar] = useSidebarCollapsed(); const { data: user } = useQuery({ queryKey: ['current-user-layout'], @@ -494,6 +416,7 @@ function LayoutChrome({ children }) { const userRole = localStorage.getItem("dev_role_override") || user?.user_role || user?.role || "admin"; // Categories that left the sidebar now live in the "More options" menu. const secondaryNavItems = secondaryItemsFor(userRole); + const navCategories = primaryCategoriesFor(userRole); const userName = user?.full_name || user?.email || "User"; const userInitial = userName.charAt(0).toUpperCase(); @@ -591,7 +514,11 @@ function LayoutChrome({ children }) {
- setMobileMenuOpen(false)} /> + setMobileMenuOpen(false)} + />
+ + +
+ {group.items.map((item) => ( + + ))} +
+
+ ); + } + + /* --- expanded --- */ + return ( +
+ + + {isOpen && ( +
+ {group.items.map((item) => ( + + ))} +
+ )} +
+ ); + })} + + ); +} + +/* ------------------------------------------------------------------ rail */ + +/** + * The desktop rail: a track in the shell's body row that changes width rather + * than overlaying the page, so
and the Owliver panel simply re-flow. + */ +export default function SidebarNav({ categories, currentPath, collapsed, onToggle }) { + const ChevronIcon = collapsed ? ChevronsRight : ChevronsLeft; + + return ( + + + + ); +} diff --git a/src/components/orders/orderPresentation.jsx b/src/components/orders/orderPresentation.jsx new file mode 100644 index 0000000..10bdcb1 --- /dev/null +++ b/src/components/orders/orderPresentation.jsx @@ -0,0 +1,174 @@ +import { AlertTriangle, CheckCircle2, Clock, FileText, Users, X, Zap } from "lucide-react"; + +/** + * Order presentation primitives — KROW Control Tower + * -------------------------------------------------- + * One source of truth for how an order *looks* wherever it appears: the Order + * Management table and Owliver's operational cards both render from here, so a + * status pill or a staffing meter can never drift between the page and the + * copilot reading that same page. + * + * Presentation only. Nothing here computes business state — status strings and + * headcounts arrive already calculated by the page (Events.jsx) or by the + * Owliver page registry, which mirror each other deliberately. + */ + +/* -------------------------------------------------------------- status pill */ + +/** + * Compact status tokens. Colour carries meaning and nothing else: the pill is a + * quiet tinted chip with a solid dot, not a saturated block, so a table of nine + * orders does not read as a paint chart. + */ +export const STATUS_TOKENS = { + "Draft": { label: "Draft", dot: "bg-slate-400", text: "text-slate-600", bg: "bg-slate-50", border: "border-slate-200", icon: FileText }, + "Pending": { label: "Pending", dot: "bg-amber-500", text: "text-amber-800", bg: "bg-amber-50", border: "border-amber-200", icon: Clock }, + "Partial Staffed": { label: "Partial", dot: "bg-orange-500", text: "text-orange-800", bg: "bg-orange-50", border: "border-orange-200", icon: AlertTriangle }, + "Fully Staffed": { label: "Fully staffed", dot: "bg-emerald-500", text: "text-emerald-800", bg: "bg-emerald-50", border: "border-emerald-200", icon: CheckCircle2 }, + "Active": { label: "Active", dot: "bg-[#0039A6]", text: "text-[#0039A6]", bg: "bg-blue-50", border: "border-blue-200", icon: Users }, + "Completed": { label: "Completed", dot: "bg-slate-300", text: "text-slate-500", bg: "bg-slate-50", border: "border-slate-200", icon: CheckCircle2 }, + "Canceled": { label: "Canceled", dot: "bg-red-500", text: "text-red-700", bg: "bg-red-50", border: "border-red-200", icon: X }, +}; + +const RAPID_TOKEN = { + label: "Rapid", + dot: "bg-red-500", + text: "text-red-700", + bg: "bg-red-50", + border: "border-red-200", + icon: Zap, +}; + +const FALLBACK_TOKEN = STATUS_TOKENS["Draft"]; + +export const statusToken = (status, isRapid) => + isRapid ? RAPID_TOKEN : STATUS_TOKENS[status] || { ...FALLBACK_TOKEN, label: status || "Unknown" }; + +/** + * The status of one order, as a compact pill. + * `hasConflicts` adds a small warning marker rather than a second badge — a + * conflict is an annotation on the status, not a status of its own. + */ +export function OrderStatusPill({ status, isRapid = false, hasConflicts = false, className = "" }) { + const token = statusToken(status, isRapid); + + return ( + + + + {token.label} + + {hasConflicts && ( + + )} + + ); +} + +/* ----------------------------------------------------------------- staffing */ + +/** + * How urgent an order's staffing gap is. + * full — at requested headcount + * high — under half filled, or nobody assigned at all + * medium — filled, but short + * none — nothing requested yet (a draft with no roles priced in) + */ +export function severityOf(assigned, requested) { + const a = Number(assigned) || 0; + const r = Number(requested) || 0; + if (r <= 0) return "none"; + if (a >= r) return "full"; + return a / r < 0.5 ? "high" : "medium"; +} + +export const SEVERITY_TOKENS = { + high: { label: "High", fill: "bg-orange-500", track: "bg-orange-100", text: "text-orange-700", bg: "bg-orange-50", border: "border-orange-200", rail: "bg-orange-500" }, + medium: { label: "Medium", fill: "bg-amber-500", track: "bg-amber-100", text: "text-amber-700", bg: "bg-amber-50", border: "border-amber-200", rail: "bg-amber-400" }, + full: { label: "On track", fill: "bg-emerald-500", track: "bg-emerald-100", text: "text-emerald-700", bg: "bg-emerald-50", border: "border-emerald-200", rail: "bg-transparent" }, + none: { label: "Not sized", fill: "bg-slate-300", track: "bg-slate-100", text: "text-slate-500", bg: "bg-slate-50", border: "border-slate-200", rail: "bg-transparent" }, +}; + +export const fillPercent = (assigned, requested) => { + const a = Number(assigned) || 0; + const r = Number(requested) || 0; + if (r <= 0) return 0; + return Math.min(100, Math.round((a / r) * 100)); +}; + +const SEGMENTS = 10; + +/** + * `4 / 9` · `44%` · ▮▮▮▮▯▯▯▯▯▯ + * + * The ratio is the fact, the percentage is the read, and the segmented track is + * the glance. Segments rather than a smooth bar: ten discrete blocks are + * countable at a glance and land on the same visual rhythm in a dense table row + * as they do in a copilot card. + */ +export function StaffingMeter({ + assigned, + requested, + size = "md", + align = "start", + showFigures = true, + className = "", +}) { + const a = Number(assigned) || 0; + const r = Number(requested) || 0; + const pct = fillPercent(a, r); + const severity = severityOf(a, r); + const token = SEVERITY_TOKENS[severity]; + const litSegments = r > 0 ? Math.max(a > 0 ? 1 : 0, Math.round((pct / 100) * SEGMENTS)) : 0; + + const figures = size === "sm" ? "text-[11px]" : "text-[12.5px]"; + const trackH = size === "sm" ? "h-[3px]" : "h-1"; + + return ( +
+ {showFigures && ( +
+ + {a} / {r || "—"} + + + {r > 0 ? `${pct}%` : ""} + +
+ )} + +
0 ? `${a} of ${r} staffed, ${pct} percent` : "No headcount requested"} + > + {Array.from({ length: SEGMENTS }).map((_, i) => ( + + ))} +
+
+ ); +} + +/* ------------------------------------------------------------------- dates */ + +/** "Aug 17" / "6:00 PM – 11:30 PM" formatting shared by the table and Owliver. */ +export const to12Hour = (time24) => { + if (!time24) return null; + const [rawH, m] = String(time24).split(":"); + const h = Number(rawH); + if (Number.isNaN(h)) return time24; + const suffix = h >= 12 ? "PM" : "AM"; + return `${h % 12 || 12}:${m} ${suffix}`; +}; + +export default OrderStatusPill; diff --git a/src/components/owliver/OwliverAnswer.jsx b/src/components/owliver/OwliverAnswer.jsx deleted file mode 100644 index eca7c9b..0000000 --- a/src/components/owliver/OwliverAnswer.jsx +++ /dev/null @@ -1,369 +0,0 @@ -import ReactMarkdown from "react-markdown"; -import { useNavigate } from "react-router-dom"; -import { - ArrowRight, - Check, - Loader2, - X, - Clock, - MapPin, - Sparkles, - Building2, - AlertTriangle, -} from "lucide-react"; -import { stripContext } from "@/lib/owliver/context"; -import { extractActions, isValidRoute } from "@/lib/owliver/actions"; - -/** Compact, non-technical progress line for an agent tool call. */ -function ToolLine({ toolCall }) { - const status = toolCall.status || "pending"; - const running = ["pending", "running", "in_progress"].includes(status); - const failed = ["failed", "error"].includes(status); - const label = toolCall.display_projection?.label || toolCall.name || "Working"; - const activeLabel = toolCall.display_projection?.active_label || `${label}…`; - const errorLabel = toolCall.display_projection?.error_label || `${label} failed`; - - return ( -
- {running ? - : failed ? - : } - {running ? activeLabel : failed ? errorLabel : label} -
- ); -} - -function extractNodeText(node) { - if (!node) return ""; - if (typeof node === "string") return node; - if (typeof node === "number") return String(node); - if (Array.isArray(node)) return node.map(extractNodeText).join(""); - if (node.props?.children) return extractNodeText(node.props.children); - return ""; -} - -function parseListItemText(text) { - const rawClean = String(text || "").trim(); - - let id = ""; - const idMatch = rawClean.match(/\[id:([^\]]+)\]/); - if (idMatch) { - id = idMatch[1]; - } - const clean = rawClean.replace(/\[id:[^\]]+\]/g, "").trim(); - - // Ratio check e.g. "3/10 staffed" or "9/20 staffed" or "3/10" - const ratioMatch = clean.match(/(\d+)\/(\d+)\s*(staffed|filled|seats)?/i); - if (ratioMatch) { - const assigned = parseInt(ratioMatch[1], 10); - const requested = parseInt(ratioMatch[2], 10); - const pct = requested > 0 ? Math.min(100, Math.round((assigned / requested) * 100)) : 0; - - let titlePart = clean.replace(/·|\b\d+\/\d+\s*(staffed|filled|seats)?/gi, "").trim(); - let hub = ""; - let dateTime = ""; - - const hubMatch = titlePart.match(/\(([^)]+)\)/); - if (hubMatch) { - hub = hubMatch[1]; - titlePart = titlePart.replace(/\([^)]+\)/, "").trim(); - } - - const commaParts = titlePart.split(","); - if (commaParts.length > 1) { - titlePart = commaParts[0].trim(); - dateTime = commaParts.slice(1).join(",").trim(); - } - - let company = titlePart; - let eventName = ""; - - if (titlePart.includes("—")) { - const parts = titlePart.split("—"); - company = parts[0].trim(); - eventName = parts.slice(1).join("—").trim(); - } else if (titlePart.includes(" - ")) { - const parts = titlePart.split(" - "); - company = parts[0].trim(); - eventName = parts.slice(1).join(" - ").trim(); - } - - return { - type: "order_card", - id, - company, - eventName, - hub, - dateTime, - assigned, - requested, - pct, - raw: clean, - }; - } - - // Step flow check e.g. "Step 1: ...", "1. ...", "First: ..." - const stepMatch = clean.match(/^(step\s*\d+|\d+[.)]|first|second|third|fourth|fifth)[\s:]*(.*)/i); - if (stepMatch) { - return { - type: "flow_step", - stepLabel: stepMatch[1], - content: stepMatch[2] || clean, - raw: clean, - }; - } - - return { - type: "generic_card", - raw: clean, - }; -} - -export default function OwliverAnswer({ message }) { - const navigate = useNavigate(); - const content = stripContext(message.content); - - if (message.role === "user") { - if (!content) return null; - return ( -

- {content} -

- ); - } - - const actions = extractActions(content); - const toolCalls = message.tool_calls || []; - - return ( - <> - {toolCalls.length > 0 && ( -
- {toolCalls.map((tc, i) => )} -
- )} - - {content && ( -
-
{children}
, - ol: ({ children }) =>
{children}
, - li: ({ children }) => { - const rawText = extractNodeText(children); - const parsed = parseListItemText(rawText); - - if (parsed.type === "order_card") { - const isFull = parsed.pct >= 100; - const isLow = parsed.pct < 50; - - const borderAccent = isFull - ? "border-l-emerald-500" - : isLow - ? "border-l-amber-500" - : "border-l-[#0039A6]"; - - const badgeStyle = isFull - ? "bg-emerald-50 text-emerald-700 border-emerald-200" - : isLow - ? "bg-amber-50 text-amber-700 border-amber-200" - : "bg-blue-50 text-blue-700 border-blue-200"; - - const progressColor = isFull - ? "bg-emerald-500" - : isLow - ? "bg-amber-500" - : "bg-[#0039A6]"; - - return ( -
-
-
-

- - {parsed.company} -

- {parsed.eventName && ( -

{parsed.eventName}

- )} - {parsed.hub && ( -

- - {parsed.hub} -

- )} -
- - - {parsed.assigned}/{parsed.requested} Staffed - -
- - {/* Progress Bar */} -
-
- Staffing Fill - {parsed.pct}% -
-
-
-
-
- -
- - - {parsed.dateTime || "Scheduled Shift"} - - -
-
- ); - } - - if (parsed.type === "flow_step") { - return ( -
-
- {parsed.stepLabel.slice(0, 3)} -
-
-

{parsed.content}

-
-
- ); - } - - return ( -
-
-
- {children} -
-
- ); - }, - p: ({ children }) => { - const text = extractNodeText(children); - - // If paragraph mentions order risk, headcount ratio, or open seats - const ratioMatch = text.match(/(\d+)\/(\d+)/); - if (ratioMatch) { - const assigned = parseInt(ratioMatch[1], 10); - const requested = parseInt(ratioMatch[2], 10); - const pct = requested > 0 ? Math.min(100, Math.round((assigned / requested) * 100)) : 0; - - return ( -
-
-
- - Order Attention - -

{text}

-
- - {assigned}/{requested} - -
- -
-
- Staffing Fill - {pct}% -
-
-
-
-
- -
- -
-
- ); - } - - // If paragraph is a summary metric callout - if (/(\d+)\s*(orders|shifts|positions|candidates|seats)/i.test(text)) { - return ( -
- - {children} -
- ); - } - return

{children}

; - }, - a: ({ href, children }) => - isValidRoute(href) ? ( - { e.preventDefault(); navigate(href); }} - className="underline underline-offset-2 font-medium" - style={{ color: "var(--ow-brand)" }} - > - {children} - - ) : ( - - {children} - - ), - }} - > - {content} - -
- )} - - {actions.length > 0 && ( -
- {actions.map((action) => ( - - ))} -
- )} - - ); -} diff --git a/src/components/owliver/OwliverComposer.jsx b/src/components/owliver/OwliverComposer.jsx new file mode 100644 index 0000000..c07e4a0 --- /dev/null +++ b/src/components/owliver/OwliverComposer.jsx @@ -0,0 +1,144 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ArrowUp, AlertCircle, Loader2, X } from "lucide-react"; + +/** + * The composer. + * + * ┌──────────────────────────────────────┐ + * │ Ask Owliver about Saving Engine... │ + * │ │ + * │ ✦ Saving Engine [ ↑ ] │ + * └──────────────────────────────────────┘ + * + * One field that starts at a single line and grows to about four before it + * scrolls, a context chip that names what the question will be answered + * against, and a send affordance that only lights up when there is something to + * send. Enter sends, Shift+Enter breaks the line, and the whole thing is docked + * — it never scrolls away with the conversation. + * + * While a turn is in flight the field stays mounted and readable but refuses + * input, so a follow-up is never asked against half an answer. + */ +export default function OwliverComposer({ + onSubmit, placeholder, contextLabel, disabled, error, onRetry, autoFocus, +}) { + const [value, setValue] = useState(""); + const [focused, setFocused] = useState(false); + const inputRef = useRef(null); + + // Grow with the content, then scroll — a composer that swallows the panel is + // worse than one that scrolls. + useEffect(() => { + const el = inputRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 104)}px`; + }, [value]); + + useEffect(() => { + if (autoFocus) inputRef.current?.focus(); + }, [autoFocus]); + + // The field is re-armed the moment Owliver finishes, so the next question can + // be typed without reaching for the mouse. + useEffect(() => { + if (!disabled) inputRef.current?.focus({ preventScroll: true }); + }, [disabled]); + + const submit = useCallback(() => { + const text = value.trim(); + if (!text || disabled) return; + setValue(""); + onSubmit(text); + }, [value, disabled, onSubmit]); + + const onKeyDown = (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }; + + const canSend = value.trim().length > 0 && !disabled; + + return ( +
+ {error && ( +
+ +

{error}

+ {onRetry && ( + + )} +
+ )} + +
{ e.preventDefault(); submit(); }} + className={`rounded-[14px] border bg-white transition-all duration-150 ${ + focused + ? "border-[#0039A6]/45 shadow-[0_0_0_3px_rgba(0,57,166,0.10)]" + : "border-slate-200 hover:border-slate-300" + }`} + > +
+