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
5 changes: 5 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pre-commit:
commands:
biome:
glob: "*.{js,ts,jsx,tsx,json,css}"
run: npx biome check --write {staged_files} && git add {staged_files}
1,873 changes: 731 additions & 1,142 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"dependencies": {
"@next/third-parties": "^16.1.6",
"@sentry/nextjs": "^10.38.0",
"@spree/next": "^0.6.0",
"@spree/next": "^0.6.1",
"@spree/sdk": "^0.6.0",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
Expand All @@ -40,6 +40,7 @@
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.4",
"jsdom": "^28.0.0",
"lefthook": "^2.1.2",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.0.18"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import type { ProductListParams } from "@spree/sdk";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { ProductListingLayout } from "@/components/products/ProductListingLayout";
Expand All @@ -18,10 +19,7 @@ export function ProductsContent({ basePath }: ProductsContentProps) {
const query = searchParams.get("q") || "";

const fetchFn = useCallback(
(
params: Record<string, unknown>,
options: { currency: string; locale: string },
) => getProducts(params, options),
(params: ProductListParams) => getProducts(params),
[],
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,20 @@ export function ProductDetailsWrapper({
slug,
basePath,
}: ProductDetailsWrapperProps) {
const { currency, locale, loading: storeLoading } = useStore();
const { currency } = useStore();
const [product, setProduct] = useState<StoreProduct | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);

useEffect(() => {
// Wait for store context to load (to get correct currency)
if (storeLoading) return;

let cancelled = false;

const fetchProduct = async () => {
setLoading(true);
try {
const data = await getProduct(
slug,
{ includes: "variants,images,option_types" },
{ currency, locale },
);
const data = await getProduct(slug, {
includes: "variants,images,option_types",
});
if (!cancelled) {
setProduct(data);
setError(false);
Expand All @@ -58,9 +53,9 @@ export function ProductDetailsWrapper({
return () => {
cancelled = true;
};
}, [slug, currency, locale, storeLoading]);
}, [slug, currency]);

if (loading || storeLoading) {
if (loading) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import type { ProductListParams } from "@spree/sdk";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { ProductListingLayout } from "@/components/products/ProductListingLayout";
import { useStore } from "@/contexts/StoreContext";
Expand All @@ -23,10 +24,7 @@ export function CategoryProductsContent({
const { currency } = useStore();

const fetchFn = useCallback(
(
params: Record<string, unknown>,
options: { currency: string; locale: string },
) => getTaxonProducts(taxonPermalink, params, options),
(params: ProductListParams) => getTaxonProducts(taxonPermalink, params),
[taxonPermalink],
);

Expand Down
30 changes: 24 additions & 6 deletions src/components/products/ProductFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ import type {
import { memo, useState } from "react";
import { ChevronDownIcon } from "@/components/icons";

const SORT_LABELS: Record<string, string> = {
manual: "Manual",
best_selling: "Best Selling",
"price asc": "Price (low-high)",
"price desc": "Price (high-low)",
"available_on desc": "Newest",
"available_on asc": "Oldest",
"name asc": "Name (A-Z)",
"name desc": "Name (Z-A)",
};

const AVAILABILITY_LABELS: Record<string, string> = {
in_stock: "In Stock",
out_of_stock: "Out of Stock",
};
Comment on lines +12 to +26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid overriding localized labels with hardcoded English maps.

Line 137 and Line 324 now prioritize static labels keyed by IDs, which can bypass localized labels from the API and regress non-English storefronts.

💡 Suggested adjustment
- {SORT_LABELS[option.id] || option.id}
+ {option.label || SORT_LABELS[option.id] || option.id}

- {AVAILABILITY_LABELS[option.id] || option.id}
+ {option.label || AVAILABILITY_LABELS[option.id] || option.id}

Also applies to: 137-137, 323-325

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/products/ProductFilters.tsx` around lines 12 - 26, The
hardcoded English maps SORT_LABELS and AVAILABILITY_LABELS override localized
labels from the API; remove these static maps and use the label provided by the
API (e.g., option.label / sortOption.label) or the existing localization utility
(e.g., t(...) or props.localizedLabels) as the primary source, falling back to a
short default only if the API label is absent; update any usages in
ProductFilters (references to SORT_LABELS and AVAILABILITY_LABELS) to read the
dynamic label from the sort/filter option object or translation helper instead.


interface ProductFiltersProps {
taxonId?: string;
filtersData: ProductFiltersResponse | null;
Expand Down Expand Up @@ -118,7 +134,7 @@ export const ProductFilters = memo(function ProductFilters({
>
{filtersData.sort_options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
{SORT_LABELS[option.id] || option.id}
</option>
))}
</select>
Expand All @@ -141,7 +157,7 @@ export const ProductFilters = memo(function ProductFilters({
return (
<FilterSection
key={filter.id}
title={filter.label}
title="Price"
expanded={expandedSections.has(filter.id)}
onToggle={() => toggleSection(filter.id)}
>
Expand All @@ -158,7 +174,7 @@ export const ProductFilters = memo(function ProductFilters({
return (
<FilterSection
key={filter.id}
title={filter.label}
title="Availability"
expanded={expandedSections.has(filter.id)}
onToggle={() => toggleSection(filter.id)}
>
Expand All @@ -173,7 +189,7 @@ export const ProductFilters = memo(function ProductFilters({
return (
<FilterSection
key={filter.id}
title={filter.label}
title={(filter as OptionFilter).presentation}
expanded={expandedSections.has(filter.id)}
onToggle={() => toggleSection(filter.id)}
>
Expand Down Expand Up @@ -304,7 +320,9 @@ function AvailabilityFilterSection({
onChange={() => onChange(option.id as "in_stock" | "out_of_stock")}
className="text-primary-500"
/>
<span className="text-sm text-gray-700">{option.label}</span>
<span className="text-sm text-gray-700">
{AVAILABILITY_LABELS[option.id] || option.id}
</span>
<span className="text-xs text-gray-400">({option.count})</span>
</label>
))}
Expand Down Expand Up @@ -343,7 +361,7 @@ function OptionFilterSection({
onChange={() => onToggle(option.id)}
className="rounded text-primary-500"
/>
<span className="text-sm text-gray-700">{option.label}</span>
<span className="text-sm text-gray-700">{option.presentation}</span>
<span className="text-xs text-gray-400">({option.count})</span>
</label>
))}
Expand Down
15 changes: 6 additions & 9 deletions src/components/search/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface SearchBarProps {

export function SearchBar({ basePath }: SearchBarProps) {
const router = useRouter();
const { currency, locale } = useStore();
const { currency } = useStore();
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<StoreProduct[]>([]);
const [isOpen, setIsOpen] = useState(false);
Expand All @@ -35,13 +35,10 @@ export function SearchBar({ basePath }: SearchBarProps) {

setLoading(true);
try {
const response = await getProducts(
{
"q[multi_search]": searchQuery,
per_page: 6,
},
{ currency, locale },
);
const response = await getProducts({
multi_search: searchQuery,
per_page: 6,
});
setSuggestions(response.data);
if (response.data.length > 0) {
trackQuickSearch(response.data, searchQuery, currency);
Expand All @@ -53,7 +50,7 @@ export function SearchBar({ basePath }: SearchBarProps) {
setLoading(false);
}
},
[currency, locale],
[currency],
);

// Debounced search
Expand Down
53 changes: 24 additions & 29 deletions src/hooks/useCarouselProducts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { StoreProduct } from "@spree/sdk";
import { useEffect, useState } from "react";
import { useStore } from "@/contexts/StoreContext";
import { getProducts, getTaxonProducts } from "@/lib/data/products";

interface UseCarouselProductsOptions {
Expand All @@ -18,48 +17,44 @@ export function useCarouselProducts({
taxonId,
limit = 8,
}: UseCarouselProductsOptions = {}): UseCarouselProductsResult {
const { currency, locale, loading: storeLoading } = useStore();
const [products, setProducts] = useState<StoreProduct[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;

if (!storeLoading) {
const fetchProducts = async () => {
setLoading(true);
setError(null);
try {
const options = { currency, locale };
const params = { per_page: limit };
const fetchProducts = async () => {
setLoading(true);
setError(null);
try {
const params = { per_page: limit };

const response = taxonId
? await getTaxonProducts(taxonId, params, options)
: await getProducts(params, options);
const response = taxonId
? await getTaxonProducts(taxonId, params)
: await getProducts(params);

if (!cancelled) {
setProducts(response.data);
}
} catch (err) {
console.error("Failed to fetch carousel products:", err);
if (!cancelled) {
setError("Failed to load products. Please try again later.");
}
} finally {
if (!cancelled) {
setLoading(false);
}
if (!cancelled) {
setProducts(response.data);
}
};
} catch (err) {
console.error("Failed to fetch carousel products:", err);
if (!cancelled) {
setError("Failed to load products. Please try again later.");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};

fetchProducts();
}
fetchProducts();

return () => {
cancelled = true;
};
}, [currency, locale, storeLoading, taxonId, limit]);
}, [taxonId, limit]);

return { products, loading: loading || storeLoading, error };
return { products, loading, error };
}
33 changes: 11 additions & 22 deletions src/hooks/useProductListing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
import type {
PaginatedResponse,
ProductFiltersResponse,
ProductListParams,
StoreProduct,
} from "@spree/sdk";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ActiveFilters } from "@/components/products/ProductFilters";
import { useStore } from "@/contexts/StoreContext";
import { getProductFilters } from "@/lib/data/products";
import { buildProductQueryParams } from "@/lib/utils/product-query";

Expand All @@ -26,13 +26,12 @@ function filtersEqual(a: ActiveFilters, b: ActiveFilters): boolean {
}

interface UseProductListingOptions {
/** Function that fetches a page of products given query params and store options. */
/** Function that fetches a page of products given query params. */
fetchFn: (
params: Record<string, unknown>,
options: { currency: string; locale: string },
params: ProductListParams,
) => Promise<PaginatedResponse<StoreProduct>>;
/** Optional params passed to getProductFilters (e.g. { taxon_id }). */
filterParams?: Record<string, unknown>;
filterParams?: ProductListParams;
/** Optional search query string. */
searchQuery?: string;
}
Expand All @@ -42,8 +41,6 @@ export function useProductListing({
filterParams = {},
searchQuery = "",
}: UseProductListingOptions) {
const { currency, locale, loading: storeLoading } = useStore();

const [products, setProducts] = useState<StoreProduct[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
Expand Down Expand Up @@ -75,16 +72,13 @@ export function useProductListing({
async (page: number, filters: ActiveFilters, query: string) => {
try {
const queryParams = buildProductQueryParams(filters, query);
return await fetchFn(
{ page, per_page: 12, ...queryParams },
{ currency, locale },
);
return await fetchFn({ page, per_page: 12, ...queryParams });
} catch (error) {
console.error("Failed to fetch products:", error);
return null;
}
},
[fetchFn, currency, locale],
[fetchFn],
);

const loadProducts = useCallback(
Expand Down Expand Up @@ -112,7 +106,6 @@ export function useProductListing({

// Fetch filters (scoped to search query when present)
useEffect(() => {
if (storeLoading) return;
// Track filterParams changes for re-fetching on soft-nav
void filterParamsKey;

Expand All @@ -123,12 +116,9 @@ export function useProductListing({
try {
const params = { ...filterParamsRef.current };
if (searchQuery) {
params["q[multi_search]"] = searchQuery;
params.multi_search = searchQuery;
}
const response = await getProductFilters(params, {
currency,
locale,
});
const response = await getProductFilters(params);
if (!cancelled) {
setFiltersData(response);
}
Expand All @@ -146,15 +136,14 @@ export function useProductListing({
return () => {
cancelled = true;
};
}, [currency, locale, storeLoading, searchQuery, filterParamsKey]);
}, [searchQuery, filterParamsKey]);

// Load products when search query, store context, or filter params change
// Load products when search query or filter params change
useEffect(() => {
if (storeLoading) return;
// Track filterParams changes for re-fetching on soft-nav
void filterParamsKey;
loadProducts(filtersRef.current, searchQuery);
}, [storeLoading, searchQuery, loadProducts, filterParamsKey]);
}, [searchQuery, loadProducts, filterParamsKey]);

const handleFilterChange = useCallback(
(newFilters: ActiveFilters) => {
Expand Down
Loading