feat: infinity scroll, load more, resore scroll
This commit is contained in:
@@ -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
45
spa/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
12
spa/src/stores/SettingsStore.js
Normal file
12
spa/src/stores/SettingsStore.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import {defineStore} from "pinia";
|
||||
|
||||
export const useSettingsStore = defineStore('settings', {
|
||||
state: () => ({
|
||||
night_auto: true,
|
||||
theme: {
|
||||
light: 'light',
|
||||
dark: 'dark',
|
||||
},
|
||||
noMoreProductsMessage: '🔚 Ну всё, разгрузили всё, что было. Даже кладовщика разбудить не удалось.',
|
||||
}),
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user