import { createFileRoute } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; import { useMemo, useRef, useState } from "react"; import { z } from "zod"; import { CategoryFilterDropdown } from "../../components/CategoryFilterDropdown"; import { CategoryHeader } from "../../components/CategoryHeader"; import { CreateThreadModal } from "../../components/CreateThreadModal"; import { ItemCard } from "../../components/ItemCard"; import { SetupCard } from "../../components/SetupCard"; import { ThreadCard } from "../../components/ThreadCard"; import { useCategories } from "../../hooks/useCategories"; import { useCurrency } from "../../hooks/useCurrency"; import { useItems } from "../../hooks/useItems"; import { useCreateSetup, useSetups } from "../../hooks/useSetups"; import { useThreads } from "../../hooks/useThreads"; import { useTotals } from "../../hooks/useTotals"; import { useWeightUnit } from "../../hooks/useWeightUnit"; import { formatPrice, formatWeight } from "../../lib/formatters"; import { LucideIcon } from "../../lib/iconData"; import { useUIStore } from "../../stores/uiStore"; const searchSchema = z.object({ tab: z.enum(["gear", "planning", "setups"]).catch("gear"), }); export const Route = createFileRoute("/collection/")({ validateSearch: searchSchema, component: CollectionPage, }); const TAB_ORDER = ["gear", "planning", "setups"] as const; const slideVariants = { enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }), center: { x: 0, opacity: 1 }, exit: (dir: number) => ({ x: `${dir * -15}%`, opacity: 0 }), }; function CollectionPage() { const { tab } = Route.useSearch(); const prevTab = useRef(tab); const direction = TAB_ORDER.indexOf(tab) >= TAB_ORDER.indexOf(prevTab.current) ? 1 : -1; prevTab.current = tab; return (
{tab === "gear" ? ( ) : tab === "planning" ? ( ) : ( )}
); } function CollectionView() { const { data: items, isLoading: itemsLoading } = useItems(); const { data: totals } = useTotals(); const { data: categories } = useCategories(); const unit = useWeightUnit(); const currency = useCurrency(); const openAddPanel = useUIStore((s) => s.openAddPanel); const [searchText, setSearchText] = useState(""); const [categoryFilter, setCategoryFilter] = useState(null); const filteredItems = useMemo(() => { if (!items) return []; return items.filter((item) => { const matchesSearch = searchText === "" || item.name.toLowerCase().includes(searchText.toLowerCase()); const matchesCategory = categoryFilter === null || item.categoryId === categoryFilter; return matchesSearch && matchesCategory; }); }, [items, searchText, categoryFilter]); const hasActiveFilters = searchText !== "" || categoryFilter !== null; if (itemsLoading) { return (
{[1, 2, 3].map((i) => (
))}
); } if (!items || items.length === 0) { return (

Your collection is empty

Start cataloging your gear by adding your first item. Track weight, price, and organize by category.

); } // Build category totals lookup const categoryTotalsMap = new Map< number, { totalWeight: number; totalCost: number; itemCount: number } >(); if (totals?.categories) { for (const ct of totals.categories) { categoryTotalsMap.set(ct.categoryId, { totalWeight: ct.totalWeight, totalCost: ct.totalCost, itemCount: ct.itemCount, }); } } // Group filtered items by categoryId (used when no active filters) const groupedItems = new Map< number, { items: typeof filteredItems; categoryName: string; categoryIcon: string; } >(); for (const item of filteredItems) { const group = groupedItems.get(item.categoryId); if (group) { group.items.push(item); } else { groupedItems.set(item.categoryId, { items: [item], categoryName: item.categoryName, categoryIcon: item.categoryIcon, }); } } return ( <> {/* Collection stats card */} {totals?.global && (
Items {totals.global.itemCount}
Total Weight {formatWeight(totals.global.totalWeight, unit)}
Total Spent {formatPrice(totals.global.totalCost, currency)}
)} {/* Search/filter toolbar */}
setSearchText(e.target.value)} className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" /> {searchText && ( )}
{hasActiveFilters && (

Showing {filteredItems.length} of {items.length} items

)}
{/* Filtered results */} {hasActiveFilters ? ( filteredItems.length === 0 ? (

No items match your search

) : (
{filteredItems.map((item) => ( ))}
) ) : ( Array.from(groupedItems.entries()).map( ([ categoryId, { items: categoryItems, categoryName, categoryIcon }, ]) => { const catTotals = categoryTotalsMap.get(categoryId); return (
{categoryItems.map((item) => ( ))}
); }, ) )} ); } function PlanningView() { const [activeTab, setActiveTab] = useState<"active" | "resolved">("active"); const [categoryFilter, setCategoryFilter] = useState(null); const openCreateThreadModal = useUIStore((s) => s.openCreateThreadModal); const { data: categories } = useCategories(); const { data: threads, isLoading } = useThreads(activeTab === "resolved"); if (isLoading) { return (
{[1, 2].map((i) => (
))}
); } // Filter threads by active tab and category const filteredThreads = (threads ?? []) .filter((t) => t.status === activeTab) .filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true)); // Determine if we should show the educational empty state const isEmptyNoFilters = filteredThreads.length === 0 && activeTab === "active" && categoryFilter === null && (!threads || threads.length === 0); return (
{/* Header row */}

Planning Threads

{/* Filter row */}
{/* Pill tabs */}
{/* Category filter */}
{/* Content: empty state or thread grid */} {isEmptyNoFilters ? (

Plan your next purchase

1

Create a thread

Start a research thread for gear you're considering

2

Add candidates

Add products you're comparing with prices and weights

3

Pick a winner

Resolve the thread and the winner joins your collection

) : filteredThreads.length === 0 ? (

No threads found

) : (
{filteredThreads.map((thread) => ( ))}
)}
); } function SetupsView() { const [newSetupName, setNewSetupName] = useState(""); const { data: setups, isLoading } = useSetups(); const createSetup = useCreateSetup(); function handleCreateSetup(e: React.FormEvent) { e.preventDefault(); const name = newSetupName.trim(); if (!name) return; createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") }); } return (
{/* Create setup form */}
setNewSetupName(e.target.value)} placeholder="New setup name..." className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" />
{/* Loading skeleton */} {isLoading && (
{[1, 2].map((i) => (
))}
)} {/* Empty state */} {!isLoading && (!setups || setups.length === 0) && (

Build your perfect loadout

1

Create a setup

Name your loadout for a specific trip or activity

2

Add items

Pick gear from your collection to include in the setup

3

Track weight

See weight breakdown and optimize your pack

)} {/* Setup grid */} {!isLoading && setups && setups.length > 0 && (
{setups.map((setup) => ( ))}
)}
); }