Skip to content
Open
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
51 changes: 51 additions & 0 deletions themes/demo-store-headless/dist/assets/index-DAW23qF1.js

Large diffs are not rendered by default.

51 changes: 0 additions & 51 deletions themes/demo-store-headless/dist/assets/index-DGnXaPK9.js

This file was deleted.

1 change: 1 addition & 0 deletions themes/demo-store-headless/dist/assets/style-A1w-0fGi.css

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion themes/demo-store-headless/dist/assets/style-sOtZaEm7.css

This file was deleted.

4 changes: 2 additions & 2 deletions themes/demo-store-headless/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Salve</title>
<script type="module" crossorigin src="/assets/index-DGnXaPK9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/style-sOtZaEm7.css">
<script type="module" crossorigin src="/assets/index-DAW23qF1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/style-A1w-0fGi.css">
</head>
<body>
<div id="root"></div>
Expand Down
106 changes: 101 additions & 5 deletions themes/demo-store-headless/src/api/products.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,54 @@
*
* NOTE: the demo-data fallback at the bottom is demo-store specific.
* base-headless ships without it. Skip when porting back.
*
* NOTE: getPriceRange() and minPrice/maxPrice filtering are demo-store
* specific — do not port to base-headless.
*/

import { storeApiRequest } from './storeApi';
import { demoProducts, getDemoProductBySlug } from '../data/demoProducts';

const isDemo = typeof window !== 'undefined' && !window.wpData;

