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:
parent
556354a44d
commit
e8e0675d7d
11 changed files with 207 additions and 123 deletions
|
|
@ -9,5 +9,6 @@
|
|||
@use "ui/notification";
|
||||
@use "ui/rating";
|
||||
@use "ui/select";
|
||||
@use "ui/slider";
|
||||
|
||||
|
||||
|
|
|
|||
17
storefront/app/assets/styles/ui/slider.scss
Normal file
17
storefront/app/assets/styles/ui/slider.scss
Normal 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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ watch(select, value => {
|
|||
|
||||
<style scoped lang="scss">
|
||||
.top {
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,7 +216,8 @@
|
|||
"title": "Filters",
|
||||
"apply": "Apply",
|
||||
"reset": "Reset",
|
||||
"all": "All"
|
||||
"all": "All",
|
||||
"price": "Price"
|
||||
},
|
||||
"empty": "There are no products by these parameters."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -216,7 +216,8 @@
|
|||
"title": "Фильтры",
|
||||
"apply": "Применить",
|
||||
"reset": "Сбросить",
|
||||
"all": "Все"
|
||||
"all": "Все",
|
||||
"price": "Цена"
|
||||
},
|
||||
"empty": "По этим параметрам нет товаров."
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue