feat: infinity scroll, load more, resore scroll

This commit is contained in:
Nikita Kiselev
2025-07-23 13:26:22 +03:00
parent 6a14ad0a74
commit bb2ee38118
9 changed files with 188 additions and 106 deletions

View File

@@ -13,6 +13,7 @@ use Openguru\OpenCartFramework\ImageTool\ImageToolInterface;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Support\PaginationHelper;
class ProductsHandler
{
@@ -43,7 +44,7 @@ class ProductsHandler
{
$languageId = 1;
$page = $request->get('page', 1);
$perPage = $request->get('perPage', 10);
$perPage = 10;
$categoryId = (int) $request->get('categoryId', 0);
$categoryName = '';
@@ -59,7 +60,7 @@ class ProductsHandler
->value('name');
}
$products = $this->queryBuilder->newQuery()
$productsQuery = $this->queryBuilder->newQuery()
->select([
'products.product_id' => 'product_id',
'products.quantity' => 'product_quantity',
@@ -84,7 +85,13 @@ class ProductsHandler
->where('product_to_category.category_id', '=', $categoryId);
}
);
})
});
$total = $productsQuery->count();
$lastPage = PaginationHelper::calculateLastPage($total, $perPage);
$hasMore = $page + 1 <= $lastPage;
$products = $productsQuery
->forPage($page, $perPage)
->orderBy('date_added', 'DESC')
->get();
@@ -152,6 +159,7 @@ class ProductsHandler
'meta' => [
'currentCategoryName' => $categoryName,
'hasMore' => $hasMore,
]
]);
}

45
spa/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.11",
"@vueuse/core": "^13.5.0",
"crypto-js": "^4.2.0",
"ofetch": "^1.4.1",
"pinia": "^3.0.3",
@@ -1093,6 +1094,12 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.0.tgz",
@@ -1240,6 +1247,44 @@
"integrity": "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg==",
"license": "MIT"
},
"node_modules/@vueuse/core": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.5.0.tgz",
"integrity": "sha512-wV7z0eUpifKmvmN78UBZX8T7lMW53Nrk6JP5+6hbzrB9+cJ3jr//hUlhl9TZO/03bUkMK6gGkQpqOPWoabr72g==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "13.5.0",
"@vueuse/shared": "13.5.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.5.0.tgz",
"integrity": "sha512-euhItU3b0SqXxSy8u1XHxUCdQ8M++bsRs+TYhOLDU/OykS7KvJnyIFfep0XM5WjIFry9uAPlVSjmVHiqeshmkw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.5.0.tgz",
"integrity": "sha512-K7GrQIxJ/ANtucxIXbQlUHdB0TPA8c+q5i+zbrjxuhJCnJ9GtBg75sBSnvmLSxHKPg2Yo8w62PWksl9kwH0Q8g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.11",
"@vueuse/core": "^13.5.0",
"crypto-js": "^4.2.0",
"ofetch": "^1.4.1",
"pinia": "^3.0.3",

View File

