feat(filters): add filters for the main page
This commit is contained in:
@@ -5,9 +5,9 @@
|
||||
<section class="safe-top">
|
||||
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/>
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition name="route" appear>
|
||||
<KeepAlive include="Home" :key="filtersStore.paramsHashForRouter">
|
||||
<component :is="Component" :key="route.fullPath"/>
|
||||
</Transition>
|
||||
</KeepAlive>
|
||||
</RouterView>
|
||||
<CartButton v-if="settings.store_enabled"/>
|
||||
</section>
|
||||
@@ -21,6 +21,7 @@ import {useMiniApp, FullscreenViewport} from 'vue-tg';
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import CartButton from "@/components/CartButton.vue";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||
|
||||
const tg = useMiniApp();
|
||||
const platform = ref();
|
||||
@@ -32,6 +33,7 @@ disableVerticalSwipes();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const settings = useSettingsStore();
|
||||
const filtersStore = useProductFiltersStore();
|
||||
const backButton = window.Telegram.WebApp.BackButton;
|
||||
const haptic = window.Telegram.WebApp.HapticFeedback;
|
||||
|
||||
@@ -54,21 +56,3 @@ watch(
|
||||
{immediate: true}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* route transitions */
|
||||
.route-enter-active,
|
||||
.route-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.route-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.route-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,10 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center text-center py-16">
|
||||
<span class="text-5xl mb-4">🛒</span>
|
||||
<h2 class="text-xl font-semibold mb-2">Здесь пока пусто</h2>
|
||||
<p class="text-sm mb-4">Мы уже выехали на склад, чтобы найти что-нибудь подходящее.</p>
|
||||
<button class="btn btn-primary" @click="goBack">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
Назад
|
||||
</button>
|
||||
<h2 class="text-xl font-semibold mb-2">Здесь нет товаров</h2>
|
||||
<p class="text-sm mb-4">
|
||||
Попробуйте изменить настройки фильтров
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<template>
|
||||
<div class="border-b mb-5 border-b-base-200 flex justify-between">
|
||||
<div class="mb-2 text-base-content/40">Товары для главной страницы</div>
|
||||
<div class="mb-2">Рекомендуемые товары</div>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
disabled
|
||||
:checked="filter.criteria.product_for_main_page.params.value"
|
||||
v-model="filter.criteria.product_for_main_page.params.value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="border-b mb-5 border-b-base-200">
|
||||
<div class="mb-2">Категория</div>
|
||||
<div v-if="categoriesStore.isLoading" class="skeleton h-10 w-full"></div>
|
||||
<select
|
||||
v-else
|
||||
v-model.number="props.filter.criteria.product_category_id.params.value"
|
||||
class="select w-full"
|
||||
>
|
||||
<option :value="null">Любая категория</option>
|
||||
<SelectOption
|
||||
v-for="category in categoriesStore.categories"
|
||||
:key="category.id"
|
||||
:level="0"
|
||||
:category="category"
|
||||
/>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
|
||||
import {onMounted} from "vue";
|
||||
import SelectOption from "@/components/ProductFilters/Components/ProductCategory/SelectOption.vue";
|
||||
|
||||
const props = defineProps({
|
||||
filter: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const categoriesStore = useCategoriesStore();
|
||||
|
||||
onMounted(() => {
|
||||
categoriesStore.fetchCategories();
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<option :value="category.id">
|
||||
{{ "-".repeat(level) }} {{ category.name }}
|
||||
</option>
|
||||
|
||||
<SelectOption
|
||||
v-if="category.children"
|
||||
v-for="child in category.children"
|
||||
:key="child.id"
|
||||
:category="child"
|
||||
:level="level + 1"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
category: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="fixed top-0 left-0 z-50 w-full h-full bg-base-200 flex flex-col safe-top"
|
||||
>
|
||||
<header class="text-center shrink-0 p-3 font-bold text-xl">
|
||||
Фильтры
|
||||
</header>
|
||||
|
||||
<main class="mt-5 px-5 bg-base-200">
|
||||
<div
|
||||
v-if="draft?.rules && Object.keys(draft.rules).length > 0"
|
||||
v-for="(filter, filterId) in draft.rules"
|
||||
>
|
||||
<component
|
||||
v-if="componentMap[filterId]"
|
||||
:is="componentMap[filterId]"
|
||||
:filter="filter"
|
||||
/>
|
||||
|
||||
<p v-else>Not supported {{ filter.type }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-base-100 rounded-2xl p-5">
|
||||
Нет фильтров
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProductPrice from "@/components/ProductFilters/Components/ProductPrice.vue";
|
||||
import {onMounted, onUnmounted, ref} from "vue";
|
||||
import ForMainPage from "@/components/ProductFilters/Components/ForMainPage.vue";
|
||||
|
||||
const componentMap = {
|
||||
RULE_PRODUCT_PRICE: ProductPrice,
|
||||
RULE_PRODUCT_FOR_MAIN_PAGE: ForMainPage,
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
closeOnOverlay: {type: Boolean, default: true},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'apply', 'reset']);
|
||||
|
||||
const mainButton = window.Telegram.WebApp.MainButton;
|
||||
const secondaryButton = window.Telegram.WebApp.SecondaryButton;
|
||||
const backButton = window.Telegram.WebApp.BackButton;
|
||||
const draft = ref({});
|
||||
|
||||
const applyFilters = () => {
|
||||
emit('apply', draft);
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
function closeFiltersWithoutApply() {
|
||||
emit('close');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.document.body.style.overflow = 'hidden';
|
||||
|
||||
// Crete draft of the filters.
|
||||
draft.value = JSON.parse(JSON.stringify(props.filters));
|
||||
|
||||
mainButton.setParams({
|
||||
text: 'Применить',
|
||||
is_active: true,
|
||||
is_visible: true,
|
||||
});
|
||||
mainButton.show();
|
||||
mainButton.onClick(applyFilters);
|
||||
|
||||
secondaryButton.setParams({
|
||||
text: 'Сбросить фильтры',
|
||||
is_active: true,
|
||||
is_visible: true,
|
||||
position: 'top',
|
||||
});
|
||||
secondaryButton.show();
|
||||
secondaryButton.onClick(resetFilters);
|
||||
|
||||
backButton.show();
|
||||
backButton.onClick(closeFiltersWithoutApply);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
mainButton.hide();
|
||||
secondaryButton.hide();
|
||||
mainButton.offClick(applyFilters);
|
||||
secondaryButton.offClick(resetFilters);
|
||||
backButton.hide();
|
||||
backButton.offClick(closeFiltersWithoutApply);
|
||||
window.document.body.style.overflow = '';
|
||||
});
|
||||
</script>
|
||||
@@ -1,31 +0,0 @@
|
||||
export const FILTERS_MAIN_PAGE_DEFAULT = {
|
||||
operand: "AND",
|
||||
rules: {
|
||||
RULE_PRODUCT_PRICE: {
|
||||
criteria: {
|
||||
"product_price":
|
||||
{
|
||||
"type": "number",
|
||||
"params": {
|
||||
"operator": "between",
|
||||
"value": {
|
||||
"from": "",
|
||||
"to": ""
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
RULE_PRODUCT_FOR_MAIN_PAGE: {
|
||||
"criteria": {
|
||||
"product_for_main_page": {
|
||||
"type": "boolean",
|
||||
"params": {
|
||||
"operator": "equals",
|
||||
"value": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
<swiper-container ref="swiperEl" init="false" pagination-dynamic-bullets="true">
|
||||
<swiper-slide
|
||||
v-for="image in images"
|
||||
lazy
|
||||
:key="image.url"
|
||||
class="bg-base-100 overflow-hidden"
|
||||
style="aspect-ratio:1/1; border-radius:12px;"
|
||||
>
|
||||
@@ -18,7 +18,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {onMounted, onUnmounted, ref} from "vue";
|
||||
import {onActivated, onMounted, onUnmounted, ref} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
images: {
|
||||
@@ -45,12 +45,43 @@ onUnmounted(() => {
|
||||
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
swiperEl.value?.addEventListener('swiperactiveindexchange', (event) => {
|
||||
window.Telegram.WebApp.HapticFeedback.selectionChanged();
|
||||
onMounted(() => {
|
||||
const el = swiperEl.value;
|
||||
if (!el) return;
|
||||
|
||||
el.addEventListener('swiperactiveindexchange', () => {
|
||||
window.Telegram?.WebApp?.HapticFeedback?.selectionChanged();
|
||||
});
|
||||
|
||||
Object.assign(swiperEl.value, params);
|
||||
swiperEl.value.initialize();
|
||||
Object.assign(el, params);
|
||||
el.initialize();
|
||||
|
||||
// 👇 важно, особенно если картинки подгружаются не сразу
|
||||
el.addEventListener('swiperinit', () => {
|
||||
el.swiper.update();
|
||||
});
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
const el = swiperEl.value
|
||||
if (!el) return;
|
||||
|
||||
// Если swiper есть, но pagination потерялся — уничтожаем
|
||||
if (el.swiper) {
|
||||
try {
|
||||
el.swiper.destroy(true, true)
|
||||
} catch (e) {
|
||||
console.warn('Failed to destroy swiper', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Переинициализация с параметрами
|
||||
Object.assign(el, params)
|
||||
el.initialize()
|
||||
|
||||
// Пересчёт пагинации после инициализации
|
||||
el.addEventListener('swiperinit', () => {
|
||||
el.swiper.update()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl px-4 py-4 sm:px-6 sm:py-6 lg:max-w-7xl lg:px-8">
|
||||
<h2 class="text-lg font-bold mb-5 text-center">{{ productsStore.products.meta.currentCategoryName }}</h2>
|
||||
<div class="mx-auto max-w-2xl px-4 py-4 pb-14">
|
||||
<h2 v-if="categoryName" class="text-lg font-bold mb-5 text-center">{{ categoryName }}</h2>
|
||||
|
||||
<div v-if="productsStore.products.data.length > 0">
|
||||
<template v-if="products.length > 0">
|
||||
<div
|
||||
class="products-grid grid grid-cols-2 gap-x-5 gap-y-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8"
|
||||
>
|
||||
<RouterLink
|
||||
v-for="product in productsStore.products.data"
|
||||
v-for="product in products"
|
||||
:key="product.id"
|
||||
class="product-grid-card group"
|
||||
:to="`/product/${product.id}`"
|
||||
@@ -26,21 +26,16 @@
|
||||
<div ref="bottom" style="height: 1px;"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="productsStore.isLoading" class="text-center mt-5">
|
||||
<span class="loading loading-spinner loading-md"></span> Загрузка...
|
||||
<div v-if="isLoadingMore" class="text-center mt-5">
|
||||
<span class="loading loading-spinner loading-md"></span> Загрузка товаров...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="productsStore.products.meta.hasMore === false"
|
||||
class="text-xs text-center mt-4 pt-4 mb-2 border-t"
|
||||
>
|
||||
<div v-else-if="hasMore === false" class="text-xs text-center mt-4 pt-4 mb-2 border-t">
|
||||
{{ settings.noMoreProductsMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<NoProducts v-else-if="productsStore.loadFinished"/>
|
||||
|
||||
<div v-else
|
||||
<div v-else-if="isLoading === true"
|
||||
class="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
|
||||
<div v-for="n in 8" :key="n" class="animate-pulse space-y-2">
|
||||
<div class="aspect-square bg-gray-200 rounded-md"></div>
|
||||
@@ -48,32 +43,71 @@
|
||||
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NoProducts v-else/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NoProducts from "@/components/NoProducts.vue";
|
||||
import ProductImageSwiper from "@/components/ProductImageSwiper.vue";
|
||||
import {useProductsStore} from "@/stores/ProductsStore.js";
|
||||
import {useInfiniteScroll} from '@vueuse/core';
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import {ref} from "vue";
|
||||
import {useIntersectionObserver} from '@vueuse/core';
|
||||
|
||||
const productsStore = useProductsStore();
|
||||
const settings = useSettingsStore();
|
||||
const bottom = ref(null);
|
||||
|
||||
const emits = defineEmits(['loadMore']);
|
||||
|
||||
const props = defineProps({
|
||||
products: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
hasMore: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
categoryName: {
|
||||
type: String,
|
||||
default: () => '',
|
||||
},
|
||||
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
isLoadingMore: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
|
||||
function haptic() {
|
||||
window.Telegram.WebApp.HapticFeedback.selectionChanged();
|
||||
// productsStore.savedScrollY = window.scrollY;
|
||||
// console.log("Store scrollY: ", productsStore.savedScrollY);
|
||||
}
|
||||
|
||||
useInfiniteScroll(
|
||||
useIntersectionObserver(
|
||||
bottom,
|
||||
async () => await productsStore.loadMore(),
|
||||
{distance: 1000}
|
||||
)
|
||||
([entry]) => {
|
||||
console.debug('Check Intersection');
|
||||
if (entry?.isIntersecting === true
|
||||
&& props.hasMore === true
|
||||
&& props.isLoading === false
|
||||
&& props.isLoadingMore === false
|
||||
) {
|
||||
emits('loadMore');
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '400px 0',
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { register } from 'swiper/element/bundle';
|
||||
import 'swiper/element/bundle';
|
||||
import 'swiper/css/bundle';
|
||||
import AppLoading from "@/AppLoading.vue";
|
||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||
register();
|
||||
|
||||
const pinia = createPinia();
|
||||
@@ -30,6 +31,11 @@ const appLoading = createApp(AppLoading);
|
||||
appLoading.mount('#app');
|
||||
|
||||
settings.load()
|
||||
.then(async () => {
|
||||
console.debug('Load default filters for the main page');
|
||||
const filtersStore = useProductFiltersStore();
|
||||
filtersStore.applied = await filtersStore.fetchFiltersForMainPage();
|
||||
})
|
||||
.then(() => {
|
||||
if (settings.app_enabled === false) {
|
||||
throw new Error('App disabled (maintenance mode)');
|
||||
|
||||
@@ -7,6 +7,7 @@ import Products from "@/views/Products.vue";
|
||||
import Checkout from "@/views/Checkout.vue";
|
||||
import OrderCreated from "@/views/OrderCreated.vue";
|
||||
import Search from "@/views/Search.vue";
|
||||
import Filters from "@/views/Filters.vue";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -14,6 +15,7 @@ const routes = [
|
||||
name: 'home',
|
||||
component: Home,
|
||||
},
|
||||
{path: '/filters', name: 'filters', component: Filters},
|
||||
{path: '/product/:id', name: 'product.show', component: Product},
|
||||
{
|
||||
path: '/products/:category_id',
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import {defineStore} from "pinia";
|
||||
|
||||
import {getFiltersForMainPage} from "@/utils/ftch.js";
|
||||
import {md5} from "js-md5";
|
||||
import {toRaw} from "vue";
|
||||
|
||||
export const useProductFiltersStore = defineStore('product_filters', {
|
||||
state: () => ({
|
||||
filters: {},
|
||||
isLoading: false,
|
||||
draft: {},
|
||||
applied: {},
|
||||
fullPath: '',
|
||||
}),
|
||||
|
||||
getters: {
|
||||
hasFilters: (state) => state.filters?.rules && Object.keys(state.filters.rules).length > 0,
|
||||
paramsHashForRouter: (state) => md5(JSON.stringify({ filters: state.applied })),
|
||||
},
|
||||
|
||||
actions: {
|
||||
reset() {
|
||||
async fetchFiltersForMainPage() {
|
||||
if (this.isLoading) return;
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const response = await getFiltersForMainPage();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
clear() {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const useProductsStore = defineStore('products', {
|
||||
search: '',
|
||||
page: 1,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
loadFinished: false,
|
||||
savedScrollY: 0,
|
||||
currentLoadedParamsHash: null,
|
||||
@@ -23,6 +24,10 @@ export const useProductsStore = defineStore('products', {
|
||||
|
||||
getters: {
|
||||
paramsHash: (state) => md5(JSON.stringify(toRaw(state.getParams()))),
|
||||
paramsHashForRouter: (state) => md5(JSON.stringify({
|
||||
search: state.search,
|
||||
filters: toRaw(state.filters),
|
||||
})),
|
||||
},
|
||||
|
||||
actions: {
|
||||
@@ -36,8 +41,6 @@ export const useProductsStore = defineStore('products', {
|
||||
|
||||
async fetchProducts() {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
|
||||
console.debug('Current params hash: ', this.currentLoadedParamsHash);
|
||||
if (this.products.data.length > 0 && this.paramsHash === this.currentLoadedParamsHash) {
|
||||
console.debug('Loading products from cache');
|
||||
@@ -61,7 +64,6 @@ export const useProductsStore = defineStore('products', {
|
||||
console.error("Failed to load products");
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -70,8 +72,8 @@ export const useProductsStore = defineStore('products', {
|
||||
|
||||
try {
|
||||
console.debug('Load products with filters', filters);
|
||||
console.debug('Filters for URL: ', this.filtersFullUrl);
|
||||
this.isLoading = false;
|
||||
this.reset();
|
||||
this.isLoading = true;
|
||||
this.page = 1;
|
||||
this.loadFinished = false;
|
||||
this.search = '';
|
||||
@@ -86,10 +88,10 @@ export const useProductsStore = defineStore('products', {
|
||||
},
|
||||
|
||||
async loadMore() {
|
||||
if (this.isLoading || this.products.meta.hasMore === false) return;
|
||||
if (this.isLoading || this.isLoadingMore || this.products.meta.hasMore === false) return;
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.isLoadingMore = true;
|
||||
this.page++;
|
||||
console.debug('Load more products for page: ', this.page);
|
||||
const response = await this.fetchProducts();
|
||||
@@ -98,8 +100,9 @@ export const useProductsStore = defineStore('products', {
|
||||
} catch (e) {
|
||||
console.error('Ошибка загрузки', e);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.isLoadingMore = false;
|
||||
this.loadFinished = true;
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import {defineStore} from "pinia";
|
||||
import ftch from "@/utils/ftch.js";
|
||||
|
||||
export const useSearchStore = defineStore('search', {
|
||||
|
||||
state: () => ({
|
||||
search: '',
|
||||
page: 1,
|
||||
|
||||
@@ -68,4 +68,8 @@ export async function fetchSettings() {
|
||||
return await ftch('settings');
|
||||
}
|
||||
|
||||
export async function getFiltersForMainPage() {
|
||||
return await ftch('filtersForMainPage');
|
||||
}
|
||||
|
||||
export default ftch;
|
||||
|
||||
108
spa/src/views/Filters.vue
Normal file
108
spa/src/views/Filters.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div ref="goodsRef" class="pb-10">
|
||||
<div class="flex flex-col">
|
||||
<header class="text-center shrink-0 p-3 font-bold text-xl">
|
||||
Фильтры
|
||||
</header>
|
||||
|
||||
<main class="mt-5 px-5 bg-base-200">
|
||||
<div
|
||||
v-if="filtersStore.draft?.rules && Object.keys(filtersStore.draft.rules).length > 0"
|
||||
v-for="(filter, filterId) in filtersStore.draft.rules"
|
||||
>
|
||||
<component
|
||||
v-if="componentMap[filterId]"
|
||||
:is="componentMap[filterId]"
|
||||
:filter="filter"
|
||||
/>
|
||||
|
||||
<p v-else>Not supported: {{ filterId }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
Нет фильтров
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {nextTick, onMounted, onUnmounted} from "vue";
|
||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||
import ProductPrice from "@/components/ProductFilters/Components/ProductPrice.vue";
|
||||
import ForMainPage from "@/components/ProductFilters/Components/ForMainPage.vue";
|
||||
import {useRouter} from "vue-router";
|
||||
import ProductCategory from "@/components/ProductFilters/Components/ProductCategory/ProductCategory.vue";
|
||||
|
||||
defineOptions({
|
||||
name: 'Filters'
|
||||
});
|
||||
|
||||
const componentMap = {
|
||||
RULE_PRODUCT_PRICE: ProductPrice,
|
||||
RULE_PRODUCT_FOR_MAIN_PAGE: ForMainPage,
|
||||
RULE_PRODUCT_CATEGORY: ProductCategory,
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const emit = defineEmits(['close', 'apply']);
|
||||
|
||||
const filtersStore = useProductFiltersStore();
|
||||
|
||||
const mainButton = window.Telegram.WebApp.MainButton;
|
||||
const secondaryButton = window.Telegram.WebApp.SecondaryButton;
|
||||
const haptic = window.Telegram.WebApp.HapticFeedback;
|
||||
|
||||
const applyFilters = async () => {
|
||||
filtersStore.applied = JSON.parse(JSON.stringify(filtersStore.draft));
|
||||
console.debug('Filters: apply filters. Hash for router: ', filtersStore.paramsHashForRouter);
|
||||
haptic.impactOccurred('soft');
|
||||
await nextTick();
|
||||
router.back();
|
||||
}
|
||||
|
||||
const resetFilters = async () => {
|
||||
filtersStore.applied = await filtersStore.fetchFiltersForMainPage();
|
||||
console.debug('Filters: reset filters. Hash for router: ', filtersStore.paramsHashForRouter);
|
||||
haptic.notificationOccurred('success');
|
||||
await nextTick();
|
||||
window.scrollTo(0, 0);
|
||||
router.back();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
console.debug('Filters: OnMounted');
|
||||
mainButton.setParams({
|
||||
text: 'Применить',
|
||||
is_active: true,
|
||||
is_visible: true,
|
||||
});
|
||||
mainButton.show();
|
||||
mainButton.onClick(applyFilters);
|
||||
|
||||
secondaryButton.setParams({
|
||||
text: 'Сбросить фильтры',
|
||||
is_active: true,
|
||||
is_visible: true,
|
||||
position: 'top',
|
||||
});
|
||||
secondaryButton.show();
|
||||
secondaryButton.onClick(resetFilters);
|
||||
|
||||
if (filtersStore.applied?.rules) {
|
||||
console.debug('Filters: Found applied filters.');
|
||||
filtersStore.draft = JSON.parse(JSON.stringify(filtersStore.applied));
|
||||
} else {
|
||||
console.debug('No filters. Load filters from server');
|
||||
filtersStore.draft = await filtersStore.fetchFiltersForMainPage();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
mainButton.hide();
|
||||
secondaryButton.hide();
|
||||
mainButton.offClick(applyFilters);
|
||||
secondaryButton.offClick(resetFilters);
|
||||
});
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="goodsRef">
|
||||
<div ref="goodsRef" class="pb-10">
|
||||
<CategoriesInline/>
|
||||
|
||||
<div class="px-5 fixed z-50 w-full opacity-90" style="bottom: var(--tg-safe-area-inset-bottom);">
|
||||
@@ -11,13 +11,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductsList/>
|
||||
<Filters
|
||||
v-if="isFiltersShow"
|
||||
:filters="productsStore.filters"
|
||||
@apply="applyFilters"
|
||||
@reset="resetFilters"
|
||||
@close="closeFilters"
|
||||
<ProductsList
|
||||
:products="products"
|
||||
:hasMore="hasMore"
|
||||
:isLoading="isLoading"
|
||||
:isLoadingMore="isLoadingMore"
|
||||
@loadMore="onLoadMore"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -26,59 +25,78 @@
|
||||
import ProductsList from "@/components/ProductsList.vue";
|
||||
import CategoriesInline from "../components/CategoriesInline.vue";
|
||||
import SearchInput from "@/components/SearchInput.vue";
|
||||
import Filters from "@/components/ProductFilters/Filters.vue";
|
||||
import {onMounted, onUnmounted, ref} from "vue";
|
||||
import {useProductsStore} from "@/stores/ProductsStore.js";
|
||||
import {onActivated, onMounted, ref, toRaw} from "vue";
|
||||
import IconFunnel from "@/components/Icons/IconFunnel.vue";
|
||||
import {FILTERS_MAIN_PAGE_DEFAULT} from "@/components/ProductFilters/filters.js";
|
||||
import {useRoute} from "vue-router";
|
||||
import {useRouter} from "vue-router";
|
||||
import ftch from "@/utils/ftch.js";
|
||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||
|
||||
const route = useRoute();
|
||||
const productsStore = useProductsStore();
|
||||
defineOptions({
|
||||
name: 'Home'
|
||||
});
|
||||
|
||||
const isFiltersShow = ref(false);
|
||||
const backButton = window.Telegram.WebApp.BackButton;
|
||||
const router = useRouter();
|
||||
const filtersStore = useProductFiltersStore();
|
||||
const haptic = window.Telegram.WebApp.HapticFeedback;
|
||||
|
||||
const products = ref([]);
|
||||
const hasMore = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const isLoadingMore = ref(false);
|
||||
const page = ref(1);
|
||||
|
||||
function showFilters() {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
|
||||
isFiltersShow.value = true;
|
||||
backButton.show();
|
||||
haptic.impactOccurred('soft');
|
||||
router.push({name: 'filters'});
|
||||
}
|
||||
|
||||
function closeFilters() {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('rigid');
|
||||
isFiltersShow.value = false;
|
||||
}
|
||||
|
||||
async function applyFilters(newFilters) {
|
||||
closeFilters();
|
||||
console.log("Load products with new filters: ", newFilters);
|
||||
productsStore.page = 1;
|
||||
await productsStore.loadProducts(newFilters);
|
||||
}
|
||||
|
||||
async function resetFilters() {
|
||||
closeFilters();
|
||||
productsStore.reset();
|
||||
await productsStore.loadProducts(FILTERS_MAIN_PAGE_DEFAULT);
|
||||
}
|
||||
|
||||
function handleClickOutside(e) {
|
||||
if (!e.target.closest('input, textarea')) {
|
||||
document.activeElement?.blur()
|
||||
async function fetchProducts() {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
console.debug('Home: Load products for Main Page.');
|
||||
console.debug('Home: Fetch products from server using filters: ', toRaw(filtersStore.applied));
|
||||
const response = await ftch('products', null, toRaw({
|
||||
page: page.value,
|
||||
filters: filtersStore.applied,
|
||||
}));
|
||||
products.value = response.data;
|
||||
hasMore.value = response.meta.hasMore;
|
||||
console.debug('Home: Products for main page loaded.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => document.removeEventListener('click', handleClickOutside));
|
||||
async function onLoadMore() {
|
||||
try {
|
||||
console.debug('Home: onLoadMore');
|
||||
if (isLoading.value === true || isLoadingMore.value === true || hasMore.value === false) return;
|
||||
isLoadingMore.value = true;
|
||||
page.value++;
|
||||
console.debug('Home: Load more for page ', page.value, ' using filters: ', toRaw(filtersStore.applied));
|
||||
const response = await ftch('products', null, toRaw({
|
||||
page: page.value,
|
||||
filters: filtersStore.applied,
|
||||
}));
|
||||
products.value.push(...response.data);
|
||||
hasMore.value = response.meta.hasMore;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
if (productsStore.filtersFullUrl !== route.fullPath) {
|
||||
productsStore.filtersFullUrl = route.fullPath;
|
||||
await productsStore.loadProducts(FILTERS_MAIN_PAGE_DEFAULT);
|
||||
} else {
|
||||
await productsStore.loadProducts(productsStore.filters ?? FILTERS_MAIN_PAGE_DEFAULT);
|
||||
}
|
||||
console.debug("Home: Home Mounted");
|
||||
console.debug("Home: Scroll top");
|
||||
await fetchProducts();
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
|
||||
onActivated(async () => {
|
||||
console.debug('Home: Activated Home');
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<template>
|
||||
<div ref="goodsRef">
|
||||
<div ref="goodsRef" class="pb-10">
|
||||
<div class="px-5 fixed z-50 w-full opacity-90" style="bottom: var(--tg-safe-area-inset-bottom);">
|
||||
<div class="bg-base-300 flex justify-between p-2 rounded-xl shadow-md">
|
||||
<SearchInput/>
|
||||
</div>
|
||||
</div>
|
||||
<ProductsList/>
|
||||
<ProductsList
|
||||
:products="productsStore.products.data"
|
||||
:hasMore="productsStore.products.meta.hasMore"
|
||||
:isLoading="productsStore.isLoading"
|
||||
:isLoadingMore="productsStore.isLoadingMore"
|
||||
@loadMore="productsStore.loadMore"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -17,12 +23,17 @@ import {useRoute} from "vue-router";
|
||||
import {useProductsStore} from "@/stores/ProductsStore.js";
|
||||
import IconFunnel from "@/components/Icons/IconFunnel.vue";
|
||||
|
||||
defineOptions({
|
||||
name: 'Products'
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const productsStore = useProductsStore();
|
||||
|
||||
const categoryId = route.params.category_id ?? null;
|
||||
|
||||
onMounted(async () => {
|
||||
console.debug("Category Products Mounted");
|
||||
console.debug("Load products for category: ", categoryId);
|
||||
|
||||
if (productsStore.filtersFullUrl === route.fullPath) {
|
||||
|
||||
@@ -91,6 +91,5 @@ onUnmounted(() => {
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
nextTick(() => searchInput.value.focus());
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user