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

45
spa/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@vueuse/core": "^13.5.0",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"ofetch": "^1.4.1", "ofetch": "^1.4.1",
"pinia": "^3.0.3", "pinia": "^3.0.3",
@@ -1093,6 +1094,12 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT" "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": { "node_modules/@vitejs/plugin-vue": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.0.tgz", "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==", "integrity": "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg==",
"license": "MIT" "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": { "node_modules/autoprefixer": {
"version": "10.4.21", "version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",

View File

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

View File

@@ -1,7 +1,8 @@
<template> <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 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 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="aspect-square bg-gray-200 rounded-md"></div>
<div class="h-4 bg-gray-200 rounded w-3/4"></div> <div class="h-4 bg-gray-200 rounded w-3/4"></div>
@@ -10,11 +11,14 @@
</div> </div>
<template v-else> <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 <RouterLink
v-for="product in products" v-for="product in productsStore.products.data"
:key="product.id" :key="product.id"
class="group" class="group"
:to="`/product/${product.id}`" :to="`/product/${product.id}`"
@@ -26,49 +30,98 @@
</RouterLink> </RouterLink>
</div> </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/> <NoProducts v-else/>
</template> </template>
</div> </div>
</template> </template>
<script setup> <script setup>
import {ref} from "vue"; import NoProducts from "@/components/NoProducts.vue";
import {useHapticFeedback} from 'vue-tg'; import ProductImageSwiper from "@/components/ProductImageSwiper.vue";
import NoProducts from "../components/NoProducts.vue"; import {useProductsStore} from "@/stores/ProductsStore.js";
import ProductImageSwiper from "../components/ProductImageSwiper.vue"; 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 route = useRoute();
const categoryId = route.params.id ?? null;
const props = defineProps({ const productsStore = useProductsStore();
products: { const settings = useSettingsStore();
type: Array,
default: () => [],
},
meta: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
}
});
function haptic() { function haptic() {
window.Telegram.WebApp.HapticFeedback.selectionChanged(); window.Telegram.WebApp.HapticFeedback.selectionChanged();
productsStore.savedScrollY = window.scrollY;
console.log("Store scrollY: ", productsStore.savedScrollY);
} }
const carouselRef = ref(); async function loadMore() {
let lastScrollLeft = 0; if (productsStore.isLoading || productsStore.hasMore === false) return;
function onScroll(e) {
const scrollLeft = e.target.scrollLeft;
const delta = Math.abs(scrollLeft - lastScrollLeft);
if (delta > 30) { console.debug("Loading more...");
hapticFeedback.impactOccurred('soft'); console.debug("Page: ", productsStore.page);
lastScrollLeft = scrollLeft;
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> </script>

View File

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

View File

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

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> <template>
<div ref="goodsRef"> <div ref="goodsRef">
<CategoriesInline/> <CategoriesInline/>
<ProductsList <ProductsList/>
:products="productsStore.homeProducts.data"
:meta="productsStore.homeProducts.meta"
:isLoading="productsStore.isLoading"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import {ref} from "vue";
import ProductsList from "@/components/ProductsList.vue"; import ProductsList from "@/components/ProductsList.vue";
import CategoriesInline from "../components/CategoriesInline.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> </script>

View File

@@ -1,22 +1,9 @@
<template> <template>
<div ref="goodsRef"> <div ref="goodsRef">
<ProductsList <ProductsList/>
:products="productsStore.products.data"
:meta="productsStore.products.meta"
:isLoading="productsStore.isLoading"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import {useProductsStore} from "@/stores/ProductsStore.js";
import ProductsList from "@/components/ProductsList.vue"; 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> </script>