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:
@@ -1,61 +1,191 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { DashboardCard } from "../components/DashboardCard";
|
||||
import { useFormatters } from "../hooks/useFormatters";
|
||||
import { useSetups } from "../hooks/useSetups";
|
||||
import { useThreads } from "../hooks/useThreads";
|
||||
import { useTotals } from "../hooks/useTotals";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Search } from "lucide-react";
|
||||
import { GlobalItemCard } from "../components/GlobalItemCard";
|
||||
import { PublicSetupCard } from "../components/PublicSetupCard";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import {
|
||||
useDiscoveryCategories,
|
||||
useDiscoveryItems,
|
||||
useDiscoverySetups,
|
||||
} from "../hooks/useDiscovery";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: DashboardPage,
|
||||
component: LandingPage,
|
||||
});
|
||||
|
||||
function DashboardPage() {
|
||||
const { data: totals } = useTotals();
|
||||
const { data: threads } = useThreads(false);
|
||||
const { data: setups } = useSetups();
|
||||
const { weight, price } = useFormatters();
|
||||
|
||||
const global = totals?.global;
|
||||
const activeThreadCount = threads?.length ?? 0;
|
||||
const setupCount = setups?.length ?? 0;
|
||||
function LandingPage() {
|
||||
const { data: auth } = useAuth();
|
||||
const isAuthenticated = !!auth?.user;
|
||||
const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<DashboardCard
|
||||
to="/collection"
|
||||
title="Collection"
|
||||
icon="backpack"
|
||||
stats={[
|
||||
{ label: "Items", value: String(global?.itemCount ?? 0) },
|
||||
{
|
||||
label: "Weight",
|
||||
value: weight(global?.totalWeight ?? null),
|
||||
},
|
||||
{
|
||||
label: "Cost",
|
||||
value: price(global?.totalCost ?? null),
|
||||
},
|
||||
]}
|
||||
emptyText="Get started"
|
||||
/>
|
||||
<DashboardCard
|
||||
to="/collection"
|
||||
search={{ tab: "planning" }}
|
||||
title="Planning"
|
||||
icon="search"
|
||||
stats={[
|
||||
{ label: "Active threads", value: String(activeThreadCount) },
|
||||
]}
|
||||
/>
|
||||
<DashboardCard
|
||||
to="/collection"
|
||||
search={{ tab: "setups" }}
|
||||
title="Setups"
|
||||
icon="tent"
|
||||
stats={[{ label: "Setups", value: String(setupCount) }]}
|
||||
/>
|
||||
</div>
|
||||
<HeroSection
|
||||
isAuthenticated={isAuthenticated}
|
||||
onSearchFocus={() => openCatalogSearch("collection")}
|
||||
/>
|
||||
<PopularSetupsSection />
|
||||
<RecentItemsSection />
|
||||
<TrendingCategoriesSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroSection({
|
||||
isAuthenticated,
|
||||
onSearchFocus,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
onSearchFocus: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-2">
|
||||
Discover Gear
|
||||
</h1>
|
||||
<p className="text-gray-500 mb-8">Browse what other people carry</p>
|
||||
<div
|
||||
onClick={onSearchFocus}
|
||||
onKeyDown={(e) => e.key === "Enter" && onSearchFocus()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
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"
|
||||
>
|
||||
<Search className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
<span className="text-sm text-gray-400 flex-1 text-left">
|
||||
Search the catalog...
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user