schon/storefront/app/components/store/index.vue
Alexandr SaVBaD Waltz e8e0675d7d 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.
2026-03-01 22:00:34 +03:00

226 lines
No EOL
6.1 KiB
Vue

<template>
<div class="store">
<store-filter
v-if="filters.length"
:filterableAttributes="filters"
@update:selected="onFiltersChange"
:initialMinPrice="Number(minPrice)"
:initialMaxPrice="Number(maxPrice)"
:categoryMinPrice="minMaxPrices.minPrice"
:categoryMaxPrice="minMaxPrices.maxPrice"
@filterMinPrice="updateMinPrice"
@filterMaxPrice="updateMaxPrice"
/>
<div class="store__inner">
<store-top
v-model="orderBy"
@toggle-filter="onFilterToggle"
:isFilters="filters.length > 0"
/>
<client-only>
<div
class="store__list"
:class="[
{ 'store__list-grid': productView === 'grid' },
{ 'store__list-list': productView === 'list' }
]"
>
<cards-product
v-if="products.length && !pending"
v-for="product in products"
:key="product.node.uuid"
:product="product.node"
:isList="productView === 'list'"
/>
<skeletons-cards-product
v-if="pending"
v-for="idx in 12"
:key="idx"
:isList="productView === 'list'"
/>
</div>
</client-only>
<div class="store__list-observer" ref="observer"></div>
<p class="store__empty" v-if="!products.length && !pending">{{ t('store.empty') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import {useFilters, useStore} from '@composables/store';
import {useCategoryBySlug} from '@composables/categories';
import {useBrandBySlug} from '@composables/brands';
import {useDefaultSeo} from '@composables/seo';
const { t, locale } = useI18n();
const { $appHelpers } = useNuxtApp();
const productView = useCookie<string>(
$appHelpers.COOKIES_PRODUCT_VIEW_KEY as string,
{
default: () => 'grid',
path: '/',
}
);
const categorySlug = useRouteParams<string>('categorySlug');
const brandSlug = useRouteParams<number>('brandSlug');
const attributes = useRouteQuery<string>('attributes', '');
const orderBy = useRouteQuery<string>('orderBy', 'created');
const minPrice = useRouteQuery<number>('minPrice', 0);
const maxPrice = useRouteQuery<number>('maxPrice', 50000);
const observer = ref(null);
const categoryData = categorySlug.value
? await useCategoryBySlug(categorySlug.value)
: { category: ref(null), seoMeta: ref(null), filters: ref([]) };
const brandData = brandSlug.value
? await useBrandBySlug(brandSlug.value)
: { brand: ref(null), seoMeta: ref(null) };
const { category, seoMeta: categorySeoMeta, filters, minMaxPrices } = categoryData;
const { brand, seoMeta: brandSeoMeta } = brandData;
const seoMeta = computed(() => categorySeoMeta.value || brandSeoMeta.value);
const meta = useDefaultSeo(seoMeta.value || null);
if (meta) {
useSeoMeta({
title: meta.title || $appHelpers.APP_NAME,
description: meta.description || meta.title || $appHelpers.APP_NAME,
ogTitle: meta.og.title || undefined,
ogDescription: meta.og.description || meta.title || $appHelpers.APP_NAME,
ogType: meta.og.type || undefined,
ogUrl: meta.og.url || undefined,
ogImage: meta.og.image || undefined,
twitterCard: meta.twitter.card || undefined,
twitterTitle: meta.twitter.title || undefined,
twitterDescription: meta.twitter.description || undefined,
robots: meta.robots,
});
useHead({
link: [
meta.canonical ? { rel: 'canonical', href: meta.canonical } : {},
].filter(Boolean) as any,
meta: [{ property: 'og:locale', content: locale.value }],
script: meta.jsonLd.map((obj: any) => ({
type: 'application/ld+json',
innerHTML: JSON.stringify(obj),
})),
__dangerouslyDisableSanitizersByTagID: Object.fromEntries(
meta.jsonLd.map((_, i: number) => [`ldjson-${i}`, ['innerHTML']])
),
});
}
watch(
() => category.value,
(cat) => {
if (cat && !useRoute().query.maxPrice) {
maxPrice.value = cat.minMaxPrices.maxPrice;
}
},
{ immediate: true }
);
const { pending, products, pageInfo, variables, getProducts } = useStore({
orderBy: orderBy.value,
categoriesSlugs: categorySlug.value,
productAfter: '',
minPrice: minPrice.value,
maxPrice: maxPrice.value,
brand: brand.value?.name,
attributes: attributes.value
});
await getProducts();
const { buildAttributesString } = useFilters(filters);
const showFilter = ref<boolean>(false);
function onFilterToggle() {
showFilter.value = true;
}
async function onFiltersChange(newFilters: Record<string, string[]>) {
const attrString = buildAttributesString(newFilters);
attributes.value = attrString;
variables.attributes = attrString;
await getProducts();
}
const updateMinPrice = useDebounceFn((filteredPrice) => {
minPrice.value = filteredPrice;
}, 500);
const updateMaxPrice = useDebounceFn((filteredPrice) => {
maxPrice.value = filteredPrice;
}, 500);
useIntersectionObserver(
observer,
async ([{ isIntersecting }]) => {
if (isIntersecting && pageInfo.value?.hasNextPage && !pending.value) {
variables.productAfter = pageInfo.value.endCursor;
await getProducts();
}
},
);
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();
}
);
</script>
<style scoped lang="scss">
.store {
position: relative;
display: flex;
align-items: flex-start;
gap: 32px;
&__inner {
width: 100%;
}
&__list {
margin-top: 32px;
&-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 25px;
}
&-list {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 20px;
}
&-observer {
background-color: transparent;
width: 100%;
height: 10px;
}
}
&__empty {
color: $primary;
font-weight: 500;
font-size: 18px;
}
}
</style>