feat: add filters to mainpage

This commit is contained in:
2025-10-03 00:26:13 +03:00
parent 023acee68f
commit 1e2a9bc705
168 changed files with 5367 additions and 662 deletions

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/svg+xml" href="/vite.svg"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, orientation=portrait">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>OpenCart Telegram Mini App</title>
</head>
<body>

978
spa/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"@tailwindcss/vite": "^4.1.11",
"@vueuse/core": "^13.5.0",
"crypto-js": "^4.2.0",
"js-md5": "^0.8.3",
"ofetch": "^1.4.1",
"pinia": "^3.0.3",
"swiper": "^11.2.10",
@@ -27,6 +28,6 @@
"daisyui": "^5.0.46",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"vite": "^7.0.3"
"vite": "^7.1.7"
}
}

View File

@@ -2,8 +2,8 @@
<div class="app-container h-full">
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/>
<RouterView v-slot="{ Component, route }">
<Transition name="route" mode="out-in" appear>
<component :is="Component" :key="route.fullPath" />
<Transition name="route" appear>
<component :is="Component" :key="route.fullPath"/>
</Transition>
</RouterView>
<CartButton v-if="settings.store_enabled"/>
@@ -11,8 +11,8 @@
</template>
<script setup>
import {onMounted, ref, watch} from "vue";
import {useWebAppViewport, useBackButton} from 'vue-tg';
import {ref, watch} from "vue";
import {useWebAppViewport} from 'vue-tg';
import {useMiniApp, FullscreenViewport} from 'vue-tg';
import {useRoute, useRouter} from "vue-router";
import CartButton from "@/components/CartButton.vue";
@@ -28,47 +28,25 @@ disableVerticalSwipes();
const router = useRouter();
const route = useRoute();
const settings = useSettingsStore();
const backButton = window.Telegram.WebApp.BackButton;
const haptic = window.Telegram.WebApp.HapticFeedback;
function navigateBack() {
haptic.impactOccurred('light');
router.back();
}
watch(
() => route.name,
() => {
if (route.name === 'home') {
window.Telegram.WebApp.BackButton.hide();
window.Telegram.WebApp.BackButton.offClick();
backButton.hide();
backButton.offClick(navigateBack);
} else {
window.Telegram.WebApp.BackButton.show();
window.Telegram.WebApp.BackButton.onClick(() => {
window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
router.back();
});
backButton.show();
backButton.onClick(navigateBack);
}
},
{immediate: true}
);
</script>
<style scoped>
/* очень мягкий fade+slide */
.route-enter-from,
.route-leave-to {
opacity: 0;
transform: translateY(6px);
}
.route-enter-active,
.route-leave-active {
transition: opacity .18s ease, transform .18s ease;
}
/* уважение к reduced motion */
@media (prefers-reduced-motion: reduce) {
.route-enter-active,
.route-leave-active {
transition: opacity .12s linear;
}
.route-enter-from,
.route-leave-to {
transform: none;
}
}
</style>

View File

@@ -0,0 +1,5 @@
<template>
<div class="fixed px-4 pb-10 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col gap-2 border-t-1 border-t-base-300">
<slot></slot>
</div>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<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="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z"/>
</svg>
</template>

View File

@@ -0,0 +1,21 @@
<template>
<div class="border-b mb-5 border-b-base-200 flex justify-between">
<div class="mb-2 text-base-content/40">Товары для главной страницы</div>
<div>
<input
type="checkbox"
class="toggle"
disabled
:checked="filter.criteria.product_for_main_page.params.value"
/>
</div>
</div>
</template>
<script setup>
const props = defineProps({
filter: {
required: true,
},
});
</script>

View File

@@ -0,0 +1,43 @@
<template>
<div class="border-b mb-5 border-b-base-200">
<div class="mb-2">Цена</div>
<div class="flex justify-between">
<label class="input mr-3">
От
<input
type="number"
class="grow"
min="0"
step="50"
:placeholder="filter.criteria.product_price.params.value.from || '0'"
v-model="filter.criteria.product_price.params.value.from"
/>
</label>
<label class="input">
До
<input
type="number"
class="grow"
min="0"
step="50"
:placeholder="filter.criteria.product_price.params.value.to || '∞'"
:value="filter.criteria.product_price.params.value.to"
v-model="filter.criteria.product_price.params.value.to"
/>
</label>
</div>
</div>
</template>
<script setup>
const props = defineProps({
filter: {
required: true,
},
});
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,105 @@
<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>

View File

@@ -0,0 +1,31 @@
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
}
}
}
},
},
};

View File