@@ -1,7 +1,8 @@
<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">
<div v-if="isLoading" 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-if="productsStore.isLoading"
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>
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
@@ -10,11 +11,14 @@
</div>
<template v-else>
<h2 class="text-lg font-bold mb-5 text-center">{{ meta.currentCategoryName }}</h2>
<h2 class="text-lg font-bold mb-5 text-center">{{ productsStore.products.meta.currentCategoryName }}</h2>
<div v-if="products.length > 0" 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-if="productsStore.products.data.length > 0">
<div
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"
>
<RouterLink
v-for="product in products"
v-for="product in productsStore.products.data"
:key="product.id"
class="group"
:to="`/product/${product.id}`"
@@ -26,49 +30,98 @@
</RouterLink>
</div>
<div
v-if="productsStore.hasMore === false"
class="text-gray-500 text-xs text-center mt-4 pt-4 mb-2 border-t"
>
{{ settings.noMoreProductsMessage }}
</div>
</div>
<NoProducts v-else/>
</template>
</div>
</template>
<script setup>
import {ref} from "vue";
import {useHapticFeedback} from 'vue-tg';
import NoProducts from "../components/NoProducts.vue";
import ProductImageSwiper from "../components/ProductImageSwiper.vue";
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, ref, watch} from "vue";
const hapticFeedback = useHapticFeedback();
const props = defineProps({
products: {
type: Array,
default: () => [],
},
meta: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
}
});
const route = useRoute();
const categoryId = route.params.id ?? null;
const productsStore = useProductsStore();
const settings = useSettingsStore();
function haptic() {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
productsStore.savedScrollY = window.scrollY;
console.log("Store scrollY: ", productsStore.savedScrollY);
}
const carouselRef = ref();
let lastScrollLeft = 0;
function onScroll(e) {
const scrollLeft = e.target.scrollLeft;
const delta = Math.abs(scrollLeft - lastScrollLeft);
async function loadMore() {
if (productsStore.isLoading || productsStore.hasMore === false) return;
if (delta > 30) {
hapticFeedback.impactOccurred('soft');
lastScrollLeft = scrollLeft;
console.debug("Loading more...");
console.debug("Page: ", productsStore.page);
productsStore.isLoading = true;
try {
const response = await productsStore.fetchProducts(categoryId, productsStore.page);
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;
}
}
useInfiniteScroll(
window,
loadMore,
{distance: 300}
)
watch(() => route.params.id, async newId => {
if (newId !== productsStore.savedCategoryId) {
productsStore.reset()
productsStore.savedCategoryId = newId
await loadMore()
}
});
onMounted(async () => {
console.log("Mounted");
const saved = productsStore.savedCategoryId === categoryId;
console.log("Saved Category: ", saved);
if (saved && productsStore.products.data.length > 0) {
await nextTick();
console.log("Products exists, scrolling to ", productsStore.savedScrollY);
// повторяем до тех пор, пока высота не станет больше 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>

View File

@@ -5,14 +5,6 @@ import { VueTelegramPlugin } from 'vue-tg';
import { router } from './router';
import { createPinia } from 'pinia';
const config = {
night_auto: true,
theme: {
light: 'light',
dark: 'dark',
}
};
const pinia = createPinia();
const app = createApp(App);
app
@@ -22,21 +14,19 @@ app
app.mount('#app');
const productsStore = useProductsStore();
productsStore.fetchHomeProducts();
const settings = useSettingsStore();
const categoriesStore = useCategoriesStore();
categoriesStore.fetchTopCategories();
import {useProductsStore} from "@/stores/ProductsStore.js";
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
if (config.night_auto) {
document.documentElement.setAttribute('data-theme', config.theme[this.colorScheme]);
if (settings.night_auto) {
window.Telegram.WebApp.onEvent('themeChanged', function () {
document.documentElement.setAttribute('data-theme', config.theme[this.colorScheme]);
document.documentElement.setAttribute('data-theme', settings.theme[this.colorScheme]);
});
} else {
document.documentElement.setAttribute('data-theme', config.theme.light);
document.documentElement.setAttribute('data-theme', settings.theme.light);
}
window.Telegram.WebApp.ready();

View File

@@ -3,22 +3,25 @@ import ftch from "../utils/ftch.js";
export const useProductsStore = defineStore('products', {
state: () => ({
homeProducts: {
data: [],
meta: {},
},
products: {
data: [],
meta: {},
},
page: 1,
isLoading: false,
hasMore: true,
savedCategoryId: null,
savedScrollY: 0,
}),
actions: {
async fetchHomeProducts() {
async fetchProducts(categoryId = null, page = 1) {
try {
this.isLoading = true;
this.homeProducts = await ftch('products');
return await ftch('products', {
categoryId: categoryId,
page: page,
});
} catch (error) {
console.error(error);
} finally {
@@ -26,17 +29,13 @@ export const useProductsStore = defineStore('products', {
}
},
async fetchProducts(categoryId = null) {
try {
this.isLoading = true;
this.products = await ftch('products', {
categoryId: categoryId,
});
} catch (error) {
console.error(error);
} finally {
this.isLoading = false;
reset() {
this.page = 1;
this.hasMore = true;
this.products = {
data: [],
meta: {},
};
}
},
},
});

View File

@@ -0,0 +1,12 @@
import {defineStore} from "pinia";
export const useSettingsStore = defineStore('settings', {
state: () => ({
night_auto: true,
theme: {
light: 'light',
dark: 'dark',
},
noMoreProductsMessage: '🔚 Ну всё, разгрузили всё, что было. Даже кладовщика разбудить не удалось.',
}),
});

View File

@@ -1,24 +1,11 @@
<template>
<div ref="goodsRef">
<CategoriesInline/>
<ProductsList
:products="productsStore.homeProducts.data"
:meta="productsStore.homeProducts.meta"
:isLoading="productsStore.isLoading"
/>
<ProductsList/>
</div>
</template>
<script setup>
import {ref} from "vue";
import ProductsList from "@/components/ProductsList.vue";
import CategoriesInline from "../components/CategoriesInline.vue";
import {useProductsStore} from "@/stores/ProductsStore.js";
const productsStore = useProductsStore();
const goodsRef = ref();
function scrollToProducts() {
goodsRef.value?.scrollIntoView({ behavior: 'smooth' });
}
</script>

View File

@@ -1,22 +1,9 @@
<template>
<div ref="goodsRef">
<ProductsList
:products="productsStore.products.data"
:meta="productsStore.products.meta"
:isLoading="productsStore.isLoading"
/>
<ProductsList/>
</div>
</template>
<script setup>
import {useProductsStore} from "@/stores/ProductsStore.js";
import ProductsList from "@/components/ProductsList.vue";
import {onMounted} from "vue";
import {useRoute} from "vue-router";
const route = useRoute();
const categoryId = route.params.id ?? null;
const productsStore = useProductsStore();
onMounted(() => productsStore.fetchProducts(categoryId))
</script>