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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user