feat(storefront): enhance store filter and price handling for improved UX

Enhanced store filters with a refined price range slider, accommodating category-specific min/max prices and dynamic updates. Optimized reactivity for product filtering by consolidating watchers into a unified approach. Adjusted UI elements for consistent spacing and modern icon usage in filters.

- Added `minMaxPrices` and debounce logic to improve price filtering performance.
- Updated filter UI with collapsible headers and better styling for usability.
- Refactored multiple watchers into a single handler for better efficiency.
- Introduced global constants for currency symbol usage.

Breaking Changes: Components relying on price filters must adapt to new props and event names (`filterMinPrice`, `filterMaxPrice`). Styles may require alignment with refined SCSS rules for filters.
This commit is contained in:
Alexandr SaVBaD Waltz 2026-03-01 22:00:34 +03:00
parent 556354a44d
commit e8e0675d7d
11 changed files with 207 additions and 123 deletions

View file

@ -9,5 +9,6 @@
@use "ui/notification";
@use "ui/rating";
@use "ui/select";
@use "ui/slider";

View file

@ -0,0 +1,17 @@
.el-slider {
width: 90% !important;
margin-inline: auto;
height: 30px !important;
}
.el-slider__bar, .el-slider__runway {
height: 4px !important;
}
.el-slider__button-wrapper {
width: 34px !important;
height: 34px !important;
}
.el-slider__button {
width: 16px !important;
height: 16px !important;
}

View file

@ -55,6 +55,7 @@
<div class="header__inner">
<div class="header__block">
<icon
v-if="uiConfig.showSearchBar"
@click="isSearchVisible = true"
class="header__block-search"
name="tabler:search"

View file

