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 */}
{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 (
);
}
// 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 ? (
) : (
{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 */}
{/* Loading skeleton */}
{isLoading && (
)}
{/* 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) => (
))}
)}
);
}