Price Filter Enhancements
Design Log 04: Price Filter Enhancements
Written for AI agents. See Log Methodology Note below for details.
Background
The current product search contract supports price filtering via two number input fields (minPrice and maxPrice). Users want more intuitive price filtering options:
- Pre-defined price ranges - Radio buttons like "₪0-₪100", "₪100-₪200", etc.
- Dual price slider - A range slider for visual min/max selection
Reference UI: Master of Malt style price filter with radio options for predefined ranges.
API Discovery
The Wix searchProducts API supports aggregations in the same call:
// From exploration/query-products-catalog-v3/query-playground.ts
await productsClient.searchProducts({
aggregations: [
// Get product counts per price bucket
{
fieldPath: 'actualPriceRange.minValue.amount',
name: 'price-buckets',
type: 'RANGE',
range: {
buckets: [
{ from: 0, to: 50 },
{ from: 50, to: 100 },
{ from: 100, to: 200 },
],
},
},
// Get minimum price across all products
{
fieldPath: 'actualPriceRange.minValue.amount',
name: 'min-price',
type: 'SCALAR',
scalar: { type: 'MIN' },
},
// Get maximum price across all products
{
fieldPath: 'actualPriceRange.minValue.amount',
name: 'max-price',
type: 'SCALAR',
scalar: { type: 'MAX' },
},
],
});
This allows us to:
- Compute slider bounds automatically from actual product data
- Compute price buckets with product counts per range
- Single API call for search + aggregations
Important: Only searchProducts supports aggregations. The current fast phase uses queryProducts which must be changed.
Problem
- Contract limitation: Current
priceRangeonly supports free-formminPrice/maxPriceinputs - No predefined ranges: Cannot define store-specific price buckets
- No slider support: No contract structure for range slider UI
- Dynamic bounds needed: Slider min/max should come from actual product prices
Questions and Answers
Q1: Should predefined ranges be configurable per store or computed from API?
A: Computed from API using aggregations. The searchProducts call will include RANGE aggregations with predefined buckets, returning product counts per bucket.
Q2: Should we keep the existing min/max inputs alongside the new options? A: Yes. Offer three modes: (1) Input fields, (2) Radio ranges, (3) Dual slider. Store chooses which to use in their template.
Q3: How should the dual slider bounds be determined? A: From the API using SCALAR MIN/MAX aggregations. The slider bounds are dynamically set from actual product prices.
Q4: What happens when a range radio is selected?
A: It sets the minPrice and maxPrice values internally and triggers a search.
Q5: Should there be a "Show all" option? A: Yes, it clears both min and max price filters.
Q6: Which store gets which UI? A:
whisky-store→ Radio price ranges (Master of Malt style)store→ Dual price slider
Proposed Design
1. Extend priceRange Sub-contract
# Price range filter (fast+interactive)
- tag: priceRange
type: sub-contract
phase: fast+interactive
description: Price range filter
tags:
# Current filter values - works with <input type="number"> or <input type="range">
- tag: minPrice
type: [data, interactive]
dataType: number
elementType: HTMLInputElement
description: Current minimum price filter value
- tag: maxPrice
type: [data, interactive]
dataType: number
elementType: HTMLInputElement
description: Current maximum price filter value
# Bounds for slider UI (from API aggregation)
- tag: minBound
type: data
dataType: number
phase: fast+interactive
description: Absolute minimum from API (SCALAR MIN aggregation)
- tag: maxBound
type: data
dataType: number
phase: fast+interactive
description: Absolute maximum from API (SCALAR MAX aggregation)
# NEW: Predefined ranges (from API RANGE aggregation)
- tag: ranges
type: sub-contract
repeated: true
trackBy: rangeId
phase: fast+interactive
description: Price range buckets with product counts
tags:
- tag: rangeId
type: data
dataType: string
description: Unique range identifier
- tag: label
type: data
dataType: string
description: Display label (e.g., "₪0 - ₪100")
- tag: minValue
type: data
dataType: number
description: Range minimum (null for "Show all")
- tag: maxValue
type: data
dataType: number
description: Range maximum (null for open-ended like "₪400+")
- tag: productCount
type: data
dataType: number
description: Number of products in this price range (from aggregation)
- tag: isSelected
type: [data, interactive]
dataType: boolean
elementType: HTMLInputElement
description: Radio button for this range
2. HTML Usage Examples
Radio Button Ranges (whisky-store)
<div class="filter-section">
<div class="filter-header">
<span class="filter-title">Price</span>
<span class="filter-toggle">▲</span>
</div>
<div class="filter-options">
<label
class="filter-option"
forEach="productSearch.filters.priceRange.ranges"
trackBy="rangeId"
>
<input
type="radio"
name="priceRange"
ref="productSearch.filters.priceRange.ranges.isSelected"
/>
<span>{label}</span>
<span class="count" if="productCount">({productCount})</span>
</label>
</div>
</div>
Dual Slider (store example)
<div class="filter-section">
<div class="filter-title">Price Range</div>
<div class="price-slider">
<div class="slider-values">
<span class="slider-value-min">{productSearch.filters.priceRange.minPrice}</span>
<span class="slider-value-max">{productSearch.filters.priceRange.maxPrice}</span>
</div>
<div class="slider-track">
<input
type="range"
class="slider-handle slider-min"
min="{productSearch.filters.priceRange.minBound}"
max="{productSearch.filters.priceRange.maxBound}"
value="{productSearch.filters.priceRange.minPrice}"
ref="productSearch.filters.priceRange.minPrice"
/>
<input
type="range"
class="slider-handle slider-max"
min="{productSearch.filters.priceRange.minBound}"
max="{productSearch.filters.priceRange.maxBound}"
value="{productSearch.filters.priceRange.maxPrice}"
ref="productSearch.filters.priceRange.maxPrice"
/>
</div>
</div>
</div>
3. CSS for Dual Slider
/* Dual range slider */
.price-slider {
padding: 20px 0;
}
.slider-track {
position: relative;
height: 6px;
background: var(--border);
border-radius: 3px;
}
.slider-track input[type='range'] {
position: absolute;
width: 100%;
height: 6px;
background: transparent;
pointer-events: none;
-webkit-appearance: none;
appearance: none;
}
.slider-track input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
background: var(--navy);
border-radius: 50%;
cursor: pointer;
pointer-events: auto;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.slider-track input[type='range']::-moz-range-thumb {
width: 20px;
height: 20px;
background: var(--navy);
border-radius: 50%;
cursor: pointer;
pointer-events: auto;
border: none;
}
.slider-labels {
display: flex;
justify-content: space-between;
margin-top: 12px;
font-size: 14px;
color: var(--text-secondary);
}
4. API Integration in stores-actions.ts
// Logarithmic price bucket boundaries: 0, 20, 40, 100, 200, 400, 1000, 2000, 4000, 10000...
// Each power of 10 is divided into 3 buckets using multipliers 2, 4, 10
const PRICE_BUCKET_BOUNDARIES = [0, 20, 40, 100, 200, 400, 1000, 2000, 4000, 10000, 20000, 40000, 100000];
const PRICE_BUCKETS = PRICE_BUCKET_BOUNDARIES.slice(0, -1).map((from, i) => ({
from,
to: PRICE_BUCKET_BOUNDARIES[i + 1]
}));
// Add open-ended last bucket
PRICE_BUCKETS.push({ from: 100000 });
// In searchProducts call, add aggregations:
const response = await productsClient.searchProducts({
filter,
cursorPaging,
sort,
aggregations: [
// Price buckets with product counts
{
fieldPath: 'actualPriceRange.minValue.amount',
name: 'price-buckets',
type: "RANGE",
range: { buckets: PRICE_BUCKETS }
},
// Min price for slider bound
{
fieldPath: 'actualPriceRange.minValue.amount',
name: 'min-price',
type: "SCALAR",
scalar: { type: "MIN" }
},
// Max price for slider bound
{
fieldPath: 'actualPriceRange.minValue.amount',
name: 'max-price',
type: "SCALAR",
scalar: { type: "MAX" }
}
]
}, { fields: [...] });
// Extract aggregation results
const aggregations = response.aggregationData?.results;
const minPrice = aggregations?.find(a => a.name === 'min-price')?.scalar?.value;
const maxPrice = aggregations?.find(a => a.name === 'max-price')?.scalar?.value;
const buckets = aggregations?.find(a => a.name === 'price-buckets')?.range?.buckets;
5. Mapping Aggregation Results
function mapPriceRanges(buckets: any[], currency: string): PriceRange[] {
const ranges: PriceRange[] = [
{
rangeId: 'all',
label: 'Show all',
minValue: null,
maxValue: null,
productCount: null,
isSelected: true,
},
];
for (const bucket of buckets) {
const from = bucket.from ?? 0;
const to = bucket.to;
const count = bucket.count ?? 0;
// Skip empty buckets
if (count === 0) continue;
const label = to ? `${currency}${from} - ${currency}${to}` : `${currency}${from}+`;
ranges.push({
rangeId: `${from}-${to ?? 'plus'}`,
label,
minValue: from,
maxValue: to ?? null,
productCount: count,
isSelected: false,
});
}
return ranges;
}
6. Implementation in product-search.ts
// Handle range radio selection
for (const range of viewState.priceRange.ranges) {
range.isSelected.onChange = () => {
if (range.isSelected.value) {
// Deselect other ranges
viewState.priceRange.ranges.forEach((r) => {
if (r.rangeId !== range.rangeId) r.isSelected.value = false;
});
// Set price filter values
viewState.priceRange.minPrice.value = range.minValue;
viewState.priceRange.maxPrice.value = range.maxValue;
// Trigger search
performSearch();
}
};
}
// Handle slider changes (with debounce) - uses same minPrice/maxPrice tags
let sliderDebounce: NodeJS.Timeout;
const handleSliderChange = () => {
clearTimeout(sliderDebounce);
sliderDebounce = setTimeout(() => {
performSearch();
}, 300); // 300ms debounce
};
// When bound to <input type="range">, the same onChange works
viewState.priceRange.minPrice.onChange = handleSliderChange;
viewState.priceRange.maxPrice.onChange = handleSliderChange;
Implementation Plan
Phase 1: Contract Extension
- Update
product-search.jay-contractwith newpriceRangetags:- Add
minBound,maxBoundfor dynamic slider bounds - Add
rangesrepeated sub-contract withproductCount - Existing
minPrice/maxPricework for both number inputs and range sliders
- Add
- Run contract type generation
Phase 2: API Integration
- Change fast phase from
queryProductstosearchProducts:- Currently
product-search.tsline 126 useswixStores.products.queryProducts() - Must change to
searchProductsto get aggregations - Only
searchProductssupports theaggregationsparameter
- Currently
- Update
stores-actions.ts:- Add aggregations to
searchProductscall - Define logarithmic
PRICE_BUCKETS(0-20, 20-40, 40-100, 100-200, etc.) - Extract min/max prices and bucket counts from response
- Filter out empty buckets from results
- Map buckets to
PriceRangeBucket[]with currency-formatted labels
- Add aggregations to
Phase 3: Component Implementation
- Update
product-search.ts:- Populate
sliderMinBound/sliderMaxBoundfrom API - Populate
rangesfrom aggregation results - Handle range radio selection → set min/max → search
- Handle slider change (with debounce) → set min/max → search
- Populate
Phase 4: HTML/CSS Updates
- whisky-store: Update products page with radio price ranges
- Show product count in parentheses: "₪0 - ₪100 (12)"
- store: Update products page with dual slider
- Add slider CSS to theme
- Test both filter modes
Trade-offs
| Decision | Pros | Cons |
|---|---|---|
| Keep all three modes | Flexibility for different stores | More complex contract |
| Radio sets min/max internally | Clean separation | Hidden state sync |
| Configurable ranges | Store-specific buckets | Requires initialization logic |
| CSS-only dual slider | No JS library needed | Limited styling options |
Verification Criteria
- API aggregations: Search response includes min-price, max-price, and bucket counts
- Bounds:
minBound/maxBoundpopulated from API scalar aggregations - Range options:
rangesarray populated from API range aggregation with product counts - Radio ranges: Clicking a range updates minPrice/maxPrice and filters products
- Product counts: Each range shows "(N)" count from aggregation
- "Show all": Clears price filter, shows all products
- Dual slider: Binding minPrice/maxPrice to
<input type="range">works with debounce - whisky-store: Shows radio price ranges
- store: Shows dual slider
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.