@@ -31,7 +31,7 @@
</div>
<div
v-if="productsStore.hasMore === false"
v-if="productsStore.products.meta.hasMore === false"
class="text-xs text-center mt-4 pt-4 mb-2 border-t"
>
{{ settings.noMoreProductsMessage }}
@@ -56,89 +56,24 @@ import NoProducts from "@/components/NoProducts.vue";
import ProductImageSwiper from "@/components/ProductImageSwiper.vue";
import {useProductsStore} from "@/stores/ProductsStore.js";
import {useInfiniteScroll} from '@vueuse/core';
import {useRoute} from "vue-router";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {nextTick, onMounted, onUnmounted, ref, watch} from "vue";
import {ref} from "vue";
const route = useRoute();
const categoryId = route.params.category_id ?? null;
const productsStore = useProductsStore();
const settings = useSettingsStore();
const bottom = ref(null);
function haptic() {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
productsStore.savedScrollY = window.scrollY;
console.log("Store scrollY: ", productsStore.savedScrollY);
}
async function loadMore() {
if (productsStore.isLoading || productsStore.hasMore === false) return;
console.debug("Loading more...");
console.debug("Page: ", productsStore.page);
productsStore.isLoading = true;
try {
const response = await productsStore.fetchProducts(categoryId, productsStore.page, true);
productsStore.hasMore = response.meta.hasMore ?? false;
productsStore.products.data.push(...response.data);
productsStore.products.meta.currentCategoryName = response.meta.currentCategoryName;
productsStore.page++;
console.log("Loaded products: ", productsStore.products.data.length);
console.log("Has More? ", productsStore.hasMore);
} catch (e) {
console.error('Ошибка загрузки', e)
} finally {
productsStore.isLoading = false;
productsStore.loadFinished = true;
}
// productsStore.savedScrollY = window.scrollY;
// console.log("Store scrollY: ", productsStore.savedScrollY);
}
useInfiniteScroll(
bottom,
loadMore,
async () => await productsStore.loadMore(),
{distance: 1000}
)
watch(() => route.params.id, async newId => {
if (newId !== productsStore.savedCategoryId) {
productsStore.reset()
productsStore.savedCategoryId = newId
await loadMore()
}
});
function handleClickOutside(e) {
if (!e.target.closest('input, textarea')) {
document.activeElement?.blur()
}
}
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
onMounted(async () => {
document.addEventListener('click', handleClickOutside);
const saved = productsStore.savedCategoryId === categoryId;
if (saved && productsStore.products.data.length > 0) {
await nextTick();
// повторяем до тех пор, пока высота не станет больше savedScrollY
const interval = setInterval(() => {
const maxScroll = document.documentElement.scrollHeight - window.innerHeight
if (maxScroll >= productsStore.savedScrollY) {
window.scrollTo(0, productsStore.savedScrollY)
clearInterval(interval);
}
}, 50);
} else {
productsStore.reset();
productsStore.savedCategoryId = categoryId;
await loadMore();
}
})
</script>
<style scoped>

View File

