- Add payment method SVGs (Visa, Mastercard, Apple Pay, Google Pay, Klarna, Link, Revolut Pay, Billie, Cartes, Discover) - Add carrier images (DPD, Evri) - Add CustomerConfidenceBooster section component - Enhance Footer with payment methods and carrier display - Wire CustomerConfidenceBooster into shop pages (PetCategory, RecentlyAdded, ShopIndex, SubCategory, Tag, TopCategory) and home page - Update tsconfig.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
186 lines
6.3 KiB
TypeScript
186 lines
6.3 KiB
TypeScript
"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 { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster";
|
|
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 className="mt-8">
|
|
<CustomerConfidenceBooster />
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|