export async function getProducts({ perPage = 12, page = 1, search, category } = {}) {
export async function getProducts({
perPage = 12,
page = 1,
search,
category,
minPrice,
maxPrice,
currencyMinorUnit = 2,
} = {}) {
if (isDemo) {
return demoProducts.slice(0, perPage);
let filtered = demoProducts;

if (minPrice !== undefined && minPrice !== null) {
const minMinor = Math.round(minPrice * Math.pow(10, currencyMinorUnit));
filtered = filtered.filter(
(p) => Number(p.prices.price) >= minMinor
);
}
if (maxPrice !== undefined && maxPrice !== null) {
const maxMinor = Math.round(maxPrice * Math.pow(10, currencyMinorUnit));
filtered = filtered.filter(
(p) => Number(p.prices.price) <= maxMinor
);
}

return filtered.slice(0, perPage);
}
return storeApiRequest('products', {
query: { per_page: perPage, page, search, category },
});

const query = { per_page: perPage, page, search, category };

if (minPrice !== undefined && minPrice !== null) {
query.min_price = Math.round(minPrice * Math.pow(10, currencyMinorUnit));
}
if (maxPrice !== undefined && maxPrice !== null) {
query.max_price = Math.round(maxPrice * Math.pow(10, currencyMinorUnit));
}

return storeApiRequest('products', { query });
}

export async function getProductBySlug(slug) {
Expand All @@ -39,3 +73,65 @@ export async function getProductById(id) {
}
return storeApiRequest(`products/${id}`);
}

/**
* Fetch the min and max product prices in the catalog.
* Returns { min, max, currencySymbol, currencyMinorUnit } in major units.
*
* NOTE: demo-store specific — do not port to base-headless.
*/
export async function getPriceRange() {
if (isDemo) {
const unit = demoProducts[0]?.prices?.currency_minor_unit ?? 2;
const symbol = demoProducts[0]?.prices?.currency_symbol ?? '$';
const divisor = Math.pow(10, unit);

const prices = demoProducts.map((p) => Number(p.prices.price) / divisor);
return {
min: Math.floor(Math.min(...prices)),
max: Math.ceil(Math.max(...prices)),
currencySymbol: symbol,
currencyMinorUnit: unit,
};
}

// Live mode: paginate through all products to find the true min/max.
// Each page fetches only the `prices` field to minimise payload.
let page = 1;
let allPriceData = [];
let hasMore = true;

while (hasMore) {
const batch = await storeApiRequest('products', {
query: { _fields: 'prices', per_page: 100, page },
});
if (!Array.isArray(batch) || batch.length === 0) break;
allPriceData = allPriceData.concat(batch);
hasMore = batch.length === 100;
page += 1;
}
Comment on lines +104 to +112

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Paginating through all products sequentially in a while loop to find the min/max prices is a major performance bottleneck. For stores with many products, this will result in numerous sequential API requests on every mount of the Shop page, leading to slow load times and high server load.

Consider using the WooCommerce Store API's dedicated /products/collection-data endpoint, which returns the min/max prices of the collection in a single lightweight request, or at least caching the result.


if (allPriceData.length === 0) {
return { min: 0, max: 0, currencySymbol: '$', currencyMinorUnit: 2 };
}

const unit = allPriceData[0].prices.currency_minor_unit ?? 2;
const symbol = allPriceData[0].prices.currency_symbol ?? '$';
Comment on lines +118 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Using direct property access on allPriceData[0].prices is unsafe here. If the first product in the fetched batch has missing or incomplete price data, this will throw a TypeError and crash the page. Use optional chaining to safely access these properties, matching the pattern used in the demo mode fallback.

Suggested change
const unit = allPriceData[0].prices.currency_minor_unit ?? 2;
const symbol = allPriceData[0].prices.currency_symbol ?? '$';
const unit = allPriceData[0].prices?.currency_minor_unit ?? 2;
const symbol = allPriceData[0].prices?.currency_symbol ?? '$';

const divisor = Math.pow(10, unit);

const prices = allPriceData
.filter((p) => p.prices?.price != null)
.map((p) => Number(p.prices.price) / divisor)
.filter((n) => !Number.isNaN(n));

if (prices.length === 0) {
return { min: 0, max: 0, currencySymbol: symbol, currencyMinorUnit: unit };
}

return {
min: Math.floor(Math.min(...prices)),
max: Math.ceil(Math.max(...prices)),
currencySymbol: symbol,
currencyMinorUnit: unit,
};
}
104 changes: 104 additions & 0 deletions themes/demo-store-headless/src/components/PriceSlider.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* SYNC: demo-store specific — do not port to base-headless.
*
* Dual-handle price range slider built from two native <input type="range">
* elements. No external dependencies.
*/

import '../styles/PriceSlider.css';

function PriceSlider({
min,
max,
currentMin,
currentMax,
currencySymbol = '$',
onChange,
onReset,
disabled = false,
}) {
const range = max - min || 1;
const step = range < 10 ? 0.01 : 1;
const midpoint = (max + min) / 2;
const swap = currentMin > midpoint;
const isNarrowed = currentMin !== min || currentMax !== max;

// Percentage positions for the active range highlight
const minPercent = ((currentMin - min) / range) * 100;
const maxPercent = ((currentMax - min) / range) * 100;

function handleMinChange(e) {
const value = Number(e.target.value);
onChange(Math.min(value, currentMax), currentMax);
}

function handleMaxChange(e) {
const value = Number(e.target.value);
onChange(currentMin, Math.max(value, currentMin));
}
Comment on lines +30 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

When the minimum and maximum thumbs meet or overlap, the thumb with the lower z-index becomes completely unreachable and cannot be dragged. This causes the slider to get stuck, preventing the user from expanding the range again.

To resolve this, we can implement a 'push' behavior where dragging the minimum handle past the maximum handle (or vice versa) automatically updates the other handle, allowing them to cross smoothly without getting stuck.

Suggested change
function handleMinChange(e) {
const value = Number(e.target.value);
onChange(Math.min(value, currentMax), currentMax);
}
function handleMaxChange(e) {
const value = Number(e.target.value);
onChange(currentMin, Math.max(value, currentMin));
}
function handleMinChange(e) {
const value = Number(e.target.value);
if (value > currentMax) {
onChange(currentMax, value);
} else {
onChange(value, currentMax);
}
}
function handleMaxChange(e) {
const value = Number(e.target.value);
if (value < currentMin) {
onChange(value, currentMin);
} else {
onChange(currentMin, value);
}
}


function formatPrice(value) {
if (step < 1) {
return `${currencySymbol}${value.toFixed(2)}`;
}
return `${currencySymbol}${value}`;
}

return (
<div
className={`price-slider${disabled ? ' price-slider--disabled' : ''}`}
role="group"
aria-label="Price filter"
>
<span className="price-slider__label">Price Range</span>

<div className="price-slider__track-wrapper">
<div className="price-slider__track" />
<div
className="price-slider__range"
style={{ left: `${minPercent}%`, width: `${maxPercent - minPercent}%` }}
/>
<input
type="range"
className={`price-slider__input price-slider__input--min${swap ? ' price-slider__input--swap' : ''}`}
min={min}
max={max}
step={step}
value={currentMin}
onChange={handleMinChange}
aria-label="Minimum price"
disabled={disabled}
/>
<input
type="range"
className={`price-slider__input price-slider__input--max${swap ? ' price-slider__input--swap' : ''}`}
min={min}
max={max}
step={step}
value={currentMax}
onChange={handleMaxChange}
aria-label="Maximum price"
disabled={disabled}
/>
</div>

<div className="price-slider__values">
<span>{formatPrice(currentMin)}</span>
<span>{formatPrice(currentMax)}</span>
</div>

{isNarrowed && onReset && (
<button
type="button"
className="price-slider__reset"
onClick={onReset}
disabled={disabled}
>
Reset
</button>
)}
</div>
);
}

export default PriceSlider;
17 changes: 13 additions & 4 deletions themes/demo-store-headless/src/components/ProductList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,30 @@
* Port improvements both ways. Identifier mapping when porting:
* localStorage key 'demo-store-cart-token' ↔ 'base-headless-cart-token'
* script handle 'demo-store-app' ↔ 'base-headless-app'
*
* NOTE: minPrice / maxPrice / currencyMinorUnit props and emptyMessage
* are demo-store specific. base-headless does not use them.
*/

import { useEffect, useState } from 'react';
import { getProducts } from '../api/products';
import ProductCard from './ProductCard';

function ProductList({ perPage = 12 }) {
function ProductList({
perPage = 12,
minPrice,
maxPrice,
currencyMinorUnit,
emptyMessage = 'No products yet.',
}) {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
let cancelled = false;
setLoading(true);
getProducts({ perPage })
getProducts({ perPage, minPrice, maxPrice, currencyMinorUnit })
.then((data) => {
if (cancelled) return;
setProducts(Array.isArray(data) ? data : []);
Expand All @@ -32,7 +41,7 @@ function ProductList({ perPage = 12 }) {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [perPage]);
}, [perPage, minPrice, maxPrice, currencyMinorUnit]);

if (loading) {
return (
Expand All @@ -48,7 +57,7 @@ function ProductList({ perPage = 12 }) {
}

if (products.length === 0) {
return <div className="empty-state"><p>No products yet.</p></div>;
return <div className="empty-state"><p>{emptyMessage}</p></div>;
}

return (
Expand Down
Loading