import { createRootRoute, type ErrorComponentProps, Outlet, useLocation, useMatchRoute, useNavigate, useRouter, } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Toaster } from "sonner"; import "../app.css"; import { AddToCollectionModal } from "../components/AddToCollectionModal"; import { AddToThreadModal } from "../components/AddToThreadModal"; import { AuthPromptModal } from "../components/AuthPromptModal"; import { BottomTabBar } from "../components/BottomTabBar"; import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay"; import { ConfirmDialog } from "../components/ConfirmDialog"; import { ExternalLinkDialog } from "../components/ExternalLinkDialog"; import { FabMenu } from "../components/FabMenu"; import { OnboardingFlow } from "../components/onboarding/OnboardingFlow"; import { TopNav } from "../components/TopNav"; import { useAuth } from "../hooks/useAuth"; import { useDeleteCandidate } from "../hooks/useCandidates"; import { useLanguage } from "../hooks/useLanguage"; import { useOnboardingComplete } from "../hooks/useSettings"; import { useResolveThread, useThread } from "../hooks/useThreads"; import { useUIStore } from "../stores/uiStore"; export const Route = createRootRoute({ component: RootLayout, errorComponent: RootErrorBoundary, }); function RootErrorBoundary({ error, reset }: ErrorComponentProps) { const router = useRouter(); const { t } = useTranslation(); return (

{t("errors.somethingWentWrong")}

{error instanceof Error ? error.message : t("errors.unexpectedError")}

); } function RootLayout() { const navigate = useNavigate(); const location = useLocation(); const { data: auth, isLoading: authLoading } = useAuth(); const isAuthenticated = !!auth?.user; const language = useLanguage(); const { i18n } = useTranslation(); // Sync i18n language with persisted setting useEffect(() => { if (language && i18n.language !== language) { i18n.changeLanguage(language); } }, [language, i18n]); // Candidate delete state const confirmDeleteCandidateId = useUIStore( (s) => s.confirmDeleteCandidateId, ); const closeConfirmDeleteCandidate = useUIStore( (s) => s.closeConfirmDeleteCandidate, ); // Resolution dialog state const resolveThreadId = useUIStore((s) => s.resolveThreadId); const resolveCandidateId = useUIStore((s) => s.resolveCandidateId); const closeResolveDialog = useUIStore((s) => s.closeResolveDialog); // Onboarding — only check when authenticated (endpoint requires auth) const { data: onboardingComplete, isLoading: onboardingLoading } = useOnboardingComplete(isAuthenticated); const [wizardDismissed, setWizardDismissed] = useState(false); // Don't show onboarding wizard until user has created an account const showWizard = !onboardingLoading && onboardingComplete !== "true" && !wizardDismissed && isAuthenticated; // Route matching for contextual behavior const matchRoute = useMatchRoute(); const threadMatch = matchRoute({ to: "/threads/$threadId", fuzzy: true, }) as { threadId?: string } | false; const currentThreadId = threadMatch ? Number(threadMatch.threadId) : null; // Allow public routes through without auth const searchParams = new URLSearchParams(location.search); const isPublicRoute = location.pathname === "/" || location.pathname.startsWith("/users/") || location.pathname.startsWith("/global-items") || location.pathname === "/setups" || location.pathname.startsWith("/setups/") || location.pathname === "/login" || (location.pathname.startsWith("/items/") && (searchParams.has("setup") || searchParams.has("share"))); // FAB visibility: show on all authenticated, non-public routes const isSetupsPage = !!matchRoute({ to: "/setups", fuzzy: true }); const showFab = isAuthenticated && !isPublicRoute; if (!isAuthenticated && !isPublicRoute && !authLoading) { navigate({ to: "/login" }); return null; } return (
{/* Item Confirm Delete Dialog */} {/* External Link Confirmation Dialog */} {/* Candidate Delete Confirm Dialog */} {confirmDeleteCandidateId != null && currentThreadId != null && ( )} {/* Resolution Confirm Dialog */} {resolveThreadId != null && resolveCandidateId != null && ( { closeResolveDialog(); navigate({ to: "/collection", search: { tab: "planning" } }); }} /> )} {/* Floating Action Button */} {showFab && (
)} {/* Bottom Tab Bar (mobile only) */} {/* Catalog Search Overlay */} {/* Add to Collection Modal */} {/* Add to Thread Modal */} {/* Auth Prompt Modal */} {/* Onboarding Flow */} {showWizard && ( setWizardDismissed(true)} /> )}
); } function CandidateDeleteDialog({ candidateId, threadId, onClose, }: { candidateId: number; threadId: number; onClose: () => void; }) { const { t } = useTranslation(); const deleteCandidate = useDeleteCandidate(threadId); const { data: thread } = useThread(threadId); const candidate = thread?.candidates.find((c) => c.id === candidateId); const candidateName = candidate?.name ?? "this candidate"; function handleDelete() { deleteCandidate.mutate(candidateId, { onSuccess: () => onClose(), }); } return (
{ if (e.key === "Escape") onClose(); }} />

{t("confirm.deleteCandidate")}

{t("confirm.deleteCandidateMessage", { name: candidateName })}

); } function ResolveDialog({ threadId, candidateId, onClose, onResolved, }: { threadId: number; candidateId: number; onClose: () => void; onResolved: () => void; }) { const { t } = useTranslation(); const resolveThread = useResolveThread(); const { data: thread } = useThread(threadId); const candidate = thread?.candidates.find((c) => c.id === candidateId); const candidateName = candidate?.name ?? "this candidate"; function handleResolve() { resolveThread.mutate( { threadId, candidateId }, { onSuccess: () => onResolved() }, ); } return (
{ if (e.key === "Escape") onClose(); }} />

{t("confirm.pickWinner")}

{t("confirm.pickWinnerMessage", { name: candidateName })}

); }