feat(26-03): rewrite landing page as public discovery page

- Replace DashboardPage with LandingPage using discovery hooks
- Add HeroSection with Discover Gear heading and catalog search trigger
- Add PopularSetupsSection using useDiscoverySetups with PublicSetupCard
- Add RecentItemsSection using useDiscoveryItems with GlobalItemCard
- Add TrendingCategoriesSection using useDiscoveryCategories with pills
- Conditional Go to Collection CTA for authenticated users
- Loading skeletons with animate-pulse for all three sections
- Empty state handling: sections return null when no data
- SectionSkeleton helper for consistent loading states
- All clickable elements have cursor-pointer
This commit is contained in:
2026-04-10 15:01:49 +02:00
parent 0bf1c68043
commit 8aaf4352ed

View File

@@ -1,61 +1,191 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { DashboardCard } from "../components/DashboardCard"; import { Search } from "lucide-react";
import { useFormatters } from "../hooks/useFormatters"; import { GlobalItemCard } from "../components/GlobalItemCard";
import { useSetups } from "../hooks/useSetups"; import { PublicSetupCard } from "../components/PublicSetupCard";
import { useThreads } from "../hooks/useThreads"; import { useAuth } from "../hooks/useAuth";
import { useTotals } from "../hooks/useTotals"; import {
useDiscoveryCategories,
useDiscoveryItems,
useDiscoverySetups,
} from "../hooks/useDiscovery";
import { useUIStore } from "../stores/uiStore";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: DashboardPage, component: LandingPage,
}); });
function DashboardPage() { function LandingPage() {
const { data: totals } = useTotals(); const { data: auth } = useAuth();
const { data: threads } = useThreads(false); const isAuthenticated = !!auth?.user;
const { data: setups } = useSetups(); const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
const { weight, price } = useFormatters();
const global = totals?.global;
const activeThreadCount = threads?.length ?? 0;
const setupCount = setups?.length ?? 0;
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <HeroSection
<DashboardCard isAuthenticated={isAuthenticated}
to="/collection" onSearchFocus={() => openCatalogSearch("collection")}
title="Collection" />
icon="backpack" <PopularSetupsSection />
stats={[ <RecentItemsSection />
{ label: "Items", value: String(global?.itemCount ?? 0) }, <TrendingCategoriesSection />
{ </div>
label: "Weight", );
value: weight(global?.totalWeight ?? null), }
},
{ function HeroSection({
label: "Cost", isAuthenticated,
value: price(global?.totalCost ?? null), onSearchFocus,
}, }: {
]} isAuthenticated: boolean;
emptyText="Get started" onSearchFocus: () => void;
/> }) {
<DashboardCard return (
to="/collection" <div className="text-center mb-16">
search={{ tab: "planning" }} <h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-2">
title="Planning" Discover Gear
icon="search" </h1>
stats={[ <p className="text-gray-500 mb-8">Browse what other people carry</p>
{ label: "Active threads", value: String(activeThreadCount) }, <div
]} onClick={onSearchFocus}
/> onKeyDown={(e) => e.key === "Enter" && onSearchFocus()}
<DashboardCard role="button"
to="/collection" tabIndex={0}
search={{ tab: "setups" }} className="max-w-xl mx-auto flex items-center gap-3 px-4 py-3 bg-white rounded-xl border border-gray-200 hover:border-gray-300 cursor-pointer shadow-sm transition-all"
title="Setups" >
icon="tent" <Search className="w-5 h-5 text-gray-400 shrink-0" />
stats={[{ label: "Setups", value: String(setupCount) }]} <span className="text-sm text-gray-400 flex-1 text-left">
/> Search the catalog...
</div> </span>
</div>
{isAuthenticated && (
<div className="mt-4">
<Link
to="/collection"
className="text-sm text-gray-600 hover:text-gray-900 underline underline-offset-2 cursor-pointer"
>
Go to Collection
</Link>
</div>
)}
</div>
);
}
function PopularSetupsSection() {
const { data, isLoading } = useDiscoverySetups(6);
const setups = data?.items ?? [];
if (!isLoading && setups.length === 0) return null;
return (
<section className="mb-12">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Popular Setups</h2>
</div>
{isLoading ? (
<SectionSkeleton count={6} aspect="none" />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{setups.map((setup) => (
<PublicSetupCard key={setup.id} setup={setup} />
))}
</div>
)}
</section>
);
}
function RecentItemsSection() {
const { data, isLoading } = useDiscoveryItems(8);
const items = data?.items ?? [];
if (!isLoading && items.length === 0) return null;
return (
<section className="mb-12">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Recently Added</h2>
</div>
{isLoading ? (
<SectionSkeleton count={8} aspect="[4/3]" />
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{items.map((item) => (
<GlobalItemCard
key={item.id}
id={item.id}
brand={item.brand}
model={item.model}
category={item.category}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
imageUrl={item.imageUrl}
/>
))}
</div>
)}
</section>
);
}
function TrendingCategoriesSection() {
const { data, isLoading } = useDiscoveryCategories(12);
const categories = data ?? [];
if (!isLoading && categories.length === 0) return null;
return (
<section className="mb-12">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">
Trending Categories
</h2>
</div>
{isLoading ? (
<div className="flex flex-wrap gap-2">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="h-8 w-24 bg-gray-100 rounded-full animate-pulse"
/>
))}
</div>
) : (
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<span
key={cat.name}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-gray-50 text-gray-700 border border-gray-100 hover:border-gray-200 hover:bg-gray-100 transition-all cursor-pointer"
>
{cat.name}
<span className="text-xs text-gray-400">{cat.itemCount}</span>
</span>
))}
</div>
)}
</section>
);
}
function SectionSkeleton({ count, aspect }: { count: number; aspect: string }) {
return (
<div
className={`grid ${aspect === "none" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4"} gap-4`}
>
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="bg-white rounded-xl border border-gray-100 overflow-hidden animate-pulse"
>
{aspect !== "none" && (
<div className={`aspect-${aspect} bg-gray-100`} />
)}
<div className="p-4 space-y-2">
<div className="h-3 bg-gray-100 rounded w-16" />
<div className="h-4 bg-gray-100 rounded w-32" />
</div>
</div>
))}
</div> </div>
); );
} }