EventBuzz_Console/src/app/components/TopBar.tsx

886 lines
38 KiB
TypeScript

import React, { useState, useRef, useEffect, useMemo } from "react";
import { Bell, Search, Menu, X, ArrowRight, MapPin, Calendar, ChevronRight, CreditCard, ChevronDown } from "lucide-react";
import { useBreakpoint } from "../hooks/useBreakpoint";
import { useAuth } from "../contexts/AuthContext";
import { useAppNav } from "../contexts/AppNavContext";
import { organizationApi, subscriptionApi, eventsApi } from "../../services/api";
import type { OrganizationRecord, SubscriptionPlanCatalog } from "../../services/api/types";
import { portfolioListOrganizationId } from "../../services/api/scopedOrganization";
import { ORGANIZATION_SUBSCRIPTION_UPDATED } from "../constants/appEvents";
import {
doSearch,
STATUS_BADGE,
mapApiEventToSearchEvent,
SearchEvent, SearchPage, SearchAction, SearchResults,
} from "./search/searchIndex";
import { EventBuzzBrandLogo } from "./EventBuzzBrandLogo";
/* ─────────────────────────────────────────
Props
───────────────────────────────────────── */
interface TopBarProps {
onMenuClick?: () => void;
onNavigate?: (navId: string) => void;
onEventSelect?: (eventId: string) => void;
onOpenSearchPage?: (query: string) => void;
}
/* ─────────────────────────────────────────
Inline highlight helper
───────────────────────────────────────── */
function Hl({ text, q }: { text: string; q: string }) {
if (!q.trim()) return <>{text}</>;
const idx = text.toLowerCase().indexOf(q.toLowerCase());
if (idx === -1) return <>{text}</>;
return (
<>
{text.slice(0, idx)}
<mark style={{ background: "var(--accent)", color: "var(--accent-foreground)", borderRadius: "2px", padding: "0 1px" }}>
{text.slice(idx, idx + q.length)}
</mark>
{text.slice(idx + q.length)}
</>
);
}
/* ─────────────────────────────────────────
Dropdown component (shared desktop + mobile)
───────────────────────────────────────── */
function SearchDropdown({
query, results, activeIndex, onEventClick, onPageClick, onActionClick, onViewAll,
}: {
query: string;
results: SearchResults | null;
activeIndex: number;
onEventClick: (e: SearchEvent) => void;
onPageClick: (p: SearchPage) => void;
onActionClick: (a: SearchAction) => void;
onViewAll: () => void;
}) {
const trimmed = query.trim();
/* Flat index offsets (to map activeIndex → each section) */
const eventsShown = results ? Math.min(results.events.length, 4) : 0;
const pagesShown = results ? Math.min(results.pages.length, 5) : 0;
const actionsShown = results ? Math.min(results.actions.length, 3) : 0;
const pageOff = eventsShown;
const actionOff = eventsShown + pagesShown;
/* ── Section label ── */
const SectionLabel = ({ label, count }: { label: string; count: number }) => (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 14px 4px" }}>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "700", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.07em" }}>
{label}
</span>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", background: "var(--secondary)", padding: "1px 6px", borderRadius: "999px" }}>
{count}
</span>
</div>
);
/* ── Empty query: no menu (avoid static Suggested / Recent Events panel) ── */
if (!trimmed) {
return null;
}
/* ── No results ── */
if (!results || results.total === 0) {
return (
<div style={{ padding: "28px 14px", textAlign: "center" }}>
<Search size={20} style={{ color: "var(--border)", margin: "0 auto 10px" }} />
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "4px" }}>
No results for "{trimmed}"
</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
Try a different search term
</p>
</div>
);
}
/* ── Results ── */
return (
<div>
{/* Events */}
{results.events.length > 0 && (
<div>
<SectionLabel label="Events" count={results.events.length} />
{results.events.slice(0, 4).map((e, i) => {
const badge = STATUS_BADGE[e.status];
const isAct = activeIndex === i;
return (
<button key={e.id} onClick={() => onEventClick(e)}
style={{
display: "flex", alignItems: "center", gap: "10px",
width: "100%", padding: "8px 14px", textAlign: "left",
background: isAct ? "var(--accent)" : "none",
border: "none", cursor: "pointer", transition: "background 0.1s",
}}
onMouseEnter={e2 => { (e2.currentTarget as HTMLElement).style.background = "var(--secondary)"; }}
onMouseLeave={e2 => { (e2.currentTarget as HTMLElement).style.background = isAct ? "var(--accent)" : "none"; }}
>
{/* Status dot */}
<span style={{ width: "6px", height: "6px", borderRadius: "50%", background: badge.dot ?? badge.color, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: "0 0 1px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
<Hl text={e.title} q={trimmed} />
</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
<span style={{ display: "inline-flex", alignItems: "center", gap: "3px" }}>
<MapPin size={9} />{e.location}
</span>
<span style={{ margin: "0 6px", color: "var(--border)" }}>·</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: "3px" }}>
<Calendar size={9} />{e.startDate}
</span>
</p>
</div>
<span style={{ padding: "1px 7px", borderRadius: "999px", background: badge.bg, color: badge.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", flexShrink: 0 }}>
{e.status}
</span>
</button>
);
})}
{results.events.length > 4 && (
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", padding: "4px 14px 6px", margin: 0 }}>
+{results.events.length - 4} more event{results.events.length - 4 > 1 ? "s" : ""}
</p>
)}
</div>
)}
{/* Divider between events and pages */}
{results.events.length > 0 && results.pages.length > 0 && (
<div style={{ borderTop: "1px solid var(--border)", margin: "4px 0" }} />
)}
{/* Pages */}
{results.pages.length > 0 && (
<div>
<SectionLabel label="Pages" count={results.pages.length} />
{results.pages.slice(0, 5).map((p, i) => {
const isAct = activeIndex === pageOff + i;
return (
<button key={p.id} onClick={() => onPageClick(p)}
style={{
display: "flex", alignItems: "center", gap: "10px",
width: "100%", padding: "7px 14px", textAlign: "left",
background: isAct ? "var(--accent)" : "none",
border: "none", cursor: "pointer", transition: "background 0.1s",
}}
onMouseEnter={e2 => { (e2.currentTarget as HTMLElement).style.background = "var(--secondary)"; }}
onMouseLeave={e2 => { (e2.currentTarget as HTMLElement).style.background = isAct ? "var(--accent)" : "none"; }}
>
<div style={{ width: "26px", height: "26px", borderRadius: "var(--radius-button)", background: "var(--secondary)", border: "1px solid var(--border)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, color: "var(--primary)" }}>
{p.icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: "0 0 1px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
<Hl text={p.title} q={trimmed} />
</p>
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>{p.section}</p>
</div>
<ChevronRight size={12} style={{ color: "var(--muted)", flexShrink: 0 }} />
</button>
);
})}
</div>
)}
{/* Divider before actions */}
{(results.events.length > 0 || results.pages.length > 0) && results.actions.length > 0 && (
<div style={{ borderTop: "1px solid var(--border)", margin: "4px 0" }} />
)}
{/* Actions */}
{results.actions.length > 0 && (
<div>
<SectionLabel label="Quick Actions" count={results.actions.length} />
{results.actions.slice(0, 3).map((a, i) => {
const isAct = activeIndex === actionOff + i;
return (
<button key={a.id} onClick={() => onActionClick(a)}
style={{
display: "flex", alignItems: "center", gap: "10px",
width: "100%", padding: "7px 14px", textAlign: "left",
background: isAct ? "var(--accent)" : "none",
border: "none", cursor: "pointer", transition: "background 0.1s",
}}
onMouseEnter={e2 => { (e2.currentTarget as HTMLElement).style.background = "var(--secondary)"; }}
onMouseLeave={e2 => { (e2.currentTarget as HTMLElement).style.background = isAct ? "var(--accent)" : "none"; }}
>
<div style={{ width: "26px", height: "26px", borderRadius: "var(--radius-button)", background: "var(--accent)", border: "1px solid var(--border)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, color: "var(--accent-foreground)" }}>
{a.icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: "0 0 1px" }}>
<Hl text={a.title} q={trimmed} />
</p>
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
<Hl text={a.description} q={trimmed} />
</p>
</div>
<span style={{ padding: "2px 8px", borderRadius: "var(--radius-button)", background: "var(--accent)", color: "var(--accent-foreground)", fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", flexShrink: 0 }}>
Go
</span>
</button>
);
})}
</div>
)}
{/* Footer: view all */}
<div style={{ borderTop: "1px solid var(--border)", margin: "4px 0 0" }}>
<button
onClick={onViewAll}
style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
width: "100%", padding: "10px 14px", background: "none",
border: "none", cursor: "pointer", borderRadius: "0 0 var(--radius-card) var(--radius-card)",
transition: "background 0.1s",
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "none"; }}
>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--primary)" }}>
View all {results.total} result{results.total !== 1 ? "s" : ""} for "{trimmed}"
</span>
<ArrowRight size={13} style={{ color: "var(--primary)", flexShrink: 0 }} />
</button>
</div>
</div>
);
}
/* ─────────────────────────────────────────
Logged-in user (top right)
───────────────────────────────────────── */
function userDisplayName(user: { firstName?: string; lastName?: string; email: string } | null): string {
if (!user) return "User";
if (user.firstName || user.lastName) return [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
return user.email?.split("@")[0] ?? "User";
}
function userInitials(user: { firstName?: string; lastName?: string; email: string } | null): string {
const name = userDisplayName(user);
return name.slice(0, 2).toUpperCase() || "?";
}
function formatPlanName(planId: string | undefined, catalog: SubscriptionPlanCatalog[]): string {
if (!planId) return "Free";
const p = catalog.find((x) => x.id === planId);
if (p?.name) return p.name;
return planId
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (ch) => ch.toUpperCase());
}
/** Org may omit maxParticipants; fall back to catalog plan defaults (e.g. free tier). */
function resolveEffectiveMaxParticipants(
org: OrganizationRecord,
catalog: SubscriptionPlanCatalog[]
): number | null {
const raw = org.subscription?.maxParticipants;
if (raw != null && Number.isFinite(Number(raw))) return Number(raw);
const planId = org.subscription?.plan ?? "free";
const plan = catalog.find((p) => p.id === planId);
const fromCatalog = plan?.defaultLimits?.maxParticipants;
if (fromCatalog != null && Number.isFinite(Number(fromCatalog))) return Number(fromCatalog);
return null;
}
function formatRenewalSuffix(expiresAt: string | undefined): { desktop: string; mobile: string; title: string } {
if (!expiresAt) return { desktop: "", mobile: "", title: "" };
const d = new Date(expiresAt);
if (Number.isNaN(d.getTime())) return { desktop: "", mobile: "", title: "" };
const long = d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
const short = d.toLocaleDateString("en-IN", { day: "numeric", month: "short" });
return {
desktop: ` · Renews ${long}`,
mobile: ` · ${short}`,
title: ` Renews on ${long}.`,
};
}
function NotificationsBell() {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState(true);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
return (
<div ref={wrapRef} style={{ position: "relative" }}>
<button
type="button"
style={{
position: "relative",
width: "32px",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: open ? "var(--secondary)" : "none",
border: "none",
cursor: "pointer",
color: open ? "var(--foreground)" : "var(--muted)",
borderRadius: "var(--radius-button)",
}}
aria-label="Notifications"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--secondary)";
(e.currentTarget as HTMLElement).style.color = "var(--foreground)";
}}
onMouseLeave={(e) => {
if (open) return;
(e.currentTarget as HTMLElement).style.background = "none";
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
}}
>
<Bell size={16} />
</button>
{open && (
<div
role="dialog"
aria-label="Notifications"
style={{
position: "absolute",
top: "36px",
right: 0,
width: "min(320px, calc(100vw - 24px))",
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-card)",
boxShadow: "var(--elevation-sm)",
zIndex: 250,
overflow: "hidden",
}}
>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "8px",
padding: "12px 14px",
background: "var(--secondary)",
border: "none",
borderBottom: expanded ? "1px solid var(--border)" : "none",
cursor: "pointer",
textAlign: "left",
}}
>
<span
style={{
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
}}
>
Notifications
</span>
<ChevronDown
size={14}
style={{
color: "var(--muted)",
transform: expanded ? "rotate(180deg)" : "none",
transition: "transform 0.15s",
flexShrink: 0,
}}
/>
</button>
{expanded && (
<div style={{ padding: "16px 14px" }}>
<p
style={{
margin: 0,
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
textAlign: "center",
}}
>
No new notifications
</p>
</div>
)}
</div>
)}
</div>
);
}
function LoggedInUserButton({ isMobile }: { isMobile: boolean }) {
const { user } = useAuth();
const { setActiveNav } = useAppNav();
const name = userDisplayName(user ?? null);
const initials = userInitials(user ?? null);
return (
<button
type="button"
aria-label={`Open settings for ${name}`}
title="Settings"
onClick={() => setActiveNav("settings")}
style={{ display: "flex", alignItems: "center", gap: isMobile ? "0" : "8px", padding: isMobile ? "4px" : "4px 8px 4px 4px", borderRadius: "var(--radius-button)", background: "none", border: "none", cursor: "pointer" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "none"; }}
>
<div style={{ width: "26px", height: "26px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)" }}>{initials}</span>
</div>
{!isMobile && (
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{name}</span>
)}
</button>
);
}
/* ─────────────────────────────────────────
TopBar
───────────────────────────────────────── */
export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPage }: TopBarProps) {
const { user } = useAuth();
const { setActiveNav, setSettingsInitialSection } = useAppNav();
const [query, setQuery] = useState("");
const [dropdownOpen, setDropdownOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [focused, setFocused] = useState(false);
const [orgSummary, setOrgSummary] = useState<OrganizationRecord | null>(null);
const [planCatalog, setPlanCatalog] = useState<SubscriptionPlanCatalog[]>([]);
/** Bumps when subscription/checkout updates org server-side so we refetch without relying on user._id alone. */
const [orgDataVersion, setOrgDataVersion] = useState(0);
const [orgSearchEvents, setOrgSearchEvents] = useState<SearchEvent[]>([]);
const bp = useBreakpoint();
const isMobile = bp === "mobile";
const isTablet = bp === "tablet";
const listOrgId = portfolioListOrganizationId(user);
const searchContainerRef = useRef<HTMLDivElement>(null);
const desktopInputRef = useRef<HTMLInputElement>(null);
const mobileInputRef = useRef<HTMLInputElement>(null);
/* ── Current-org events for search (never cross-tenant demo data) ── */
useEffect(() => {
if (!listOrgId) {
setOrgSearchEvents([]);
return;
}
let cancelled = false;
void (async () => {
const res = await eventsApi.list({
organization: listOrgId,
recordKind: "event",
page: 1,
limit: 200,
});
if (cancelled) return;
if (res.error || !res.data?.events) {
setOrgSearchEvents([]);
return;
}
setOrgSearchEvents(res.data.events.map(mapApiEventToSearchEvent));
})();
return () => {
cancelled = true;
};
}, [listOrgId]);
/* ── Search results ── */
const results: SearchResults | null = useMemo(
() => (query.trim() ? doSearch(query, orgSearchEvents) : null),
[query, orgSearchEvents],
);
/* ── Flat items for keyboard nav ── */
const flatItems = useMemo(() => {
if (!results) return [];
return [
...results.events.slice(0, 4).map(e => ({ type: "event" as const, data: e })),
...results.pages.slice(0, 5).map(p => ({ type: "page" as const, data: p })),
...results.actions.slice(0, 3).map(a => ({ type: "action" as const, data: a })),
];
}, [results]);
/* ── Click-outside to close dropdown ── */
useEffect(() => {
const handler = (e: MouseEvent) => {
if (searchContainerRef.current && !searchContainerRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
setActiveIndex(-1);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
useEffect(() => {
const bump = () => setOrgDataVersion((v) => v + 1);
window.addEventListener(ORGANIZATION_SUBSCRIPTION_UPDATED, bump);
return () => window.removeEventListener(ORGANIZATION_SUBSCRIPTION_UPDATED, bump);
}, []);
/* ── Plan + participant limit (organisation subscription) ── */
useEffect(() => {
if (!user?.organization?._id) {
setOrgSummary(null);
setPlanCatalog([]);
return;
}
let cancelled = false;
void (async () => {
try {
const [org, catalog] = await Promise.all([
organizationApi.get({ cacheBust: orgDataVersion > 0 }),
subscriptionApi.catalog().then((c) => c.plans).catch(() => [] as SubscriptionPlanCatalog[]),
]);
if (!cancelled) {
setOrgSummary(org);
setPlanCatalog(catalog);
}
} catch {
if (!cancelled) {
setOrgSummary(null);
setPlanCatalog([]);
}
}
})();
return () => {
cancelled = true;
};
}, [user?.organization?._id, orgDataVersion]);
const planChip = useMemo(() => {
if (!user?.organization?._id || !orgSummary) return null;
const planId = orgSummary.subscription?.plan;
const name = formatPlanName(planId, planCatalog);
const effectiveMax = resolveEffectiveMaxParticipants(orgSummary, planCatalog);
const part =
effectiveMax == null
? "Unlimited participants"
: `${effectiveMax.toLocaleString("en-IN")} participants max`;
const renew = formatRenewalSuffix(orgSummary.subscription?.expiresAt);
const label = isMobile
? `${name} · ${effectiveMax == null ? "∞" : effectiveMax.toLocaleString("en-IN")}${renew.mobile}`
: `${name} · ${part}${renew.desktop}`;
const title = `${name} — ${part}.${renew.title} Open Billing in Settings.`;
return { label, title };
}, [user?.organization?._id, orgSummary, planCatalog, isMobile]);
/* ── ⌘K / Ctrl+K global shortcut ── */
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
if (isMobile) {
setMobileSearchOpen(true);
setTimeout(() => mobileInputRef.current?.focus(), 50);
} else {
desktopInputRef.current?.focus();
if (query.trim()) setDropdownOpen(true);
}
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isMobile, query]);
/* ── Keyboard navigation inside input ── */
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, flatItems.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, -1));
} else if (e.key === "Enter") {
if (activeIndex >= 0 && flatItems[activeIndex]) {
const item = flatItems[activeIndex];
handleItemSelect(item);
} else if (query.trim()) {
handleViewAll();
}
} else if (e.key === "Escape") {
setDropdownOpen(false);
setActiveIndex(-1);
setMobileSearchOpen(false);
}
};
/* ── Navigate to selected item ── */
const handleItemSelect = (item: typeof flatItems[0]) => {
if (item.type === "event") {
onEventSelect?.((item.data as SearchEvent).id);
} else if (item.type === "page") {
onNavigate?.((item.data as SearchPage).navId);
} else {
onNavigate?.((item.data as SearchAction).navTarget);
}
closeSearch();
};
const handleEventClick = (e: SearchEvent) => { onEventSelect?.(e.id); closeSearch(); };
const handlePageClick = (p: SearchPage) => { onNavigate?.(p.navId); closeSearch(); };
const handleActionClick = (a: SearchAction) => { onNavigate?.(a.navTarget); closeSearch(); };
const handleViewAll = () => { onOpenSearchPage?.(query); closeSearch(); };
const closeSearch = () => {
setDropdownOpen(false);
setActiveIndex(-1);
setMobileSearchOpen(false);
setQuery("");
};
/* ─────────────────────────────────────
Render
───────────────────────────────────── */
return (
<>
<header
style={{
height: "52px",
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: isMobile ? "0 12px" : "0 24px",
background: "var(--card)",
borderBottom: "1px solid var(--border)",
flexShrink: 0, zIndex: 20, gap: "10px",
}}
>
{/* ── Left: hamburger + logo (mobile) + search (desktop) ── */}
<div style={{ display: "flex", alignItems: "center", gap: "10px", flex: 1, minWidth: 0 }}>
{/* Hamburger */}
{(isMobile || isTablet) && (
<button
onClick={onMenuClick}
style={{ width: "34px", height: "34px", display: "flex", alignItems: "center", justifyContent: "center", background: "none", border: "none", cursor: "pointer", color: "var(--muted)", borderRadius: "var(--radius-button)", flexShrink: 0 }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; (e.currentTarget as HTMLElement).style.color = "var(--foreground)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "none"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
aria-label="Open menu"
>
<Menu size={18} />
</button>
)}
{/* Logo — mobile only */}
{isMobile && <EventBuzzBrandLogo height={24} maxWidth={130} />}
{/* ── Desktop search with dropdown ── */}
{!isMobile && (
<div ref={searchContainerRef} style={{ position: "relative" }}>
{/* Input pill */}
<div
style={{
display: "flex", alignItems: "center", gap: "8px",
padding: "0 10px", height: "32px",
width: isTablet ? "220px" : "300px",
background: "var(--secondary)",
borderRadius: dropdownOpen ? "var(--radius-button) var(--radius-button) 0 0" : "var(--radius-button)",
borderWidth: "1px", borderStyle: "solid",
borderColor: focused || dropdownOpen ? "var(--ring)" : "transparent",
borderBottomColor: dropdownOpen ? "transparent" : (focused ? "var(--ring)" : "transparent"),
transition: "border-color 0.15s",
boxShadow: dropdownOpen ? "0 -1px 0 0 var(--ring)" : "none",
}}
>
<Search size={13} style={{ color: "var(--muted)", flexShrink: 0 }} />
<input
ref={desktopInputRef}
type="text"
value={query}
placeholder="Search events, pages, actions…"
onChange={e => { setQuery(e.target.value); setActiveIndex(-1); setDropdownOpen(!!e.target.value.trim()); }}
onFocus={() => { setFocused(true); if (query.trim()) setDropdownOpen(true); }}
onBlur={() => setFocused(false)}
onKeyDown={handleKeyDown}
style={{ background: "transparent", border: "none", outline: "none", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", width: "100%" }}
/>
{query ? (
<button onClick={() => { setQuery(""); setActiveIndex(-1); setDropdownOpen(false); }} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: "0", flexShrink: 0 }}>
<X size={12} />
</button>
) : (
!isTablet && (
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", flexShrink: 0, background: "var(--border)", padding: "1px 5px", borderRadius: "3px" }}>⌘K</span>
)
)}
</div>
{/* ── Dropdown (only when there is a query) ── */}
{dropdownOpen && query.trim() && (
<div
style={{
position: "absolute", top: "32px", left: 0,
width: isTablet ? "340px" : "420px",
background: "var(--card)",
borderWidth: "1px", borderStyle: "solid", borderColor: "var(--ring)",
borderTop: "none",
borderRadius: "0 var(--radius-card) var(--radius-card) var(--radius-card)",
boxShadow: "var(--elevation-sm)",
zIndex: 200,
maxHeight: "440px", overflowY: "auto",
}}
>
<SearchDropdown
query={query}
results={results}
activeIndex={activeIndex}
onEventClick={handleEventClick}
onPageClick={handlePageClick}
onActionClick={handleActionClick}
onViewAll={handleViewAll}
/>
</div>
)}
</div>
)}
</div>
{/* ── Right: icons ── */}
<div style={{ display: "flex", alignItems: "center", gap: "4px", flexShrink: 0 }}>
{/* Mobile search icon */}
{isMobile && (
<button
aria-label="Search"
onClick={() => { setMobileSearchOpen(true); setTimeout(() => mobileInputRef.current?.focus(), 60); }}
style={{ width: "34px", height: "34px", display: "flex", alignItems: "center", justifyContent: "center", background: "none", border: "none", cursor: "pointer", color: "var(--muted)", borderRadius: "var(--radius-button)" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; (e.currentTarget as HTMLElement).style.color = "var(--foreground)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "none"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
>
<Search size={16} />
</button>
)}
{planChip && (
<button
type="button"
aria-label={planChip.title}
title={planChip.title}
onClick={() => {
setSettingsInitialSection("billing");
setActiveNav("settings");
}}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
maxWidth: isMobile ? "min(46vw, 200px)" : "none",
padding: "4px 10px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--secondary)",
cursor: "pointer",
flexShrink: 1,
minWidth: 0,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--accent)";
e.currentTarget.style.borderColor = "var(--primary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "var(--secondary)";
e.currentTarget.style.borderColor = "var(--border)";
}}
>
<CreditCard size={14} style={{ color: "var(--primary)", flexShrink: 0 }} aria-hidden />
<span
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
fontWeight: 600,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
textAlign: "left",
color: "var(--primary)",
}}
>
{planChip.label}
</span>
</button>
)}
<NotificationsBell />
<div style={{ width: "1px", height: "20px", background: "var(--border)", margin: "0 2px" }} />
{/* User */}
<LoggedInUserButton isMobile={isMobile} />
</div>
</header>
{/* ── Mobile search overlay ── */}
{mobileSearchOpen && (
<div
style={{ position: "fixed", inset: 0, zIndex: 300, display: "flex", flexDirection: "column" }}
>
{/* Backdrop */}
<div
onClick={() => setMobileSearchOpen(false)}
style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.5)" }}
/>
{/* Panel */}
<div style={{ position: "relative", background: "var(--card)", boxShadow: "var(--elevation-sm)", display: "flex", flexDirection: "column", maxHeight: "90vh" }}>
{/* Search input bar */}
<div style={{ display: "flex", alignItems: "center", gap: "10px", padding: "10px 14px", borderBottom: "1px solid var(--border)" }}>
<Search size={16} style={{ color: "var(--muted)", flexShrink: 0 }} />
<input
ref={mobileInputRef}
type="text"
value={query}
placeholder="Search events, pages, actions…"
onChange={e => { setQuery(e.target.value); setActiveIndex(-1); }}
onKeyDown={handleKeyDown}
style={{ flex: 1, background: "transparent", border: "none", outline: "none", fontSize: "var(--text-base)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}
/>
{query && (
<button onClick={() => setQuery("")} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: "2px" }}>
<X size={14} />
</button>
)}
<button
onClick={() => { setMobileSearchOpen(false); setQuery(""); }}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--primary)", padding: "4px 0 4px 4px", flexShrink: 0 }}
>
Cancel
</button>
</div>
{/* Dropdown results — only when typing */}
{query.trim() ? (
<div style={{ overflowY: "auto", flex: 1 }}>
<SearchDropdown
query={query}
results={results}
activeIndex={activeIndex}
onEventClick={handleEventClick}
onPageClick={handlePageClick}
onActionClick={handleActionClick}
onViewAll={handleViewAll}
/>
</div>
) : null}
</div>
</div>
)}
</>
);
}