EventBuzz_Website/src/components/HyperNavigationFAB.tsx

142 lines
6.2 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Gift, Home, MessageCircle, MessageCircleQuestion, Star, Trophy, Users } from "lucide-react";
import { Link } from "react-router-dom";
import { readStoredVisitorContext, visitorEventUrl } from "../types/visitorContext";
type HyperNavigationFABProps = {
/** Preferred; falls back to stored visitor context `eventId`. */
eventId?: string;
/** When set (e.g. on session page), matches Audience Questions “View All” (`/questions?event_id&session_id`). */
sessionId?: string;
/** When true, add Feedback to the menu (session page + event allows feedback + session flag). */
showFeedbackNav?: boolean;
/** When true, add Ratings to the menu (session page + event ratings on + session flag). */
showRatingsNav?: boolean;
/** When set with showFeedbackNav, append form_id to the feedback link. */
feedbackFormId?: string;
/** When set with showRatingsNav, append form_id to the ratings link. */
ratingsFormId?: string;
};
type MenuItem = {
label: string;
to: string;
icon: ReactNode;
};
/** Fixed to viewport bottom-right, aligned with `MobileShell` max width (402px). Opens actions above the FAB. */
export function HyperNavigationFAB({
eventId: eventIdProp,
sessionId: sessionIdProp,
showFeedbackNav = false,
showRatingsNav = false,
feedbackFormId,
ratingsFormId,
}: HyperNavigationFABProps) {
const ctx = readStoredVisitorContext();
const eventId = (eventIdProp ?? ctx?.eventId ?? "").trim();
const sessionId = (sessionIdProp ?? ctx?.sessionId ?? "").trim();
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const eventHome = eventId ? visitorEventUrl(eventId) : "/";
const rewards =
eventId ? `/rewards?${new URLSearchParams({ event_id: eventId }).toString()}` : "/rewards";
const leaderboard =
eventId ? `/leaderboard?${new URLSearchParams({ event_id: eventId }).toString()}` : "/leaderboard";
const allQuestionsQs = new URLSearchParams();
if (eventId) allQuestionsQs.set("event_id", eventId);
if (sessionId) allQuestionsQs.set("session_id", sessionId);
const allQuestions =
eventId ? `/questions?${allQuestionsQs.toString()}` : "/questions";
const feedbackQs = new URLSearchParams();
if (eventId) feedbackQs.set("event_id", eventId);
if (sessionId) feedbackQs.set("session_id", sessionId);
if (feedbackFormId?.trim()) feedbackQs.set("form_id", feedbackFormId.trim());
const feedbackTo = eventId ? `/feedback?${feedbackQs.toString()}` : "/feedback";
const ratingsQs = new URLSearchParams();
if (eventId) ratingsQs.set("event_id", eventId);
if (sessionId) ratingsQs.set("session_id", sessionId);
if (ratingsFormId?.trim()) ratingsQs.set("form_id", ratingsFormId.trim());
const ratingsTo = eventId ? `/ratings?${ratingsQs.toString()}` : "/ratings";
const items: MenuItem[] = [
{ label: "Event Home", to: eventHome, icon: <Home className="size-4 shrink-0 text-[#1e72ef]" strokeWidth={1.75} /> },
{ label: "Rewards", to: rewards, icon: <Gift className="size-4 shrink-0 text-[#1e72ef]" strokeWidth={1.75} /> },
{ label: "Leaderboard", to: leaderboard, icon: <Trophy className="size-4 shrink-0 text-[#1e72ef]" strokeWidth={1.75} /> },
...(showFeedbackNav
? [{ label: "Feedback", to: feedbackTo, icon: <MessageCircle className="size-4 shrink-0 text-[#1e72ef]" strokeWidth={1.75} /> }]
: []),
...(showRatingsNav
? [{ label: "Ratings", to: ratingsTo, icon: <Star className="size-4 shrink-0 text-[#1e72ef]" strokeWidth={1.75} /> }]
: []),
{
label: "All Questions",
to: allQuestions,
icon: <MessageCircleQuestion className="size-4 shrink-0 text-[#1e72ef]" strokeWidth={1.75} />,
},
];
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const close = useCallback(() => setOpen(false), []);
return (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex justify-center pb-6">
<div className="pointer-events-none flex w-full max-w-[402px] justify-end pr-4">
<div ref={wrapRef} className="relative flex flex-col items-end pointer-events-auto">
{open ? (
<div
className="mb-2 w-[min(280px,calc(100vw-2rem))] divide-y divide-[#f2f2f7] overflow-hidden rounded-xl border border-[rgba(116,112,112,0.2)] bg-white shadow-[0_8px_24px_rgba(12,12,13,0.12)]"
role="menu"
aria-label="Quick navigation"
>
{items.map((item) => (
<Link
key={item.label}
to={item.to}
role="menuitem"
onClick={close}
className="flex items-center gap-3 px-4 py-3 text-left text-sm font-medium text-[#24282f] transition-colors hover:bg-[#f2f2f7] active:bg-[#e5e5ea]"
>
{item.icon}
{item.label}
</Link>
))}
</div>
) : null}
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="menu"
aria-label={open ? "Close quick navigation" : "Open quick navigation"}
className="relative flex size-12 shrink-0 items-center justify-center rounded-full border-2 border-white text-white shadow-[0_14px_18px_rgba(0,0,0,0.2),0_2px_3px_rgba(0,0,0,0.17)] eb-gradient-fab"
>
<span
className="pointer-events-none absolute inset-[-2px] rounded-full shadow-[inset_0_6px_4px_rgba(0,0,0,0.25)]"
aria-hidden
/>
<Users className={`relative z-[1] size-7 transition-transform ${open ? "scale-90" : ""}`} strokeWidth={1.75} />
</button>
</div>
</div>
</div>
);
}