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:
2026-03-04 09:31:18 +03:00
commit cc15338ad9
361 changed files with 45005 additions and 0 deletions

View 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>
);
}