@@ -9,9 +9,17 @@ import OrderCreated from "@/views/OrderCreated.vue";
import Search from "@/views/Search.vue";
const routes = [
{path: '/', name: 'home', component: Home},
{
path: '/',
name: 'home',
component: Home,
},
{path: '/product/:id', name: 'product.show', component: Product},
{path: '/products/:category_id', name: 'product.categories.show', component: Products},
{
path: '/products/:category_id',
name: 'product.categories.show',
component: Products,
},
{path: '/categories', name: 'categories', component: CategoriesList},
{path: '/category/:id', name: 'category.show', component: CategoriesList},
{path: '/cart', name: 'cart', component: Cart},

View File

@@ -0,0 +1,23 @@
import {defineStore} from "pinia";
export const useProductFiltersStore = defineStore('product_filters', {
state: () => ({
filters: {},
fullPath: '',
}),
getters: {
hasFilters: (state) => state.filters?.rules && Object.keys(state.filters.rules).length > 0,
},
actions: {
reset() {
},
clear() {
this.filters = {};
}
},
});

View File

@@ -1,47 +1,118 @@
import {defineStore} from "pinia";
import ftch from "../utils/ftch.js";
import ftch from "@/utils/ftch.js";
import {md5} from 'js-md5';
import {toRaw} from "vue";
export const useProductsStore = defineStore('products', {
state: () => ({
products: {
data: [],
meta: {},
meta: {
hasMore: true,
},
},
filters: null,
filtersFullUrl: '',
search: '',
page: 1,
isLoading: false,
loadFinished: false,
hasMore: true,
savedCategoryId: null,
savedScrollY: 0,
currentLoadedParamsHash: null,
}),
getters: {
paramsHash: (state) => md5(JSON.stringify(toRaw(state.getParams()))),
},
actions: {
async fetchProducts(categoryId = null, page = 1, forMainPage = false) {
getParams() {
return {
page: this.page,
search: this.search,
filters: toRaw(this.filters),
};
},
async fetchProducts() {
try {
this.isLoading = true;
return await ftch('products', {
categoryId: categoryId,
page: page,
search: this.search,
forMainPage: forMainPage,
});
console.debug('Current params hash: ', this.currentLoadedParamsHash);
if (this.products.data.length > 0 && this.paramsHash === this.currentLoadedParamsHash) {
console.debug('Loading products from cache');
return new Promise((resolve, reject) => {
resolve(this.products);
});
}
console.debug('Requested param cache: ', this.paramsHash);
console.debug('Invalidate cache. Fetch products from server.', this.getParams());
const response = await ftch('products', null, this.getParams());
this.currentLoadedParamsHash = this.paramsHash;
console.debug('Products loaded from server.');
console.debug('New params hash: ', this.currentLoadedParamsHash);
return {
meta: response.meta,
data: response.data,
};
} catch (error) {
console.error("Failed to load products");
console.error(error);
} finally {
this.isLoading = false;
}
},
async loadProducts(filters = null) {
if (this.isLoading) return;
try {
console.debug('Load products with filters', filters);
console.debug('Filters for URL: ', this.filtersFullUrl);
this.isLoading = false;
this.page = 1;
this.loadFinished = false;
this.search = '';
this.filters = filters;
this.products = await this.fetchProducts();
} catch (e) {
console.error('Ошибка загрузки', e);
} finally {
this.isLoading = false;
this.loadFinished = true;
}
},
async loadMore() {
if (this.isLoading || this.products.meta.hasMore === false) return;
try {
this.isLoading = true;
this.page++;
console.debug('Load more products for page: ', this.page);
const response = await this.fetchProducts();
this.products.meta = response.meta;
this.products.data.push(...response.data);
} catch (e) {
console.error('Ошибка загрузки', e);
} finally {
this.isLoading = false;
this.loadFinished = true;
}
},
reset() {
this.isLoading = false;
this.page = 1;
this.hasMore = true;
this.loadFinished = false;
this.search = '';
this.products = {
data: [],
meta: {},
meta: {
hasMore: true,
},
};
},
},

3
spa/src/translations.js Normal file
View File

@@ -0,0 +1,3 @@
export default {
RULE_PRODUCT_PRICE: 'Цена',
};

View File

@@ -1,8 +1,22 @@
<template>
<div ref="goodsRef" class="safe-top">
<CategoriesInline/>
<SearchInput/>
<div class="flex justify-between px-5">
<button @click="showFilters" class="btn">
<IconFunnel/>
</button>
<SearchInput/>
</div>
<ProductsList/>
<Filters
v-if="isFiltersShow"
:filters="productsStore.filters"
@apply="applyFilters"
@reset="resetFilters"
@close="closeFilters"
/>
</div>
</template>
@@ -10,4 +24,58 @@
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 IconFunnel from "@/components/Icons/IconFunnel.vue";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
import {FILTERS_MAIN_PAGE_DEFAULT} from "@/components/ProductFilters/filters.js";
import {useRoute} from "vue-router";
const route = useRoute();
const productsStore = useProductsStore();
const isFiltersShow = ref(false);
const backButton = window.Telegram.WebApp.BackButton;
function showFilters() {
isFiltersShow.value = true;
backButton.show();
}
function closeFilters() {
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()
}
}
onUnmounted(() => document.removeEventListener('click', handleClickOutside));
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);
}
});
</script>

View File

@@ -8,4 +8,58 @@
<script setup>
import ProductsList from "@/components/ProductsList.vue";
import SearchInput from "@/components/SearchInput.vue";
import {onMounted} from "vue";
import {useRoute} from "vue-router";
import {useProductsStore} from "@/stores/ProductsStore.js";
const route = useRoute();
const productsStore = useProductsStore();
const categoryId = route.params.category_id ?? null;
onMounted(async () => {
console.debug("Load products for category: ", categoryId);
if (productsStore.filtersFullUrl === route.fullPath) {
await productsStore.loadProducts(productsStore.filters ?? {
operand: "AND",
rules: {
RULE_PRODUCT_CATEGORIES: {
criteria: {
product_category_ids: {
type: "product_categories",
params: {
operator: "contains",
value: [
categoryId
]
}
}
}
}
},
});
} else {
productsStore.reset();
productsStore.filtersFullUrl = route.fullPath;
await productsStore.loadProducts({
operand: "AND",
rules: {
RULE_PRODUCT_CATEGORIES: {
criteria: {
product_category_ids: {
type: "product_categories",
params: {
operator: "contains",
value: [
categoryId
]
}
}
}
}
},
});
}
});
</script>