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/notification";
|
||||||
@use "ui/rating";
|
@use "ui/rating";
|
||||||
@use "ui/select";
|
@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__inner">
|
||||||
<div class="header__block">
|
<div class="header__block">
|
||||||
<icon
|
<icon
|
||||||
|
v-if="uiConfig.showSearchBar"
|
||||||
@click="isSearchVisible = true"
|
@click="isSearchVisible = true"
|
||||||
class="header__block-search"
|
class="header__block-search"
|
||||||
name="tabler:search"
|
name="tabler:search"
|
||||||
|
|
|
||||||
|
|
@ -4,73 +4,86 @@
|
||||||
<h2>{{ t('store.filters.title') }}</h2>
|
<h2>{{ t('store.filters.title') }}</h2>
|
||||||
<p @click="resetFilters">{{ t('buttons.clearAll') }}</p>
|
<p @click="resetFilters">{{ t('buttons.clearAll') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<el-collapse v-model="collapse">
|
<client-only>
|
||||||
<el-collapse-item
|
<el-collapse v-model="collapse">
|
||||||
name="0"
|
<el-collapse-item
|
||||||
>
|
name="0"
|
||||||
<template #title>
|
>
|
||||||
<div class="filters__head">
|
<template #title="{ isActive }">
|
||||||
<h3 class="filters__name" v-text="t('market.filter.price')" />
|
<div class="filters__head">
|
||||||
</div>
|
<h3 class="filters__name" v-text="t('store.filters.price')" />
|
||||||
</template>
|
<icon
|
||||||
<div class="filters__price">
|
name="material-symbols:keyboard-arrow-down"
|
||||||
<div class="filters__price-inputs">
|
size="22"
|
||||||
<ui-input
|
class="filters__head-icon"
|
||||||
:model-value="priceMinInput"
|
:class="[{ active: isActive }]"
|
||||||
type="text"
|
/>
|
||||||
placeholder="Min"
|
</div>
|
||||||
input-mode="decimal"
|
</template>
|
||||||
@update:model-value="(val) => priceMinInput = val"
|
<div class="filters__price">
|
||||||
@blur="handlePriceBlur(priceMinInput, 'min')"
|
<div class="filters__price-inputs">
|
||||||
/>
|
<ui-input
|
||||||
<span class="filters__separator">—</span>
|
:model-value="priceMinInput"
|
||||||
<ui-input
|
type="text"
|
||||||
:model-value="priceMaxInput"
|
placeholder="Min"
|
||||||
type="text"
|
input-mode="decimal"
|
||||||
placeholder="Max"
|
@update:model-value="(val) => priceMinInput = val"
|
||||||
input-mode="decimal"
|
@blur="handlePriceBlur(priceMinInput, 'min')"
|
||||||
@update:model-value="(val) => priceMaxInput = val"
|
/>
|
||||||
@blur="handlePriceBlur(priceMaxInput, 'max')"
|
<span class="filters__separator">—</span>
|
||||||
|
<ui-input
|
||||||
|
:model-value="priceMaxInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="Max"
|
||||||
|
input-mode="decimal"
|
||||||
|
@update:model-value="(val) => priceMaxInput = val"
|
||||||
|
@blur="handlePriceBlur(priceMaxInput, 'max')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-slider
|
||||||
|
v-model="priceRange"
|
||||||
|
:min="categoryMin"
|
||||||
|
:max="categoryMax"
|
||||||
|
range
|
||||||
|
:step="0.01"
|
||||||
|
:format-tooltip="formatPriceTooltip"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<el-slider
|
</el-collapse-item>
|
||||||
v-model="priceRange"
|
<el-collapse-item
|
||||||
:min="categoryMin"
|
v-if="filterableAttributes"
|
||||||
:max="categoryMax"
|
v-for="(attribute, idx) in filterableAttributes"
|
||||||
range
|
:key="idx"
|
||||||
:step="0.01"
|
:name="`${idx + 2}`"
|
||||||
:format-tooltip="formatPriceTooltip"
|
>
|
||||||
/>
|
<template #title="{ isActive }">
|
||||||
</div>
|
<div class="filters__head">
|
||||||
</el-collapse-item>
|
<h3 class="filters__name" v-text="attribute.attributeName" />
|
||||||
<el-collapse-item
|
<icon
|
||||||
v-if="filterableAttributes"
|
name="material-symbols:keyboard-arrow-down"
|
||||||
v-for="(attribute, idx) in filterableAttributes"
|
size="22"
|
||||||
:key="idx"
|
class="filters__head-icon"
|
||||||
:name="`${idx + 2}`"
|
:class="[{ active: isActive }]"
|
||||||
>
|
/>
|
||||||
<template #title>
|
</div>
|
||||||
<div class="filters__head">
|
</template>
|
||||||
<h3 class="filters__name" v-text="attribute.attributeName" />
|
<ul class="filters__list">
|
||||||
</div>
|
<li
|
||||||
</template>
|
v-for="(value, idx) of attribute.possibleValues"
|
||||||
<ul class="filters__list">
|
:key="idx"
|
||||||
<li
|
class="filters__item"
|
||||||
v-for="(value, idx) of attribute.possibleValues"
|
|
||||||
:key="idx"
|
|
||||||
class="filters__item"
|
|
||||||
>
|
|
||||||
<ui-checkbox
|
|
||||||
:id="attribute.attributeName + idx"
|
|
||||||
v-model="selectedMap[attribute.attributeName][value]"
|
|
||||||
>
|
>
|
||||||
{{ value }}
|
<ui-checkbox
|
||||||
</ui-checkbox>
|
:id="attribute.attributeName + idx"
|
||||||
</li>
|
v-model="selectedMap[attribute.attributeName][value]"
|
||||||
</ul>
|
>
|
||||||
</el-collapse-item>
|
{{ value }}
|
||||||
</el-collapse>
|
</ui-checkbox>
|
||||||
<!-- <skeletons-market-filters v-else />-->
|
</li>
|
||||||
|
</ul>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</client-only>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -78,6 +91,7 @@
|
||||||
import type {IStoreFilters} from '@types';
|
import type {IStoreFilters} from '@types';
|
||||||
import {useFilters} from '@composables/store';
|
import {useFilters} from '@composables/store';
|
||||||
import {useRouteQuery} from '@vueuse/router';
|
import {useRouteQuery} from '@vueuse/router';
|
||||||
|
import {CURRENCY} from "~/constants";
|
||||||
|
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
@ -133,9 +147,6 @@ const initializeInputs = () => {
|
||||||
const max = props.initialMaxPrice ?? categoryMax.value;
|
const max = props.initialMaxPrice ?? categoryMax.value;
|
||||||
|
|
||||||
priceRange.value = [min, max];
|
priceRange.value = [min, max];
|
||||||
|
|
||||||
const { min: floatMin, max: floatMax } = getFloatMinMax();
|
|
||||||
floatRange.value = [floatMin, floatMax];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePriceInput = useDebounceFn((value: string | number, type: 'min' | 'max') => {
|
const handlePriceInput = useDebounceFn((value: string | number, type: 'min' | 'max') => {
|
||||||
|
|
@ -179,10 +190,6 @@ watch(priceRange, () => {
|
||||||
debouncedPriceUpdate();
|
debouncedPriceUpdate();
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|
||||||
watch(floatRange, () => {
|
|
||||||
debouncedFilterApply();
|
|
||||||
}, { deep: true });
|
|
||||||
|
|
||||||
watch(selectedMap, () => {
|
watch(selectedMap, () => {
|
||||||
debouncedFilterApply();
|
debouncedFilterApply();
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|
@ -230,18 +237,18 @@ watch(
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatPriceTooltip = (value: number) => `€${value.toFixed(2)}`;
|
const formatPriceTooltip = (value: number) => `${CURRENCY}${value.toFixed(2)}`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.filter {
|
.filters {
|
||||||
width: 290px;
|
width: 290px;
|
||||||
border: 1px solid $border;
|
border: 1px solid $border;
|
||||||
border-radius: $default_border_radius;
|
border-radius: $default_border_radius;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 25px;
|
padding: 20px;
|
||||||
|
|
||||||
&__top {
|
&__top {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -255,10 +262,10 @@ const formatPriceTooltip = (value: number) => `€${value.toFixed(2)}`;
|
||||||
letter-spacing: -0.5px;
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
& span {
|
& p {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: $link_primary;
|
color: $link_primary;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
letter-spacing: -0.5px;
|
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>
|
</style>
|
||||||
|
|
@ -3,9 +3,13 @@
|
||||||
<store-filter
|
<store-filter
|
||||||
v-if="filters.length"
|
v-if="filters.length"
|
||||||
:filterableAttributes="filters"
|
:filterableAttributes="filters"
|
||||||
:isOpen="showFilter"
|
|
||||||
@update:selected="onFiltersChange"
|
@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">
|
<div class="store__inner">
|
||||||
<store-top
|
<store-top
|
||||||
|
|
@ -75,7 +79,7 @@ const brandData = brandSlug.value
|
||||||
? await useBrandBySlug(brandSlug.value)
|
? await useBrandBySlug(brandSlug.value)
|
||||||
: { brand: ref(null), seoMeta: ref(null) };
|
: { 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 { brand, seoMeta: brandSeoMeta } = brandData;
|
||||||
|
|
||||||
const seoMeta = computed(() => categorySeoMeta.value || brandSeoMeta.value);
|
const seoMeta = computed(() => categorySeoMeta.value || brandSeoMeta.value);
|
||||||
|
|
@ -147,6 +151,13 @@ async function onFiltersChange(newFilters: Record<string, string[]>) {
|
||||||
await getProducts();
|
await getProducts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateMinPrice = useDebounceFn((filteredPrice) => {
|
||||||
|
minPrice.value = filteredPrice;
|
||||||
|
}, 500);
|
||||||
|
const updateMaxPrice = useDebounceFn((filteredPrice) => {
|
||||||
|
maxPrice.value = filteredPrice;
|
||||||
|
}, 500);
|
||||||
|
|
||||||
useIntersectionObserver(
|
useIntersectionObserver(
|
||||||
observer,
|
observer,
|
||||||
async ([{ isIntersecting }]) => {
|
async ([{ isIntersecting }]) => {
|
||||||
|
|
@ -157,30 +168,19 @@ useIntersectionObserver(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(orderBy, async (newVal) => {
|
watch(
|
||||||
variables.orderBy = newVal || '';
|
[orderBy, attributes, minPrice, maxPrice],
|
||||||
variables.productAfter = '';
|
async ([newOrder, newAttr, newMin, newMax]) => {
|
||||||
products.value = [];
|
variables.orderBy = newOrder || '';
|
||||||
await getProducts();
|
variables.attributes = newAttr;
|
||||||
});
|
variables.minPrice = Number(newMin) || 0;
|
||||||
watch(attributes, async (newVal) => {
|
variables.maxPrice = Number(newMax) || 500000;
|
||||||
variables.attributes = newVal;
|
variables.productAfter = '';
|
||||||
variables.productAfter = '';
|
products.value = [];
|
||||||
products.value = [];
|
|
||||||
await getProducts();
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|
@ -195,7 +195,7 @@ watch(maxPrice, async (newVal) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
&__list {
|
&__list {
|
||||||
margin-top: 50px;
|
margin-top: 32px;
|
||||||
|
|
||||||
&-grid {
|
&-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,6 @@ watch(select, value => {
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.top {
|
.top {
|
||||||
margin-bottom: 20px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export async function useCategoryBySlug(slug: string) {
|
||||||
if (!category.value) return [];
|
if (!category.value) return [];
|
||||||
return category.value.filterableAttributes.filter((attr) => attr.possibleValues.length > 0);
|
return category.value.filterableAttributes.filter((attr) => attr.possibleValues.length > 0);
|
||||||
});
|
});
|
||||||
|
const minMaxPrices = computed(() => category.value?.minMaxPrices ?? { minPrice: 0, maxPrice: 50000 });
|
||||||
|
|
||||||
watch(error, (err) => {
|
watch(error, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|
@ -22,5 +23,6 @@ export async function useCategoryBySlug(slug: string) {
|
||||||
category,
|
category,
|
||||||
seoMeta: computed(() => category.value?.seoMeta),
|
seoMeta: computed(() => category.value?.seoMeta),
|
||||||
filters,
|
filters,
|
||||||
|
minMaxPrices
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export function useStore(args: IProdArgs) {
|
||||||
pending.value = true;
|
pending.value = true;
|
||||||
|
|
||||||
const queryVariables = {
|
const queryVariables = {
|
||||||
productFirst: variables.first,
|
productFirst: variables.productFirst,
|
||||||
categoriesSlugs: variables.categoriesSlugs,
|
categoriesSlugs: variables.categoriesSlugs,
|
||||||
productOrderBy: variables.orderBy || undefined,
|
productOrderBy: variables.orderBy || undefined,
|
||||||
productAfter: variables.productAfter || undefined,
|
productAfter: variables.productAfter || undefined,
|
||||||
|
|
|
||||||
|
|
@ -75,25 +75,28 @@
|
||||||
>
|
>
|
||||||
{{ t('buttons.addToCart') }}
|
{{ t('buttons.addToCart') }}
|
||||||
</ui-button>
|
</ui-button>
|
||||||
<ui-button
|
<div class="product__main-buttons">
|
||||||
@click="overwriteWishlist({
|
<ui-button
|
||||||
|
@click="overwriteWishlist({
|
||||||
type: (isProductInWishlist ? 'remove' : 'add'),
|
type: (isProductInWishlist ? 'remove' : 'add'),
|
||||||
productUuid: product.uuid,
|
productUuid: product.uuid,
|
||||||
productName: product.name,
|
productName: product.name,
|
||||||
})"
|
})"
|
||||||
:type="'button'"
|
:type="'button'"
|
||||||
:style="'secondary'"
|
:style="'secondary'"
|
||||||
>
|
>
|
||||||
<icon name="mdi:cards-heart-outline" size="16" />
|
<icon name="mdi:cards-heart-outline" size="16" />
|
||||||
{{ isProductInWishlist ? t('buttons.removeFromWishlist') : t('buttons.addToWishlist') }}
|
{{ isProductInWishlist ? t('buttons.removeFromWishlist') : t('buttons.addToWishlist') }}
|
||||||
</ui-button>
|
</ui-button>
|
||||||
<ui-button
|
<ui-button
|
||||||
:type="'button'"
|
:type="'button'"
|
||||||
@click="buyProduct(product.uuid)"
|
:style="'secondary'"
|
||||||
:isLoading="buyLoading"
|
@click="buyProduct(product.uuid)"
|
||||||
>
|
:isLoading="buyLoading"
|
||||||
{{ t('buttons.buyNow') }}
|
>
|
||||||
</ui-button>
|
{{ t('buttons.buyNow') }}
|
||||||
|
</ui-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -189,6 +192,10 @@ const cookieWishlist = useCookie($appHelpers.COOKIES_WISHLIST_KEY, {
|
||||||
default: () => [],
|
default: () => [],
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
|
const cookieCart = useCookie($appHelpers.COOKIES_CART_KEY, {
|
||||||
|
default: () => [],
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
const isAuthenticated = computed(() => userStore.isAuthenticated);
|
const isAuthenticated = computed(() => userStore.isAuthenticated);
|
||||||
|
|
||||||
|
|
@ -414,6 +421,16 @@ watch(
|
||||||
letter-spacing: -0.5px;
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
& .button {
|
||||||
|
width: 49%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&-button {
|
&-button {
|
||||||
margin-top: 25px;
|
margin-top: 25px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,8 @@
|
||||||
"title": "Filters",
|
"title": "Filters",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"all": "All"
|
"all": "All",
|
||||||
|
"price": "Price"
|
||||||
},
|
},
|
||||||
"empty": "There are no products by these parameters."
|
"empty": "There are no products by these parameters."
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,8 @@
|
||||||
"title": "Фильтры",
|
"title": "Фильтры",
|
||||||
"apply": "Применить",
|
"apply": "Применить",
|
||||||
"reset": "Сбросить",
|
"reset": "Сбросить",
|
||||||
"all": "Все"
|
"all": "Все",
|
||||||
|
"price": "Цена"
|
||||||
},
|
},
|
||||||
"empty": "По этим параметрам нет товаров."
|
"empty": "По этим параметрам нет товаров."
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue