-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add price slider filter to Shop page #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
This file was deleted.
Large diffs are not rendered by default.
This file was deleted.
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) { | ||||||||||
|
|
@@ -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; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using direct property access on
Suggested change
|
||||||||||
| 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, | ||||||||||
| }; | ||||||||||
| } | ||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Paginating through all products sequentially in a
whileloop 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-dataendpoint, which returns the min/max prices of the collection in a single lightweight request, or at least caching the result.