886 lines
38 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|