142 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|