feat: initial commit — storefront, convex backend, and shared packages
Completes the first milestone of The Pet Loft ecommerce platform: - apps/storefront: full customer-facing Next.js app with HeroUI (cart, checkout, orders, wishlist, product detail, shop, search, auth) - convex/: serverless backend with schema, queries, mutations, actions, HTTP routes, Stripe/Shippo integrations, and co-located tests - packages/types, packages/utils, packages/convex: shared workspace packages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
181
apps/storefront/src/components/shop/ShopIndexContent.tsx
Normal file
181
apps/storefront/src/components/shop/ShopIndexContent.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { api } from "../../../../../convex/_generated/api";
|
||||
import { ShopBreadcrumbBar } from "@/components/shop/ShopBreadcrumbBar";
|
||||
import { ShopCategoryStrip } from "@/components/shop/ShopCategoryStrip";
|
||||
import { ShopFilterModal } from "@/components/shop/ShopFilterModal";
|
||||
import { ShopFilterSidebar } from "@/components/shop/ShopFilterSidebar";
|
||||
import { ShopPageBanner } from "@/components/shop/ShopPageBanner";
|
||||
import { ShopProductGrid } from "@/components/shop/ShopProductGrid";
|
||||
import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
|
||||
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
|
||||
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
|
||||
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
|
||||
import {
|
||||
filterStateFromSearchParams,
|
||||
filterStateToSearchParams,
|
||||
shopFilterStateToApiArgs,
|
||||
} from "@/lib/shop/filterState";
|
||||
import {
|
||||
enrichedProductToCardProps,
|
||||
type EnrichedProduct,
|
||||
} from "@/lib/shop/productMapper";
|
||||
import {
|
||||
PET_CATEGORY_SLUGS,
|
||||
TOP_CATEGORY_SLUGS,
|
||||
} from "@/lib/shop/constants";
|
||||
|
||||
const SORT_OPTIONS: SortOption[] = [
|
||||
{ value: "newest", label: "Newest" },
|
||||
{ value: "price-asc", label: "Price: Low to High" },
|
||||
{ value: "price-desc", label: "Price: High to Low" },
|
||||
];
|
||||
|
||||
function formatCategoryLabel(slug: string): string {
|
||||
if (slug === "other-pets") return "Other Pets";
|
||||
return slug.charAt(0).toUpperCase() + slug.slice(1);
|
||||
}
|
||||
|
||||
export function ShopIndexContent() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [sort, setSort] = useState<string>("newest");
|
||||
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
||||
|
||||
const filterState = useMemo(
|
||||
() => filterStateFromSearchParams(searchParams),
|
||||
[searchParams],
|
||||
);
|
||||
const filterArgs = useMemo(() => shopFilterStateToApiArgs(filterState), [filterState]);
|
||||
|
||||
const filterOptions = useQuery(api.products.getFilterOptions, {});
|
||||
const products = useQuery(api.products.listActive, filterArgs);
|
||||
|
||||
const setFilterStateToUrl = useCallback(
|
||||
(state: typeof filterState) => {
|
||||
const params = filterStateToSearchParams(state);
|
||||
const q = params.toString();
|
||||
router.replace(q ? `${pathname}?${q}` : pathname);
|
||||
},
|
||||
[pathname, router],
|
||||
);
|
||||
|
||||
const stripLinks = useMemo(
|
||||
() => [
|
||||
...PET_CATEGORY_SLUGS.map((slug) => ({
|
||||
label: formatCategoryLabel(slug),
|
||||
href: `/shop/${slug}`,
|
||||
})),
|
||||
...TOP_CATEGORY_SLUGS.map((slug) => ({
|
||||
label: formatCategoryLabel(slug),
|
||||
href: `/shop/${slug}`,
|
||||
})),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const sortedProducts = useMemo(() => {
|
||||
if (!products || !Array.isArray(products)) return [];
|
||||
const list = [...products] as EnrichedProduct[];
|
||||
if (sort === "newest") {
|
||||
list.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
|
||||
} else if (sort === "price-asc") {
|
||||
list.sort((a, b) => {
|
||||
const pa = a.variants?.[0]?.price ?? 0;
|
||||
const pb = b.variants?.[0]?.price ?? 0;
|
||||
return pa - pb;
|
||||
});
|
||||
} else if (sort === "price-desc") {
|
||||
list.sort((a, b) => {
|
||||
const pa = a.variants?.[0]?.price ?? 0;
|
||||
const pb = b.variants?.[0]?.price ?? 0;
|
||||
return pb - pa;
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [products, sort]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-7xl min-w-0 px-4 py-6 md:px-6 md:py-8">
|
||||
{/* Top row: breadcrumb + toolbar (same row) */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<ShopBreadcrumbBar
|
||||
items={[
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Shop" },
|
||||
]}
|
||||
/>
|
||||
<ShopToolbar
|
||||
sortOptions={SORT_OPTIONS}
|
||||
currentSort={sort}
|
||||
onSortChange={setSort}
|
||||
onOpenFilter={() => setFilterModalOpen(true)}
|
||||
resultCount={Array.isArray(products) ? products.length : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ShopPageBanner
|
||||
title="Shop"
|
||||
subtitle="Browse all products"
|
||||
/>
|
||||
|
||||
{/* Two-column layout on lg: sidebar | main */}
|
||||
<div className="mt-6 flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
{/* Left: filter sidebar (desktop only) */}
|
||||
<ShopFilterSidebar
|
||||
filterOptions={filterOptions ?? null}
|
||||
filterState={filterState}
|
||||
onFilterChange={setFilterStateToUrl}
|
||||
filterOptionsLoading={filterOptions === undefined}
|
||||
/>
|
||||
|
||||
{/* Right: category strip + product area */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<ShopCategoryStrip links={stripLinks} />
|
||||
|
||||
<div className="mt-6">
|
||||
{products === undefined ? (
|
||||
<ShopProductGridSkeleton />
|
||||
) : products === null ? (
|
||||
<ShopErrorState
|
||||
message="Failed to load products."
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
) : Array.isArray(products) && products.length === 0 ? (
|
||||
<ShopEmptyState
|
||||
title="No products found"
|
||||
description="There are no products available right now."
|
||||
/>
|
||||
) : (
|
||||
<ShopProductGrid
|
||||
products={sortedProducts.map((p) => ({
|
||||
...enrichedProductToCardProps(p as EnrichedProduct),
|
||||
id: (p as { _id: string })._id,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter modal (mobile/tablet only); filters apply on "Apply filter" */}
|
||||
<ShopFilterModal
|
||||
isOpen={filterModalOpen}
|
||||
onClose={() => setFilterModalOpen(false)}
|
||||
onApply={() => setFilterModalOpen(false)}
|
||||
filterOptions={filterOptions ?? null}
|
||||
filterState={filterState}
|
||||
onApplyFilters={setFilterStateToUrl}
|
||||
filterOptionsLoading={filterOptions === undefined}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user