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:
180
apps/storefront/src/components/shop/RecentlyAddedPage.tsx
Normal file
180
apps/storefront/src/components/shop/RecentlyAddedPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"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 { 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,
|
||||
mergeFilterStateIntoSearchParams,
|
||||
shopFilterStateToApiArgs,
|
||||
} from "@/lib/shop/filterState";
|
||||
import {
|
||||
enrichedProductToCardProps,
|
||||
type EnrichedProduct,
|
||||
} from "@/lib/shop/productMapper";
|
||||
|
||||
const SORT_OPTIONS: SortOption[] = [
|
||||
{ value: "newest", label: "Newest" },
|
||||
{ value: "price-asc", label: "Price: Low to High" },
|
||||
{ value: "price-desc", label: "Price: High to Low" },
|
||||
];
|
||||
|
||||
export function RecentlyAddedPage() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const filterState = useMemo(
|
||||
() => filterStateFromSearchParams(searchParams),
|
||||
[searchParams],
|
||||
);
|
||||
const filterArgs = useMemo(
|
||||
() => shopFilterStateToApiArgs(filterState),
|
||||
[filterState],
|
||||
);
|
||||
|
||||
const [sort, setSort] = useState<string>("newest");
|
||||
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
||||
|
||||
const filterOptions = useQuery(api.products.getFilterOptions, {
|
||||
recentlyAdded: true,
|
||||
});
|
||||
const products = useQuery(api.products.listRecentlyAdded, { ...filterArgs });
|
||||
|
||||
const setFilterStateToUrl = useCallback(
|
||||
(state: typeof filterState) => {
|
||||
const next = mergeFilterStateIntoSearchParams(
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
state,
|
||||
);
|
||||
const q = next.toString();
|
||||
router.replace(q ? `${pathname}?${q}` : pathname);
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
const handleApplyFiltersFromModal = useCallback(
|
||||
(state: typeof filterState) => {
|
||||
const next = mergeFilterStateIntoSearchParams(
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
state,
|
||||
);
|
||||
const q = next.toString();
|
||||
router.replace(q ? `${pathname}?${q}` : pathname);
|
||||
setFilterModalOpen(false);
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
const handleApplyFilter = useCallback(() => {
|
||||
const next = mergeFilterStateIntoSearchParams(
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
filterState,
|
||||
);
|
||||
const q = next.toString();
|
||||
router.push(q ? `${pathname}?${q}` : pathname);
|
||||
setFilterModalOpen(false);
|
||||
}, [router, pathname, searchParams, filterState]);
|
||||
|
||||
const sortedProducts = useMemo(() => {
|
||||
if (!products || !Array.isArray(products)) return [];
|
||||
const list = [...products] as EnrichedProduct[];
|
||||
if (sort === "newest") {
|
||||
// Data is already ordered by _creationTime desc from the query
|
||||
list.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
|
||||
} else if (sort === "price-asc") {
|
||||
list.sort((a, b) => (a.variants?.[0]?.price ?? 0) - (b.variants?.[0]?.price ?? 0));
|
||||
} else if (sort === "price-desc") {
|
||||
list.sort((a, b) => (b.variants?.[0]?.price ?? 0) - (a.variants?.[0]?.price ?? 0));
|
||||
}
|
||||
return list;
|
||||
}, [products, sort]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
const isLoading = products === undefined;
|
||||
const hasError = products === null;
|
||||
const isEmpty = Array.isArray(products) && products.length === 0;
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-7xl min-w-0 px-4 py-6 md:px-6 md:py-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<ShopBreadcrumbBar
|
||||
items={[
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Shop", href: "/shop" },
|
||||
{ label: "Recently Added" },
|
||||
]}
|
||||
/>
|
||||
<ShopToolbar
|
||||
sortOptions={SORT_OPTIONS}
|
||||
currentSort={sort}
|
||||
onSortChange={setSort}
|
||||
onOpenFilter={() => setFilterModalOpen(true)}
|
||||
resultCount={Array.isArray(products) ? products.length : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ShopPageBanner
|
||||
title="Recently Added"
|
||||
subtitle="Explore the latest products added in the last 30 days"
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
<ShopFilterSidebar
|
||||
filterOptions={filterOptions ?? null}
|
||||
filterState={filterState}
|
||||
onFilterChange={setFilterStateToUrl}
|
||||
filterOptionsLoading={filterOptions === undefined}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mt-6">
|
||||
{isLoading ? (
|
||||
<ShopProductGridSkeleton />
|
||||
) : hasError ? (
|
||||
<ShopErrorState
|
||||
message="Failed to load products."
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
) : isEmpty ? (
|
||||
<ShopEmptyState
|
||||
title="No new products yet"
|
||||
description="Nothing has been added in the last 30 days. Check back soon!"
|
||||
/>
|
||||
) : (
|
||||
<ShopProductGrid
|
||||
products={sortedProducts.map((p) => ({
|
||||
...enrichedProductToCardProps(p as EnrichedProduct),
|
||||
id: (p as { _id: string })._id,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShopFilterModal
|
||||
isOpen={filterModalOpen}
|
||||
onClose={() => setFilterModalOpen(false)}
|
||||
onApply={handleApplyFilter}
|
||||
filterOptions={filterOptions ?? null}
|
||||
filterState={filterState}
|
||||
onApplyFilters={handleApplyFiltersFromModal}
|
||||
filterOptionsLoading={filterOptions === undefined}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user