feat: images and products loading optimization
This commit is contained in:
18
frontend/spa/src/components/Loader.vue
Normal file
18
frontend/spa/src/components/Loader.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<div class="loader"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loader {
|
||||||
|
width: 40px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f03355;
|
||||||
|
clip-path: polygon(0 0,100% 0,100% 100%,0 100%);
|
||||||
|
animation: l1 2s infinite cubic-bezier(0.3,1,0,1);
|
||||||
|
}
|
||||||
|
@keyframes l1 {
|
||||||
|
33% {border-radius: 0;background: #514b82 ;clip-path: polygon(0 0,100% 0,100% 100%,0 100%)}
|
||||||
|
66% {border-radius: 0;background: #ffa516 ;clip-path: polygon(50% 0,50% 0,100% 100%,0 100%)}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
111
frontend/spa/src/components/SingleProductImageSwiper.vue
Normal file
111
frontend/spa/src/components/SingleProductImageSwiper.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<div class="product-image-swiper">
|
||||||
|
<Swiper
|
||||||
|
:lazy="true"
|
||||||
|
:modules="modules"
|
||||||
|
:pagination="pagination"
|
||||||
|
@sliderMove="hapticScroll"
|
||||||
|
>
|
||||||
|
<SwiperSlide
|
||||||
|
v-for="(image, index) in images"
|
||||||
|
:key="image.url"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="image.thumbnailURL"
|
||||||
|
:alt="image.alt"
|
||||||
|
loading="lazy"
|
||||||
|
@click="showFullScreen(index)"
|
||||||
|
/>
|
||||||
|
</SwiperSlide>
|
||||||
|
</Swiper>
|
||||||
|
|
||||||
|
<FullScreenImageViewer
|
||||||
|
v-if="isFullScreen"
|
||||||
|
:images="images"
|
||||||
|
:activeIndex="initialFullScreenIndex"
|
||||||
|
@close="closeFullScreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {Swiper, SwiperSlide} from 'swiper/vue';
|
||||||
|
import FullScreenImageViewer from "@/components/FullScreenImageViewer.vue";
|
||||||
|
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||||
|
import {onMounted, onUnmounted, ref} from "vue";
|
||||||
|
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||||
|
|
||||||
|
const emit = defineEmits(['onLoad']);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
images: {
|
||||||
|
type: Array,
|
||||||
|
default: [],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const haptic = useHapticFeedback();
|
||||||
|
const hapticScroll = useHapticScroll();
|
||||||
|
const pagination = {
|
||||||
|
clickable: true,
|
||||||
|
};
|
||||||
|
const modules = [];
|
||||||
|
const isFullScreen = ref(false);
|
||||||
|
const initialFullScreenIndex = ref(0);
|
||||||
|
|
||||||
|
function showFullScreen(index) {
|
||||||
|
haptic.selectionChanged();
|
||||||
|
isFullScreen.value = true;
|
||||||
|
initialFullScreenIndex.value = index;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
history.pushState({fullscreen: true}, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFullScreen() {
|
||||||
|
isFullScreen.value = false;
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPopState() {
|
||||||
|
if (isFullScreen.value) {
|
||||||
|
closeFullScreen();
|
||||||
|
} else {
|
||||||
|
// пусть Vue Router сам обработает
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('popstate', onPopState);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('popstate', onPopState);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.swiper-slide {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swiper {
|
||||||
|
height: 500px;
|
||||||
|
border-radius: var(--radius-box, 0.5rem);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swiper-slide img {
|
||||||
|
width: 100%;
|
||||||
|
height: 500px;
|
||||||
|
object-fit: contain;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swiper-slide img:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,7 +15,7 @@ export const apiFetch = ofetch.create({
|
|||||||
if (data) {
|
if (data) {
|
||||||
const encoded = encodeBase64Unicode(data);
|
const encoded = encodeBase64Unicode(data);
|
||||||
options.headers = {
|
options.headers = {
|
||||||
...(isDev && { 'XDEBUG_TRIGGER': 'PHPSTORM' }),
|
...(isDev && {'XDEBUG_TRIGGER': 'PHPSTORM'}),
|
||||||
...options.headers,
|
...options.headers,
|
||||||
'X-Telegram-InitData': encoded,
|
'X-Telegram-InitData': encoded,
|
||||||
};
|
};
|
||||||
@@ -131,4 +131,16 @@ export async function heartbeat() {
|
|||||||
return await ftch('heartbeat');
|
return await ftch('heartbeat');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchProductById(productId) {
|
||||||
|
return await ftch('product_show', {
|
||||||
|
id: productId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProductImages(productId) {
|
||||||
|
return await ftch('getProductImages', {
|
||||||
|
id: productId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default ftch;
|
export default ftch;
|
||||||
|
|||||||
@@ -1,47 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<div class="relative">
|
<div v-if="!imagesLoaded" class="w-full aspect-square bg-base-200 flex items-center justify-center">
|
||||||
<div v-if="!firstImageLoaded" class="image-preloader">
|
<Loader/>
|
||||||
<div class="preloader-spinner"></div>
|
|
||||||
</div>
|
|
||||||
<Swiper
|
|
||||||
v-if="product.images && product.images.length > 0"
|
|
||||||
:lazy="true"
|
|
||||||
:modules="modules"
|
|
||||||
:pagination="pagination"
|
|
||||||
@sliderMove="hapticScroll"
|
|
||||||
:class="{ 'opacity-0': !firstImageLoaded }"
|
|
||||||
>
|
|
||||||
<SwiperSlide
|
|
||||||
v-for="(image, index) in product.images"
|
|
||||||
:key="image.url"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="image.thumbnailURL"
|
|
||||||
:alt="image.alt"
|
|
||||||
loading="lazy"
|
|
||||||
@load="onFirstImageLoad"
|
|
||||||
@error="onFirstImageLoad"
|
|
||||||
@click="showFullScreen(index)"
|
|
||||||
/>
|
|
||||||
</SwiperSlide>
|
|
||||||
</Swiper>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FullScreenImageViewer
|
<div v-else-if="imagesLoaded && images.length === 0">
|
||||||
v-if="isFullScreen"
|
<div class="bg-base-200 aspect-square flex items-center justify-center flex-col text-base-300">
|
||||||
:images="product.images"
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-18">
|
||||||
:activeIndex="initialFullScreenIndex"
|
<path fill-rule="evenodd" d="M1.5 6a2.25 2.25 0 0 1 2.25-2.25h16.5A2.25 2.25 0 0 1 22.5 6v12a2.25 2.25 0 0 1-2.25 2.25H3.75A2.25 2.25 0 0 1 1.5 18V6ZM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0 0 21 18v-1.94l-2.69-2.689a1.5 1.5 0 0 0-2.12 0l-.88.879.97.97a.75.75 0 1 1-1.06 1.06l-5.16-5.159a1.5 1.5 0 0 0-2.12 0L3 16.061Zm10.125-7.81a1.125 1.125 0 1 1 2.25 0 1.125 1.125 0 0 1-2.25 0Z" clip-rule="evenodd" />
|
||||||
@close="closeFullScreen"
|
</svg>
|
||||||
/>
|
<span class="text-xl">Нет изображений</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SingleProductImageSwiper v-else :images="images"/>
|
||||||
|
|
||||||
<!-- Product info -->
|
|
||||||
<div class="mx-auto max-w-2xl px-4 pt-6 pb-32 sm:px-6">
|
<div class="mx-auto max-w-2xl px-4 pt-6 pb-32 sm:px-6">
|
||||||
<!-- Header section -->
|
<!-- Header section -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div v-if="product.manufacturer" class="mb-2">
|
<div v-if="product.manufacturer" class="mb-2">
|
||||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">{{ product.manufacturer }}</span>
|
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">{{
|
||||||
|
product.manufacturer
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold leading-tight mb-4">{{ product.name }}</h1>
|
<h1 class="text-2xl sm:text-3xl font-bold leading-tight mb-4">{{ product.name }}</h1>
|
||||||
|
|
||||||
@@ -79,7 +60,9 @@
|
|||||||
<div v-if="product.discounts && product.discounts.length > 0" class="mt-2 pt-2 border-t border-base-300">
|
<div v-if="product.discounts && product.discounts.length > 0" class="mt-2 pt-2 border-t border-base-300">
|
||||||
<p class="text-xs font-semibold text-base-content/60 mb-1">Скидки при покупке:</p>
|
<p class="text-xs font-semibold text-base-content/60 mb-1">Скидки при покупке:</p>
|
||||||
<p v-for="discount in product.discounts" :key="discount.quantity" class="text-sm text-base-content/70">
|
<p v-for="discount in product.discounts" :key="discount.quantity" class="text-sm text-base-content/70">
|
||||||
{{ discount.quantity }} шт. или больше — <span class="font-semibold text-primary">{{ discount.price }}</span>
|
{{ discount.quantity }} шт. или больше — <span class="font-semibold text-primary">{{
|
||||||
|
discount.price
|
||||||
|
}}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,8 +78,10 @@
|
|||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div v-if="product.description" class="card bg-base-100 rounded-2xl p-5 shadow-sm">
|
<div v-if="product.description" class="card bg-base-100 rounded-2xl p-5 shadow-sm">
|
||||||
<h3 class="text-lg font-bold mb-4 flex items-center gap-2">
|
<h3 class="text-lg font-bold mb-4 flex items-center gap-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Описание
|
Описание
|
||||||
</h3>
|
</h3>
|
||||||
@@ -104,10 +89,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Attributes -->
|
<!-- Attributes -->
|
||||||
<div v-if="product.attribute_groups && product.attribute_groups.length > 0" class="card bg-base-100 rounded-2xl p-5 shadow-sm">
|
<div v-if="product.attribute_groups && product.attribute_groups.length > 0"
|
||||||
|
class="card bg-base-100 rounded-2xl p-5 shadow-sm">
|
||||||
<h3 class="text-lg font-bold mb-4 flex items-center gap-2">
|
<h3 class="text-lg font-bold mb-4 flex items-center gap-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.593m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h11.25c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" />
|
stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.593m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h11.25c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Характеристики
|
Характеристики
|
||||||
</h3>
|
</h3>
|
||||||
@@ -116,7 +104,8 @@
|
|||||||
<div class="border-b border-base-300 pb-3 last:border-0 last:pb-0">
|
<div class="border-b border-base-300 pb-3 last:border-0 last:pb-0">
|
||||||
<h4 class="text-sm font-semibold text-base-content/80 mb-2">{{ attrGroup.name }}</h4>
|
<h4 class="text-sm font-semibold text-base-content/80 mb-2">{{ attrGroup.name }}</h4>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div v-for="attr in attrGroup.attribute" :key="attr.attribute_id" class="flex justify-between items-start gap-4 py-1">
|
<div v-for="attr in attrGroup.attribute" :key="attr.attribute_id"
|
||||||
|
class="flex justify-between items-start gap-4 py-1">
|
||||||
<span class="text-sm text-base-content/60 flex-shrink-0">{{ attr.name }}</span>
|
<span class="text-sm text-base-content/60 flex-shrink-0">{{ attr.name }}</span>
|
||||||
<span class="text-sm font-medium text-right flex-1">{{ attr.text }}</span>
|
<span class="text-sm font-medium text-right flex-1">{{ attr.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,15 +123,19 @@
|
|||||||
style="padding-bottom: calc(0.5rem + env(safe-area-inset-bottom));">
|
style="padding-bottom: calc(0.5rem + env(safe-area-inset-bottom));">
|
||||||
<template v-if="settings.store_enabled">
|
<template v-if="settings.store_enabled">
|
||||||
<div v-if="error" class="alert alert-error alert-sm py-2">
|
<div v-if="error" class="alert alert-error alert-sm py-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-xs">{{ error }}</span>
|
<span class="text-xs">{{ error }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="canAddToCart === false" class="alert alert-warning alert-sm py-2">
|
<div v-if="canAddToCart === false" class="alert alert-warning alert-sm py-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-xs">Выберите обязательные опции</span>
|
<span class="text-xs">Выберите обязательные опции</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,11 +156,15 @@
|
|||||||
>
|
>
|
||||||
<span v-if="cart.isLoading" class="loading loading-spinner loading-sm"></span>
|
<span v-if="cart.isLoading" class="loading loading-spinner loading-sm"></span>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<svg v-if="!isInCart" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
<svg v-if="!isInCart" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
|
stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
|
stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
{{ btnText }}
|
{{ btnText }}
|
||||||
@@ -197,32 +194,25 @@
|
|||||||
|
|
||||||
<ProductNotFound v-else/>
|
<ProductNotFound v-else/>
|
||||||
|
|
||||||
<FullScreenImageViewer
|
|
||||||
v-if="isFullScreen"
|
|
||||||
:images="product.images"
|
|
||||||
:activeIndex="initialFullScreenIndex"
|
|
||||||
@close="closeFullScreen"
|
|
||||||
/>
|
|
||||||
<LoadingFullScreen v-if="isLoading"/>
|
<LoadingFullScreen v-if="isLoading"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {computed, nextTick, onMounted, onUnmounted, ref} from "vue";
|
import {computed, onMounted, ref} from "vue";
|
||||||
import {useRoute, useRouter} from 'vue-router';
|
import {useRoute, useRouter} from 'vue-router';
|
||||||
import ProductOptions from "../components/ProductOptions/ProductOptions.vue";
|
import ProductOptions from "../components/ProductOptions/ProductOptions.vue";
|
||||||
import {useCartStore} from "../stores/CartStore.js";
|
import {useCartStore} from "../stores/CartStore.js";
|
||||||
import Quantity from "../components/Quantity.vue";
|
import Quantity from "../components/Quantity.vue";
|
||||||
import {SUPPORTED_OPTION_TYPES} from "@/constants/options.js";
|
import {SUPPORTED_OPTION_TYPES} from "@/constants/options.js";
|
||||||
import {apiFetch} from "@/utils/ftch.js";
|
import {fetchProductById, fetchProductImages} from "@/utils/ftch.js";
|
||||||
import FullScreenImageViewer from "@/components/FullScreenImageViewer.vue";
|
|
||||||
import LoadingFullScreen from "@/components/LoadingFullScreen.vue";
|
import LoadingFullScreen from "@/components/LoadingFullScreen.vue";
|
||||||
import ProductNotFound from "@/components/ProductNotFound.vue";
|
import ProductNotFound from "@/components/ProductNotFound.vue";
|
||||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||||
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||||
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
import Loader from "@/components/Loader.vue";
|
||||||
import {Swiper, SwiperSlide} from 'swiper/vue';
|
import SingleProductImageSwiper from "@/components/SingleProductImageSwiper.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const productId = computed(() => route.params.id);
|
const productId = computed(() => route.params.id);
|
||||||
@@ -233,12 +223,12 @@ const error = ref('');
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isInCart = ref(false);
|
const isInCart = ref(false);
|
||||||
const btnText = computed(() => isInCart.value ? 'В корзине' : 'Купить');
|
const btnText = computed(() => isInCart.value ? 'В корзине' : 'Купить');
|
||||||
const isFullScreen = ref(false);
|
|
||||||
const initialFullScreenIndex = ref(0);
|
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const yaMetrika = useYaMetrikaStore();
|
const yaMetrika = useYaMetrikaStore();
|
||||||
const firstImageLoaded = ref(false);
|
const imagesLoaded = ref(false);
|
||||||
|
const images = ref([]);
|
||||||
|
|
||||||
const canAddToCart = computed(() => {
|
const canAddToCart = computed(() => {
|
||||||
if (!product.value || product.value.options === undefined || product.value.options?.length === 0) {
|
if (!product.value || product.value.options === undefined || product.value.options?.length === 0) {
|
||||||
@@ -254,19 +244,6 @@ const canAddToCart = computed(() => {
|
|||||||
return required.length === 0;
|
return required.length === 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
function showFullScreen(index) {
|
|
||||||
window.Telegram.WebApp.HapticFeedback.selectionChanged();
|
|
||||||
isFullScreen.value = true;
|
|
||||||
initialFullScreenIndex.value = index;
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
history.pushState({fullscreen: true}, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeFullScreen() {
|
|
||||||
isFullScreen.value = false;
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onCartBtnClick() {
|
async function onCartBtnClick() {
|
||||||
try {
|
try {
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -327,84 +304,47 @@ function setQuantity(newQuantity) {
|
|||||||
window.Telegram.WebApp.HapticFeedback.selectionChanged();
|
window.Telegram.WebApp.HapticFeedback.selectionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFirstImageLoad() {
|
|
||||||
if (!firstImageLoaded.value) {
|
|
||||||
firstImageLoaded.value = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hapticScroll = useHapticScroll();
|
|
||||||
const pagination = {
|
|
||||||
clickable: true,
|
|
||||||
};
|
|
||||||
const modules = [];
|
|
||||||
|
|
||||||
function onPopState() {
|
|
||||||
if (isFullScreen.value) {
|
|
||||||
closeFullScreen();
|
|
||||||
} else {
|
|
||||||
// пусть Vue Router сам обработает
|
|
||||||
router.back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('popstate', onPopState);
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
imagesLoaded.value = false;
|
||||||
|
|
||||||
|
// Запускаем оба запроса параллельно
|
||||||
|
const productPromise = fetchProductById(productId.value);
|
||||||
|
const imagesPromise = fetchProductImages(productId.value);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {data} = await apiFetch(`/index.php?route=extension/tgshop/handle&api_action=product_show&id=${productId.value}`);
|
// Ждем только загрузку продукта для рендеринга страницы
|
||||||
product.value = data;
|
const response = await productPromise;
|
||||||
|
product.value = response.data;
|
||||||
|
window.document.title = response.data.name;
|
||||||
|
|
||||||
// Сброс состояния загрузки первого изображения
|
// Страница готова к рендерингу
|
||||||
firstImageLoaded.value = false;
|
isLoading.value = false;
|
||||||
|
|
||||||
// Если изображений нет, сразу показываем слайдер
|
|
||||||
if (!data.images || data.images.length === 0) {
|
|
||||||
firstImageLoaded.value = true;
|
|
||||||
} else {
|
|
||||||
// Проверяем загрузку после следующего тика, чтобы дать Vue время отрендерить изображения
|
|
||||||
await nextTick();
|
|
||||||
// Если первое изображение уже загружено из кэша, оно может загрузиться до того, как мы начнем отслеживать
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!firstImageLoaded.value) {
|
|
||||||
// Проверяем, загружено ли первое изображение через проверку complete
|
|
||||||
const firstImg = document.querySelector('.swiper-slide img');
|
|
||||||
if (firstImg && firstImg.complete) {
|
|
||||||
firstImageLoaded.value = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
window.document.title = data.name;
|
|
||||||
|
|
||||||
|
// Отправляем метрики
|
||||||
yaMetrika.pushHit(route.path, {
|
yaMetrika.pushHit(route.path, {
|
||||||
title: data.name,
|
title: response.data.name,
|
||||||
params: {
|
params: {
|
||||||
'Название товара': data.name,
|
'Название товара': response.data.name,
|
||||||
'ИД товара': data.product_id,
|
'ИД товара': response.data.product_id,
|
||||||
'Цена': data.price,
|
'Цена': response.data.price,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_PRODUCT, {
|
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_PRODUCT, {
|
||||||
price: data.final_price_numeric,
|
price: response.data.final_price_numeric,
|
||||||
currency: data.currency,
|
currency: response.data.currency,
|
||||||
});
|
});
|
||||||
|
|
||||||
yaMetrika.dataLayerPush({
|
yaMetrika.dataLayerPush({
|
||||||
"ecommerce": {
|
"ecommerce": {
|
||||||
"currencyCode": settings.currency_code,
|
"currencyCode": settings.currency_code,
|
||||||
"detail": {
|
"detail": {
|
||||||
"products": [
|
"products": [
|
||||||
{
|
{
|
||||||
"id": data.product_id,
|
"id": response.data.product_id,
|
||||||
"name": data.name,
|
"name": response.data.name,
|
||||||
"price": data.final_price_numeric,
|
"price": response.data.final_price_numeric,
|
||||||
"brand": data.manufacturer,
|
"brand": response.data.manufacturer,
|
||||||
"category": data.category?.name,
|
"category": response.data.category?.name,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -412,66 +352,19 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обрабатываем загрузку изображений в фоне (не блокируем рендеринг)
|
||||||
window.addEventListener('popstate', onPopState);
|
try {
|
||||||
|
const {data} = await imagesPromise;
|
||||||
|
images.value = data;
|
||||||
|
console.debug('[Product]: Images loaded: ', images.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Could not load images: ', error);
|
||||||
|
images.value = [];
|
||||||
|
} finally {
|
||||||
|
imagesLoaded.value = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.swiper-slide {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.swiper {
|
|
||||||
height: 500px;
|
|
||||||
border-radius: var(--radius-box, 0.5rem);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.swiper-slide img {
|
|
||||||
width: 100%;
|
|
||||||
height: 500px;
|
|
||||||
object-fit: contain;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.swiper-slide img:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preloader {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 500px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: var(--fallback-bc, oklch(var(--bc) / 0.1));
|
|
||||||
border-radius: var(--radius-box, 0.5rem);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preloader-spinner {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: 4px solid var(--fallback-bc, oklch(var(--bc) / 0.2));
|
|
||||||
border-top-color: var(--fallback-p, oklch(var(--p)));
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -66,4 +66,24 @@ class ProductsHandler
|
|||||||
'data' => $product,
|
'data' => $product,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getProductImages(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$productId = (int) $request->get('id');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$images = $this->productsService->getProductImages($productId);
|
||||||
|
} catch (EntityNotFoundException $exception) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'message' => 'Product with id ' . $productId . ' not found',
|
||||||
|
], Response::HTTP_NOT_FOUND);
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$this->logger->error('Could not load images for product ' . $productId, ['exception' => $exception]);
|
||||||
|
$images = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => $images,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,11 +149,20 @@ class ProductsService
|
|||||||
|
|
||||||
$productsImagesMap = [];
|
$productsImagesMap = [];
|
||||||
foreach ($productsImages as $item) {
|
foreach ($productsImages as $item) {
|
||||||
$productsImagesMap[$item['product_id']][] = [
|
$productId = $item['product_id'];
|
||||||
'url' => $this->image->make($item['image'])->contain($imageWidth, $imageHeight)->url(),
|
|
||||||
|
// Ограничиваем количество картинок для каждого товара до 3
|
||||||
|
if (!isset($productsImagesMap[$productId])) {
|
||||||
|
$productsImagesMap[$productId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($productsImagesMap[$productId]) < 2) {
|
||||||
|
$productsImagesMap[$productId][] = [
|
||||||
|
'url' => $this->image->make($item['image'])->cover($imageWidth, $imageHeight)->url(),
|
||||||
'alt' => 'Product Image',
|
'alt' => 'Product Image',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$debug = [];
|
$debug = [];
|
||||||
if (env('APP_DEBUG')) {
|
if (env('APP_DEBUG')) {
|
||||||
@@ -234,10 +243,6 @@ class ProductsService
|
|||||||
$this->oc->load->model('catalog/review');
|
$this->oc->load->model('catalog/review');
|
||||||
$this->oc->load->model('tool/image');
|
$this->oc->load->model('tool/image');
|
||||||
|
|
||||||
$imageThumbWidth = 500;
|
|
||||||
$imageThumbHeight = 500;
|
|
||||||
$imageFullWidth = 1000;
|
|
||||||
$imageFullHeight = 1000;
|
|
||||||
$configTax = $this->oc->config->get('config_tax');
|
$configTax = $this->oc->config->get('config_tax');
|
||||||
|
|
||||||
$product_info = $this->oc->model_catalog_product->getProduct($productId);
|
$product_info = $this->oc->model_catalog_product->getProduct($productId);
|
||||||
@@ -271,35 +276,7 @@ class ProductsService
|
|||||||
$data['stock'] = $this->oc->language->get('text_instock');
|
$data['stock'] = $this->oc->language->get('text_instock');
|
||||||
}
|
}
|
||||||
|
|
||||||
$allImages = [];
|
$data['images'] = [];
|
||||||
if ($product_info['image']) {
|
|
||||||
$allImages[] = $product_info['image'];
|
|
||||||
}
|
|
||||||
$results = $this->oc->model_catalog_product->getProductImages($productId);
|
|
||||||
foreach ($results as $result) {
|
|
||||||
$allImages[] = $result['image'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$images = [];
|
|
||||||
foreach ($allImages as $imagePath) {
|
|
||||||
try {
|
|
||||||
[$width, $height] = $this->image->make($imagePath)->getRealSize();
|
|
||||||
$images[] = [
|
|
||||||
'thumbnailURL' => $this->image
|
|
||||||
->make($imagePath)
|
|
||||||
->contain($imageThumbWidth, $imageThumbHeight)
|
|
||||||
->url(),
|
|
||||||
'largeURL' => $this->image->make($imagePath)->resize($imageFullWidth, $imageFullHeight)->url(),
|
|
||||||
'width' => $width,
|
|
||||||
'height' => $height,
|
|
||||||
'alt' => Str::htmlEntityEncode($product_info['name']),
|
|
||||||
];
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$this->logger->error($e->getMessage(), ['exception' => $e]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$data['images'] = $images;
|
|
||||||
|
|
||||||
$price = $this->priceCalculator->format($product_info['price'], $product_info['tax_class_id']);
|
$price = $this->priceCalculator->format($product_info['price'], $product_info['tax_class_id']);
|
||||||
$priceNumeric = $this->priceCalculator->getPriceNumeric($product_info['price'], $product_info['tax_class_id']);
|
$priceNumeric = $this->priceCalculator->getPriceNumeric($product_info['price'], $product_info['tax_class_id']);
|
||||||
@@ -441,4 +418,49 @@ class ProductsService
|
|||||||
->where('product_to_category.product_id', '=', $productId)
|
->where('product_to_category.product_id', '=', $productId)
|
||||||
->firstOrNull();
|
->firstOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getProductImages(int $productId): array
|
||||||
|
{
|
||||||
|
$imageThumbWidth = 500;
|
||||||
|
$imageThumbHeight = 500;
|
||||||
|
$imageFullWidth = 1000;
|
||||||
|
$imageFullHeight = 1000;
|
||||||
|
|
||||||
|
$product_info = $this->oc->model_catalog_product->getProduct($productId);
|
||||||
|
|
||||||
|
if (! $product_info) {
|
||||||
|
throw new EntityNotFoundException('Product with id ' . $productId . ' not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allImages = [];
|
||||||
|
if ($product_info['image']) {
|
||||||
|
$allImages[] = $product_info['image'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = $this->oc->model_catalog_product->getProductImages($productId);
|
||||||
|
foreach ($results as $result) {
|
||||||
|
$allImages[] = $result['image'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$images = [];
|
||||||
|
foreach ($allImages as $imagePath) {
|
||||||
|
try {
|
||||||
|
[$width, $height] = $this->image->make($imagePath)->getRealSize();
|
||||||
|
$images[] = [
|
||||||
|
'thumbnailURL' => $this->image
|
||||||
|
->make($imagePath)
|
||||||
|
->contain($imageThumbWidth, $imageThumbHeight)
|
||||||
|
->url(),
|
||||||
|
'largeURL' => $this->image->make($imagePath)->resize($imageFullWidth, $imageFullHeight)->url(),
|
||||||
|
'width' => $width,
|
||||||
|
'height' => $height,
|
||||||
|
'alt' => Str::htmlEntityEncode($product_info['name']),
|
||||||
|
];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->logger->error($e->getMessage(), ['exception' => $e]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $images;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,4 +37,5 @@ return [
|
|||||||
'webhook' => [TelegramHandler::class, 'webhook'],
|
'webhook' => [TelegramHandler::class, 'webhook'],
|
||||||
'etlCustomers' => [ETLHandler::class, 'customers'],
|
'etlCustomers' => [ETLHandler::class, 'customers'],
|
||||||
'etlCustomersMeta' => [ETLHandler::class, 'getCustomersMeta'],
|
'etlCustomersMeta' => [ETLHandler::class, 'getCustomersMeta'],
|
||||||
|
'getProductImages' => [ProductsHandler::class, 'getProductImages'],
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user