@ -4,13 +4,20 @@
<h2>{{ t('store.filters.title') }}</h2>
<p @click="resetFilters">{{ t('buttons.clearAll') }}</p>
</div>
<client-only>
<el-collapse v-model="collapse">
<el-collapse-item
name="0"
>
<template #title>
<template #title="{ isActive }">
<div class="filters__head">
<h3 class="filters__name" v-text="t('market.filter.price')" />
<h3 class="filters__name" v-text="t('store.filters.price')" />
<icon
name="material-symbols:keyboard-arrow-down"
size="22"
class="filters__head-icon"
:class="[{ active: isActive }]"
/>
</div>
</template>
<div class="filters__price">
@ -49,9 +56,15 @@
:key="idx"
:name="`${idx + 2}`"
>
<template #title>
<template #title="{ isActive }">
<div class="filters__head">
<h3 class="filters__name" v-text="attribute.attributeName" />
<icon
name="material-symbols:keyboard-arrow-down"
size="22"
class="filters__head-icon"
:class="[{ active: isActive }]"
/>
</div>
</template>
<ul class="filters__list">
@ -70,7 +83,7 @@
</ul>
</el-collapse-item>
</el-collapse>
<!-- <skeletons-market-filters v-else />-->
</client-only>
</div>
</template>
@ -78,6 +91,7 @@
import type {IStoreFilters} from '@types';
import {useFilters} from '@composables/store';
import {useRouteQuery} from '@vueuse/router';
import {CURRENCY} from "~/constants";
const appStore = useAppStore();
const { t } = useI18n();
@ -133,9 +147,6 @@ const initializeInputs = () => {
const max = props.initialMaxPrice ?? categoryMax.value;
priceRange.value = [min, max];
const { min: floatMin, max: floatMax } = getFloatMinMax();
floatRange.value = [floatMin, floatMax];
};
const handlePriceInput = useDebounceFn((value: string | number, type: 'min' | 'max') => {
@ -179,10 +190,6 @@ watch(priceRange, () => {
debouncedPriceUpdate();
}, { deep: true });
watch(floatRange, () => {
debouncedFilterApply();
}, { deep: true });
watch(selectedMap, () => {
debouncedFilterApply();
}, { deep: true });
@ -230,18 +237,18 @@ watch(
{ immediate: true }
);
const formatPriceTooltip = (value: number) => `${value.toFixed(2)}`;
const formatPriceTooltip = (value: number) => `${CURRENCY}${value.toFixed(2)}`;
</script>
<style scoped lang="scss">
.filter {
.filters {
width: 290px;
border: 1px solid $border;
border-radius: $default_border_radius;
display: flex;
flex-direction: column;
gap: 24px;
padding: 25px;
padding: 20px;
&__top {
display: flex;
@ -255,10 +262,10 @@ const formatPriceTooltip = (value: number) => `€${value.toFixed(2)}`;
letter-spacing: -0.5px;
}
& span {
& p {
cursor: pointer;
color: $link_primary;
font-size: 14px;
font-size: 12px;
font-weight: 400;
letter-spacing: -0.5px;
@ -267,5 +274,43 @@ const formatPriceTooltip = (value: number) => `€${value.toFixed(2)}`;
}
}
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
&-icon {
color: $secondary;
&.active {
transform: rotate(-180deg);
}
}
}
&__list {
list-style: none;
}
&__name {
font-size: 16px;
font-weight: 500;
color: $primary_dark;
letter-spacing: -0.5px;
}
&__price {
&-inputs {
display: flex;
align-items: center;
gap: 10px;
}
}
}
:deep(.block__input) {
font-size: 12px;
padding: 5px 10px;
}
</style>

View file

@ -3,9 +3,13 @@
<store-filter
v-if="filters.length"
:filterableAttributes="filters"
:isOpen="showFilter"
@update:selected="onFiltersChange"
@close="showFilter = false"
:initialMinPrice="Number(minPrice)"
:initialMaxPrice="Number(maxPrice)"
:categoryMinPrice="minMaxPrices.minPrice"
:categoryMaxPrice="minMaxPrices.maxPrice"
@filterMinPrice="updateMinPrice"
@filterMaxPrice="updateMaxPrice"
/>
<div class="store__inner">
<store-top
@ -75,7 +79,7 @@ const brandData = brandSlug.value
? await useBrandBySlug(brandSlug.value)
: { brand: ref(null), seoMeta: ref(null) };
const { category, seoMeta: categorySeoMeta, filters } = categoryData;
const { category, seoMeta: categorySeoMeta, filters, minMaxPrices } = categoryData;
const { brand, seoMeta: brandSeoMeta } = brandData;
const seoMeta = computed(() => categorySeoMeta.value || brandSeoMeta.value);
@ -147,6 +151,13 @@ async function onFiltersChange(newFilters: Record<string, string[]>) {
await getProducts();
}
const updateMinPrice = useDebounceFn((filteredPrice) => {
minPrice.value = filteredPrice;
}, 500);
const updateMaxPrice = useDebounceFn((filteredPrice) => {
maxPrice.value = filteredPrice;
}, 500);
useIntersectionObserver(
observer,
async ([{ isIntersecting }]) => {
@ -157,30 +168,19 @@ useIntersectionObserver(
},
);
watch(orderBy, async (newVal) => {
variables.orderBy = newVal || '';
watch(
[orderBy, attributes, minPrice, maxPrice],
async ([newOrder, newAttr, newMin, newMax]) => {
variables.orderBy = newOrder || '';
variables.attributes = newAttr;
variables.minPrice = Number(newMin) || 0;
variables.maxPrice = Number(newMax) || 500000;
variables.productAfter = '';
products.value = [];
await getProducts();
});
watch(attributes, async (newVal) => {
variables.attributes = newVal;
variables.productAfter = '';
products.value = [];
await getProducts();
});
watch(minPrice, async (newVal) => {
variables.minPrice = newVal || 0;
variables.productAfter = '';
products.value = [];
await getProducts();
});
watch(maxPrice, async (newVal) => {
variables.maxPrice = newVal || 500000;
variables.productAfter = '';
products.value = [];
await getProducts();
});
}
);
</script>
<style scoped lang="scss">
@ -195,7 +195,7 @@ watch(maxPrice, async (newVal) => {
}
&__list {
margin-top: 50px;
margin-top: 32px;
&-grid {
display: grid;

View file

@ -81,7 +81,6 @@ watch(select, value => {
<style scoped lang="scss">
.top {
margin-bottom: 20px;
width: 100%;
position: relative;
z-index: 2;

View file

@ -11,6 +11,7 @@ export async function useCategoryBySlug(slug: string) {
if (!category.value) return [];
return category.value.filterableAttributes.filter((attr) => attr.possibleValues.length > 0);
});
const minMaxPrices = computed(() => category.value?.minMaxPrices ?? { minPrice: 0, maxPrice: 50000 });
watch(error, (err) => {
if (err) {
@ -22,5 +23,6 @@ export async function useCategoryBySlug(slug: string) {
category,
seoMeta: computed(() => category.value?.seoMeta),
filters,
minMaxPrices
};
}

View file

@ -49,7 +49,7 @@ export function useStore(args: IProdArgs) {
pending.value = true;
const queryVariables = {
productFirst: variables.first,
productFirst: variables.productFirst,
categoriesSlugs: variables.categoriesSlugs,
productOrderBy: variables.orderBy || undefined,
productAfter: variables.productAfter || undefined,

View file

@ -75,6 +75,7 @@
>
{{ t('buttons.addToCart') }}
</ui-button>
<div class="product__main-buttons">
<ui-button
@click="overwriteWishlist({
type: (isProductInWishlist ? 'remove' : 'add'),
@ -89,6 +90,7 @@
</ui-button>
<ui-button
:type="'button'"
:style="'secondary'"
@click="buyProduct(product.uuid)"
:isLoading="buyLoading"
>
@ -97,6 +99,7 @@
</div>
</div>
</div>
</div>
<!-- <client-only>-->
<!-- <div class="characteristics" id="characteristics" v-if="attributes.length">-->
<!-- <div class="characteristics__wrapper">-->
@ -189,6 +192,10 @@ const cookieWishlist = useCookie($appHelpers.COOKIES_WISHLIST_KEY, {
default: () => [],
path: '/',
});
const cookieCart = useCookie($appHelpers.COOKIES_CART_KEY, {
default: () => [],
path: '/',
});
const isAuthenticated = computed(() => userStore.isAuthenticated);
@ -414,6 +421,16 @@ watch(
letter-spacing: -0.5px;
}
&-buttons {
display: flex;
align-items: center;
justify-content: space-between;
& .button {
width: 49%;
}
}
&-button {
margin-top: 25px;
}

View file

@ -216,7 +216,8 @@
"title": "Filters",
"apply": "Apply",
"reset": "Reset",
"all": "All"
"all": "All",
"price": "Price"
},
"empty": "There are no products by these parameters."
},

View file

@ -216,7 +216,8 @@
"title": "Фильтры",
"apply": "Применить",
"reset": "Сбросить",
"all": "Все"
"all": "Все",
"price": "Цена"
},
"empty": "По этим параметрам нет товаров."
},