feat: add fullscreen viewer
This commit is contained in:
@@ -56,4 +56,22 @@ class ImageTool implements ImageToolInterface
|
|||||||
|
|
||||||
return rtrim($this->siteUrl, '/') . '/image/' . str_replace($this->imageDir, '', $fullNewPath);
|
return rtrim($this->siteUrl, '/') . '/image/' . str_replace($this->imageDir, '', $fullNewPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getUrl(string $path): string
|
||||||
|
{
|
||||||
|
return rtrim($this->siteUrl, '/') . '/image/' . str_replace($this->imageDir, '', $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRealSize(string $path): array
|
||||||
|
{
|
||||||
|
$fullPath = $this->imageDir . $path;
|
||||||
|
|
||||||
|
if (! is_file($fullPath)) {
|
||||||
|
throw new \RuntimeException('Image file not found: ' . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$img = $this->manager->make($fullPath);
|
||||||
|
|
||||||
|
return [$img->getWidth(), $img->getHeight()];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ namespace Openguru\OpenCartFramework\ImageTool;
|
|||||||
|
|
||||||
interface ImageToolInterface
|
interface ImageToolInterface
|
||||||
{
|
{
|
||||||
|
public function getUrl(string $path): string;
|
||||||
|
public function getRealSize(string $path): array;
|
||||||
|
|
||||||
public function resize(
|
public function resize(
|
||||||
string $path,
|
string $path,
|
||||||
int $width,
|
int $width,
|
||||||
|
|||||||
0
module/oc_telegram_shop/upload/oc_telegram_shop/src/Exceptions/CustomExceptionHandler.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/src/Exceptions/CustomExceptionHandler.php
Normal file → Executable file
@@ -230,20 +230,20 @@ class ProductsHandler
|
|||||||
->where('products_images.product_id', '=', $productId)
|
->where('products_images.product_id', '=', $productId)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$images = [];
|
$imagePaths = [];
|
||||||
$images[] = [
|
$imagePaths[] = $product['product_image'];
|
||||||
'url' => $this->ocImageTool->resize(
|
|
||||||
$product['product_image'],
|
|
||||||
$imageWidth,
|
|
||||||
$imageHeight,
|
|
||||||
'placeholder.png'
|
|
||||||
),
|
|
||||||
'alt' => $product['product_name'],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($productsImages as $item) {
|
foreach ($productsImages as $item) {
|
||||||
|
$imagePaths[] = $item['image'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$images = [];
|
||||||
|
foreach ($imagePaths as $imagePath) {
|
||||||
|
[$width, $height] = $this->ocImageTool->getRealSize($imagePath);
|
||||||
$images[] = [
|
$images[] = [
|
||||||
'url' => $this->ocImageTool->resize($item['image'], $imageWidth, $imageHeight, 'placeholder.png'),
|
'thumbnailURL' => $this->ocImageTool->resize($imagePath, $imageWidth, $imageHeight, 'placeholder.png'),
|
||||||
|
'largeURL' => $this->ocImageTool->getUrl($imagePath),
|
||||||
|
'width' => $width,
|
||||||
|
'height' => $height,
|
||||||
'alt' => $product['product_name'],
|
'alt' => $product['product_name'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
67
spa/src/components/FullScreenImageViewer.vue
Normal file
67
spa/src/components/FullScreenImageViewer.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed z-200 top-0 inset-0 flex justify-center align-center flex-col h-full bg-black">
|
||||||
|
<Swiper
|
||||||
|
:zoom="true"
|
||||||
|
:navigation="true"
|
||||||
|
:pagination="{
|
||||||
|
type: 'fraction',
|
||||||
|
}"
|
||||||
|
:activeIndex="activeIndex"
|
||||||
|
:modules="[Zoom, Navigation, Pagination]"
|
||||||
|
class="mySwiper w-full h-full"
|
||||||
|
@slider-move="vibrate"
|
||||||
|
>
|
||||||
|
<SwiperSlide v-for="image in images">
|
||||||
|
<div class="swiper-zoom-container">
|
||||||
|
<img :src="image.largeURL" :alt="image.alt"/>
|
||||||
|
</div>
|
||||||
|
</SwiperSlide>
|
||||||
|
</Swiper>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="absolute z-50 text-white text-xl right-5 cursor-pointer"
|
||||||
|
style="top: calc(var(--tg-safe-area-inset-top, 5px) + var(--tg-content-safe-area-inset-top, 5px))"
|
||||||
|
@click="onClose"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||||
|
class="size-10">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {Navigation, Pagination, Zoom} from "swiper/modules";
|
||||||
|
import {Swiper, SwiperSlide} from "swiper/vue";
|
||||||
|
import {onMounted} from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
images: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
|
||||||
|
activeIndex: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits(['close']);
|
||||||
|
let canVibrate = true;
|
||||||
|
|
||||||
|
function vibrate() {
|
||||||
|
if (!canVibrate) return;
|
||||||
|
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
|
||||||
|
canVibrate = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
canVibrate = true;
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
|
||||||
|
emits('close');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,100 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aspect-w-4 aspect-h-3">
|
<swiper-container ref="swiperEl" pagination="true">
|
||||||
<swiper
|
<swiper-slide
|
||||||
:lazy="true"
|
v-for="image in images"
|
||||||
:pagination="pagination"
|
lazy
|
||||||
:navigation="true"
|
|
||||||
:modules="modules"
|
|
||||||
class="mySwiper w-full min-h-[200px]"
|
|
||||||
@touchMove="onTouchMove"
|
|
||||||
>
|
>
|
||||||
<swiper-slide v-for="image in images">
|
|
||||||
<img
|
<img
|
||||||
:src="image.url"
|
:src="image.url"
|
||||||
:alt="image.alt"
|
:alt="image.alt"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
class="swiper-lazy-preloader swiper-lazy-preloader-white"
|
|
||||||
></div>
|
|
||||||
</swiper-slide>
|
</swiper-slide>
|
||||||
</swiper>
|
</swiper-container>
|
||||||
</div>
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import {Swiper, SwiperSlide} from 'swiper/vue';
|
import {onMounted, ref} from "vue";
|
||||||
|
|
||||||
import 'swiper/css';
|
const props = defineProps({
|
||||||
import 'swiper/css/pagination';
|
|
||||||
|
|
||||||
import {Pagination} from 'swiper/modules';
|
|
||||||
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
Swiper,
|
|
||||||
SwiperSlide,
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
|
||||||
images: {
|
images: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
setup() {
|
const swiperEl = ref(null);
|
||||||
const throttle = (func, delay) => {
|
|
||||||
let lastCall = 0;
|
|
||||||
return (...args) => {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastCall >= delay) {
|
|
||||||
lastCall = now;
|
|
||||||
func(...args);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const hapticTick = throttle(() => {
|
let haptic = true;
|
||||||
const haptic = window?.Telegram?.WebApp?.HapticFeedback;
|
|
||||||
if (haptic?.selectionChanged) {
|
|
||||||
haptic.selectionChanged();
|
|
||||||
} else if (haptic?.impactOccurred) {
|
|
||||||
haptic.impactOccurred('light');
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
const onTouchMove = () => {
|
onMounted(async () => {
|
||||||
hapticTick();
|
swiperEl.value?.addEventListener('swiperactiveindexchange', (event) => {
|
||||||
};
|
window.Telegram.WebApp.HapticFeedback.selectionChanged();
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
swiperEl.value?.addEventListener('swiperprogress', (num) => {
|
||||||
pagination: {
|
if (haptic === false) return;
|
||||||
clickable: true,
|
window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
|
||||||
dynamicBullets: true,
|
haptic = false;
|
||||||
},
|
setTimeout(() => haptic = true, 50);
|
||||||
modules: [Pagination],
|
});
|
||||||
onTouchMove,
|
|
||||||
};
|
swiperEl.value?.addEventListener('swiperreachbeginning', (event) => {
|
||||||
},
|
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
|
||||||
};
|
});
|
||||||
|
swiperEl.value?.addEventListener('swiperreachend', (event) => {
|
||||||
|
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.product-swiper {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.swiper-slide {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import ApplicationError from "@/ApplicationError.vue";
|
|||||||
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
||||||
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
||||||
|
|
||||||
|
|
||||||
|
import { register } from 'swiper/element/bundle';
|
||||||
|
import 'swiper/element/bundle';
|
||||||
|
import 'swiper/css/bundle';
|
||||||
|
register();
|
||||||
|
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
app
|
app
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ html, body, #app {
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
--swiper-pagination-color: var(--color-primary);
|
--swiper-pagination-color: var(--color-primary);
|
||||||
|
--swiper-navigation-color: var(--color-primary);
|
||||||
--swiper-pagination-bullet-inactive-color: var(--color-base-content);
|
--swiper-pagination-bullet-inactive-color: var(--color-base-content);
|
||||||
|
--swiper-pagination-fraction-color: var(--color-neutral-content);
|
||||||
|
--swiper-pagination-bottom: var(--tg-safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
.swiper-pagination-bullets {
|
.swiper-pagination-bullets {
|
||||||
|
|||||||
@@ -27,28 +27,33 @@
|
|||||||
:class="item.stock === false ? 'border-error' : ''"
|
:class="item.stock === false ? 'border-error' : ''"
|
||||||
>
|
>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="avatar">
|
<div class="flex">
|
||||||
|
<div class="avatar mr-5">
|
||||||
<div class="w-16 rounded">
|
<div class="w-16 rounded">
|
||||||
<img :src="item.thumb"/>
|
<img :src="item.thumb"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
<h2 class="card-title">{{ item.name }} <span v-if="! item.stock" class="text-error font-bold">***</span></h2>
|
<h2 class="card-title">{{ item.name }} <span v-if="! item.stock" class="text-error font-bold">***</span></h2>
|
||||||
<p class="text-sm font-bold">{{ item.total }}</p>
|
<p class="text-sm font-bold">{{ item.total }}</p>
|
||||||
<p>{{ item.price }}/ед</p>
|
<p>{{ item.price }}/ед</p>
|
||||||
<div>
|
<div>
|
||||||
<div v-for="option in item.option">
|
<div v-for="option in item.option">
|
||||||
<p><span class="font-bold">{{ option.name }}</span>: {{ option.value }}</p>
|
<p><span class="font-bold">{{ option.name }}</span>: {{ option.value }}</p>
|
||||||
<!-- <component-->
|
<!-- <component-->
|
||||||
<!-- v-if="SUPPORTED_OPTION_TYPES.includes(option.type) && componentMap[option.type]"-->
|
<!-- v-if="SUPPORTED_OPTION_TYPES.includes(option.type) && componentMap[option.type]"-->
|
||||||
<!-- :is="componentMap[option.type]"-->
|
<!-- :is="componentMap[option.type]"-->
|
||||||
<!-- :option="option"-->
|
<!-- :option="option"-->
|
||||||
<!-- />-->
|
<!-- />-->
|
||||||
<!-- <div v-else class="text-sm text-error">-->
|
<!-- <div v-else class="text-sm text-error">-->
|
||||||
<!-- Тип опции "{{ option.type }}" не поддерживается.-->
|
<!-- Тип опции "{{ option.type }}" не поддерживается.-->
|
||||||
<!-- </div>-->
|
<!-- </div>-->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-between">
|
<div class="card-actions justify-between">
|
||||||
<Quantity
|
<Quantity
|
||||||
:disabled="cart.isLoading"
|
:disabled="cart.isLoading"
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pb-10">
|
<div class="pb-10">
|
||||||
<div>
|
<div>
|
||||||
<ProductImageSwiper :images="product.images"/>
|
<swiper-container
|
||||||
|
pagination="true"
|
||||||
|
>
|
||||||
|
<swiper-slide
|
||||||
|
v-for="(image, index) in product.images"
|
||||||
|
lazy="true"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="image.thumbnailURL"
|
||||||
|
:alt="image.alt"
|
||||||
|
@click="showFullScreen(index)"
|
||||||
|
/>
|
||||||
|
</swiper-slide>
|
||||||
|
</swiper-container>
|
||||||
|
|
||||||
|
<FullScreenImageViewer
|
||||||
|
v-if="isFullScreen"
|
||||||
|
:images="product.images"
|
||||||
|
:activeIndex="initialFullScreenIndex"
|
||||||
|
@close="closeFullScreen"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Product info -->
|
<!-- Product info -->
|
||||||
<div
|
<div
|
||||||
@@ -92,11 +112,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {computed, onMounted, ref} from "vue";
|
import {computed, onMounted, onUnmounted, 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 ProductImageSwiper from "../components/ProductImageSwiper.vue";
|
|
||||||
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 {apiFetch} from "@/utils/ftch.js";
|
||||||
@@ -111,6 +130,11 @@ 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);
|
||||||
|
|
||||||
|
import FullScreenImageViewer from "@/components/FullScreenImageViewer.vue";
|
||||||
|
|
||||||
|
|
||||||
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) {
|
||||||
@@ -126,6 +150,19 @@ 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 actionBtnClick() {
|
async function actionBtnClick() {
|
||||||
try {
|
try {
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -150,8 +187,35 @@ function setQuantity(newQuantity) {
|
|||||||
window.Telegram.WebApp.HapticFeedback.selectionChanged();
|
window.Telegram.WebApp.HapticFeedback.selectionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let canVibrate = true;
|
||||||
|
|
||||||
|
function onPopState() {
|
||||||
|
if (isFullScreen.value) {
|
||||||
|
closeFullScreen();
|
||||||
|
} else {
|
||||||
|
// пусть Vue Router сам обработает
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('popstate', onPopState);
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const {data} = await apiFetch(`/index.php?route=extension/tgshop/handle&api_action=product_show&id=${productId.value}`);
|
const {data} = await apiFetch(`/index.php?route=extension/tgshop/handle&api_action=product_show&id=${productId.value}`);
|
||||||
product.value = data;
|
product.value = data;
|
||||||
|
|
||||||
|
window.addEventListener('popstate', onPopState);
|
||||||
|
|
||||||
|
const swiperEl = document.querySelector('swiper-container');
|
||||||
|
swiperEl.addEventListener('swiperslidermove', (event) => {
|
||||||
|
if (!canVibrate) return;
|
||||||
|
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
|
||||||
|
canVibrate = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
canVibrate = true;
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { defineConfig } from "vite";
|
import {defineConfig} from "vite";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import {fileURLToPath, URL} from 'node:url';
|
import {fileURLToPath, URL} from 'node:url';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), vue()],
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
|
vue({
|
||||||
|
template: {
|
||||||
|
compilerOptions: {
|
||||||
|
isCustomElement: tag => tag.startsWith('swiper-'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
base: '/image/catalog/tgshopspa/',
|
base: '/image/catalog/tgshopspa/',
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
Reference in New Issue
Block a user