Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

# claude code
.claude/
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useSearchParams } from "next/navigation";
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { ProductListingLayout } from "@/components/products/ProductListingLayout";
import { useStore } from "@/contexts/StoreContext";
import { useProductListing } from "@/hooks/useProductListing";
Expand Down Expand Up @@ -30,21 +30,32 @@ export function ProductsContent({ basePath }: ProductsContentProps) {
searchQuery: query,
});

const listId = query ? "search-results" : "all-products";
const listName = query ? "Search Results" : "All Products";
const listId = useMemo(
() => (query ? "search-results" : "all-products"),
[query],
);
const listName = useMemo(
() => (query ? "Search Results" : "All Products"),
[query],
);

// Track view_item_list / view_search_results when products load
// Track view_item_list / view_search_results only on fresh loads (not loadMore).
// loading transitions true→false on initial/filter/search loads; loadMore uses loadingMore instead.
const prevLoadingRef = useRef(true);
useEffect(() => {
if (listing.loading || listing.totalCount === 0) return;
const wasLoading = prevLoadingRef.current;
prevLoadingRef.current = listing.loading;

if (!wasLoading || listing.loading || listing.totalCount === 0) return;

if (query) {
trackViewSearchResults(listing.products, query, currency);
} else {
trackViewItemList(listing.products, listId, listName, currency);
}
}, [
listing.products,
listing.loading,
listing.products,
listing.totalCount,
query,
listId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { ProductListingLayout } from "@/components/products/ProductListingLayout";
import { useStore } from "@/contexts/StoreContext";
import { useProductListing } from "@/hooks/useProductListing";
Expand Down Expand Up @@ -30,21 +30,28 @@ export function CategoryProductsContent({
[taxonPermalink],
);

const filterParams = useMemo(() => ({ taxon_id: taxonId }), [taxonId]);

const listing = useProductListing({
fetchFn,
filterParams: { taxon_id: taxonId },
filterParams,
});

const listId = `category-${taxonId}`;
const listName = `Category: ${taxonName}`;
const listId = useMemo(() => `category-${taxonId}`, [taxonId]);
const listName = useMemo(() => `Category: ${taxonName}`, [taxonName]);

// Track view_item_list when products load
// Track view_item_list only on fresh loads (not loadMore).
const prevLoadingRef = useRef(true);
useEffect(() => {
if (listing.loading || listing.totalCount === 0) return;
const wasLoading = prevLoadingRef.current;
prevLoadingRef.current = listing.loading;

if (!wasLoading || listing.loading || listing.totalCount === 0) return;

trackViewItemList(listing.products, listId, listName, currency);
}, [
listing.products,
listing.loading,
listing.products,
listing.totalCount,
listId,
listName,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ProductGridSkeleton } from "@/components/products/ProductGridSkeleton";

export default function CategoryLoading() {
return (
<div>
{/* Banner skeleton */}
<div className="w-full h-48 md:h-64 lg:h-80 bg-gray-200 animate-pulse" />

<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb skeleton */}
<div className="flex items-center gap-2 mb-8">
<div className="h-4 bg-gray-200 rounded w-12 animate-pulse" />
<div className="h-4 bg-gray-200 rounded w-4 animate-pulse" />
<div className="h-4 bg-gray-200 rounded w-24 animate-pulse" />
</div>

{/* Filter button skeleton (mobile) */}
<div className="lg:hidden mb-4">
<div className="h-10 bg-gray-200 rounded-md animate-pulse" />
</div>

<div className="lg:grid lg:grid-cols-4 lg:gap-8">
{/* Sidebar skeleton (desktop) */}
<div className="hidden lg:block space-y-4">
<div className="h-6 bg-gray-200 rounded w-1/2 animate-pulse" />
<div className="h-10 bg-gray-200 rounded animate-pulse" />
<div className="h-6 bg-gray-200 rounded w-1/2 animate-pulse" />
<div className="h-10 bg-gray-200 rounded animate-pulse" />
</div>

{/* Product grid skeleton */}
<div className="lg:col-span-3">
<ProductGridSkeleton />
</div>
</div>
</div>
</div>
);
}
8 changes: 7 additions & 1 deletion src/components/products/MediaGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
SearchPlusIcon,
} from "@/components/icons";

/** Tiny 10×10 neutral gray PNG used as a blur placeholder while images load. */
const BLUR_PLACEHOLDER =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAIElEQVQYV2P4////MwwMDAxMDAwMDGQJMJCvkGwNZCsEAGebBwVss9lRAAAAAElFTkSuQmCC";

interface MediaGalleryProps {
images: StoreImage[];
productName: string;
Expand Down Expand Up @@ -45,8 +49,10 @@ export function MediaGallery({ images, productName }: MediaGalleryProps) {
fill
className="object-cover"
priority
quality={100}
quality={85}
sizes="(max-width: 768px) 100vw, 50vw"
placeholder="blur"
blurDataURL={BLUR_PLACEHOLDER}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
Expand Down
5 changes: 3 additions & 2 deletions src/components/products/ProductCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { StoreProduct } from "@spree/sdk";
import Image from "next/image";
import Link from "next/link";
import { memo } from "react";
import { ImagePlaceholderIcon } from "@/components/icons";
import { useStore } from "@/contexts/StoreContext";
import { trackSelectItem } from "@/lib/analytics/gtm";
Expand All @@ -15,7 +16,7 @@ interface ProductCardProps {
listName?: string;
}

export function ProductCard({
export const ProductCard = memo(function ProductCard({
product,
basePath = "",
index,
Expand Down Expand Up @@ -108,4 +109,4 @@ export function ProductCard({
</div>
</Link>
);
}
});
6 changes: 3 additions & 3 deletions src/components/products/ProductFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
PriceRangeFilter,
ProductFiltersResponse,
} from "@spree/sdk";
import { useState } from "react";
import { memo, useState } from "react";
import { ChevronDownIcon } from "@/components/icons";

interface ProductFiltersProps {
Expand All @@ -24,7 +24,7 @@ export interface ActiveFilters {
sortBy?: string;
}

export function ProductFilters({
export const ProductFilters = memo(function ProductFilters({
filtersData,
loading,
onFilterChange,
Expand Down Expand Up @@ -195,7 +195,7 @@ export function ProductFilters({
</div>
</div>
);
}
});

// Filter Section wrapper with expand/collapse
function FilterSection({
Expand Down
18 changes: 18 additions & 0 deletions src/components/products/ProductGridSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function ProductGridSkeleton() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div
key={i}
className="bg-white rounded-xl overflow-hidden shadow-sm animate-pulse"
>
<div className="aspect-square bg-gray-200" />
<div className="p-4">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
<div className="mt-2 h-5 bg-gray-200 rounded w-1/4" />
</div>
</div>
))}
</div>
);
}
11 changes: 2 additions & 9 deletions src/components/products/ProductListingLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ProductFilters,
} from "@/components/products/ProductFilters";
import { ProductGrid } from "@/components/products/ProductGrid";
import { ProductGridSkeleton } from "@/components/products/ProductGridSkeleton";

interface ProductListingLayoutProps {
products: StoreProduct[];
Expand Down Expand Up @@ -108,15 +109,7 @@ export function ProductListingLayout({
{/* Products */}
<div className="lg:col-span-3">
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="aspect-square bg-gray-200 rounded-xl mb-4" />
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-4 bg-gray-200 rounded w-1/4" />
</div>
))}
</div>
<ProductGridSkeleton />
) : products.length === 0 ? (
<div className="text-center py-12">
<SearchIcon
Expand Down
91 changes: 48 additions & 43 deletions src/components/products/VariantPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,44 @@ export function VariantPicker({
return options;
}, [selectedVariant]);

// Precompute variant lookup structures to avoid O(n) iteration per option value
const { variantOptionMaps, optionValueDetailsMap } = useMemo(() => {
const maps = variants.map((variant) => {
const optionsMap: Record<string, string> = {};
variant.option_values.forEach((ov) => {
optionsMap[ov.option_type_id] = ov.name;
});
return { variant, optionsMap };
});

// Build option value details index: "typeId:name" -> option value object
const detailsMap: Record<string, (typeof variants)[0]["option_values"][0]> =
{};
for (const variant of variants) {
for (const ov of variant.option_values) {
const key = `${ov.option_type_id}:${ov.name}`;
if (!detailsMap[key]) {
detailsMap[key] = ov;
}
}
}

return { variantOptionMaps: maps, optionValueDetailsMap: detailsMap };
}, [variants]);

// Find variant matching selected options
const findVariant = (
newOptions: Record<string, string>,
): StoreVariant | null => {
const optionCount = Object.keys(newOptions).length;
return (
variants.find((variant) => {
return (
variant.option_values.every((ov) => {
return newOptions[ov.option_type_id] === ov.name;
}) && variant.option_values.length === Object.keys(newOptions).length
);
}) || null
variantOptionMaps.find(
({ variant, optionsMap }) =>
variant.option_values.length === optionCount &&
Object.entries(newOptions).every(
([typeId, value]) => optionsMap[typeId] === value,
),
)?.variant || null
);
};

Expand All @@ -67,19 +93,11 @@ export function VariantPicker({
optionValue: string,
): boolean => {
const testOptions = { ...selectedOptions, [optionTypeId]: optionValue };

// Check if any variant matches these options
return variants.some((variant) => {
const variantOptions: Record<string, string> = {};
variant.option_values.forEach((ov) => {
variantOptions[ov.option_type_id] = ov.name;
});

// Check if all selected options match this variant
return Object.entries(testOptions).every(([typeId, value]) => {
return variantOptions[typeId] === value;
});
});
return variantOptionMaps.some(({ optionsMap }) =>
Object.entries(testOptions).every(
([typeId, value]) => optionsMap[typeId] === value,
),
);
};

// Check if a variant with these options is purchasable
Expand All @@ -88,19 +106,13 @@ export function VariantPicker({
optionValue: string,
): boolean => {
const testOptions = { ...selectedOptions, [optionTypeId]: optionValue };

return variants.some((variant) => {
if (!variant.purchasable) return false;

const variantOptions: Record<string, string> = {};
variant.option_values.forEach((ov) => {
variantOptions[ov.option_type_id] = ov.name;
});

return Object.entries(testOptions).every(([typeId, value]) => {
return variantOptions[typeId] === value;
});
});
return variantOptionMaps.some(
({ variant, optionsMap }) =>
variant.purchasable &&
Object.entries(testOptions).every(
([typeId, value]) => optionsMap[typeId] === value,
),
);
};

const handleOptionSelect = (optionTypeId: string, optionValue: string) => {
Expand All @@ -109,19 +121,12 @@ export function VariantPicker({
onVariantChange(newVariant);
};

// Get option value details from variants
// Get option value details from precomputed map (O(1) lookup)
const getOptionValueDetails = (
optionTypeId: string,
optionValueName: string,
) => {
for (const variant of variants) {
const optionValue = variant.option_values.find(
(ov) =>
ov.option_type_id === optionTypeId && ov.name === optionValueName,
);
if (optionValue) return optionValue;
}
return null;
): StoreVariant["option_values"][0] | null => {
return optionValueDetailsMap[`${optionTypeId}:${optionValueName}`] || null;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (optionTypes.length === 0) {
Expand Down
Loading