Squashed commit message
Some checks failed
Telegram Mini App Shop Builder / Compute version metadata (push) Has been cancelled
Telegram Mini App Shop Builder / Run Frontend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run Backend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Has been cancelled
Telegram Mini App Shop Builder / Build module. (push) Has been cancelled
Telegram Mini App Shop Builder / release (push) Has been cancelled
Some checks failed
Telegram Mini App Shop Builder / Compute version metadata (push) Has been cancelled
Telegram Mini App Shop Builder / Run Frontend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run Backend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Has been cancelled
Telegram Mini App Shop Builder / Build module. (push) Has been cancelled
Telegram Mini App Shop Builder / release (push) Has been cancelled
This commit is contained in:
9
frontend/spa/src/components/AppDebugMessage.vue
Normal file
9
frontend/spa/src/components/AppDebugMessage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div role="alert" class="alert alert-warning rounded-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" 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>
|
||||
<span>Включен режим разработчика!</span>
|
||||
</div>
|
||||
</template>
|
||||
56
frontend/spa/src/components/BottomDrawer.vue
Normal file
56
frontend/spa/src/components/BottomDrawer.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 bg-black/50 z-40"
|
||||
@click.self="close"
|
||||
>
|
||||
<transition name="slide-up">
|
||||
<div
|
||||
class="fixed bottom-0 left-0 w-full h-[80vh] bg-white rounded-t-2xl shadow-xl z-50 p-4"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
5
frontend/spa/src/components/BottomPanel.vue
Normal file
5
frontend/spa/src/components/BottomPanel.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="fixed px-4 pb-10 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col gap-2 border-t-1 border-t-base-300">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
47
frontend/spa/src/components/CartButton.vue
Normal file
47
frontend/spa/src/components/CartButton.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div v-if="isCartBtnShow" class="fixed right-2 bottom-30 z-50 opacity-90">
|
||||
<div class="indicator">
|
||||
<span class="indicator-item indicator-top indicator-start badge badge-secondary">{{ cart.productsCount }}</span>
|
||||
<button class="btn btn-primary btn-xl btn-circle" @click="openCart">
|
||||
<span v-if="cart.isLoading" class="loading loading-spinner"></span>
|
||||
<template v-else>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<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>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed, onMounted} from "vue";
|
||||
import {useCartStore} from "@/stores/CartStore.js";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const cart = useCartStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
const isCartBtnShow = computed(() => {
|
||||
return route.name === 'product.show';
|
||||
});
|
||||
|
||||
|
||||
function openCart() {
|
||||
haptic.selectionChanged();
|
||||
router.push({name: 'cart'});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await cart.getProducts();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
27
frontend/spa/src/components/CategoriesList/CategoryItem.vue
Normal file
27
frontend/spa/src/components/CategoriesList/CategoryItem.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<a
|
||||
href="#"
|
||||
:key="category.id"
|
||||
class="flex items-center"
|
||||
@click.prevent="$emit('onSelect', category)"
|
||||
>
|
||||
<div class="avatar">
|
||||
<div class="w-8 h-8 rounded">
|
||||
<img :src="category.image" :alt="category.name" loading="lazy" width="30" height="30" class="bg-base-400"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="ml-4 text-lg line-clamp-2">{{ category.name }}</h3>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
category: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(["onSelect"]);
|
||||
</script>
|
||||
150
frontend/spa/src/components/Dock.vue
Normal file
150
frontend/spa/src/components/Dock.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="dock dock-lg select-none">
|
||||
<RouterLink
|
||||
:to="{name: 'home'}"
|
||||
:class="{'dock-active': route.name === 'home'}"
|
||||
@click="onDockItemClick"
|
||||
>
|
||||
<svg class="dock-icon size-[1.5em]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g fill="currentColor" stroke-linejoin="miter" stroke-linecap="butt">
|
||||
<polyline points="1 11 12 2 23 11" fill="none" stroke="currentColor" stroke-miterlimit="10"
|
||||
stroke-width="2"></polyline>
|
||||
<path d="m5,13v7c0,1.105.895,2,2,2h10c1.105,0,2-.895,2-2v-7" fill="none" stroke="currentColor"
|
||||
stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></path>
|
||||
<line x1="12" y1="22" x2="12" y2="18" fill="none" stroke="currentColor" stroke-linecap="square"
|
||||
stroke-miterlimit="10" stroke-width="2"></line>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="dock-label">Главная</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
:to="{name: 'categories'}"
|
||||
:class="{'dock-active': route.name === 'categories'}"
|
||||
@click="onDockItemClick"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
class="dock-icon size-7">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z"/>
|
||||
</svg>
|
||||
<span class="dock-label">Каталог</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
:to="{name: 'search'}"
|
||||
:class="{'dock-active': route.name === 'search'}"
|
||||
@click="onDockItemClick"
|
||||
>
|
||||
<svg class="dock-icon size-[1.5em]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/>
|
||||
</svg>
|
||||
<span class="dock-label">Поиск</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
v-if="settings.product_interaction_mode === 'order'"
|
||||
:to="{name: 'cart'}"
|
||||
:class="{'dock-active': route.name === 'cart'}"
|
||||
@click="onDockItemClick"
|
||||
>
|
||||
<div class="indicator">
|
||||
<span class="indicator-item indicator-end badge badge-secondary badge-xs">{{ cart.productsCount }}</span>
|
||||
<svg class="dock-icon size-[1.5em]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<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>
|
||||
</div>
|
||||
<span class="dock-label">Корзина</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
:to="{name: 'account'}"
|
||||
:class="{'dock-active': route.name === 'account'}"
|
||||
@click="onDockItemClick"
|
||||
>
|
||||
<div v-if="tgData?.user?.photo_url" class="dock-icon w-7 h-7 rounded-full overflow-hidden">
|
||||
<img :src="tgData?.user?.photo_url" alt="avatar" class="w-full h-full object-cover"/>
|
||||
</div>
|
||||
<div v-else class="dock-icon bg-primary text-primary-content w-7 h-7 rounded-full flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="dock-label">Профиль</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useRoute} from "vue-router";
|
||||
import {useCartStore} from "@/stores/CartStore.js";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import {useTgData} from "@/composables/useTgData.js";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const route = useRoute();
|
||||
const cart = useCartStore();
|
||||
const settings = useSettingsStore();
|
||||
const tgData = useTgData();
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
function onDockItemClick(event) {
|
||||
haptic.selectionChanged();
|
||||
|
||||
// Находим иконку в кликнутом элементе
|
||||
const icon = event.currentTarget.querySelector('.dock-icon');
|
||||
if (icon) {
|
||||
icon.classList.add('dock-icon-clicked');
|
||||
setTimeout(() => {
|
||||
icon.classList.remove('dock-icon-clicked');
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dock {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
height: var(--dock-height);
|
||||
bottom: calc(
|
||||
var(--tg-content-safe-area-inset-bottom, 0px)
|
||||
+ var(--tg-safe-area-inset-bottom, 0px)
|
||||
);
|
||||
padding-bottom: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
|
||||
background: var(--color-base-100);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.dock-icon {
|
||||
transition: transform 0.2s ease-out;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.dock-icon-clicked {
|
||||
animation: dock-icon-bounce 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dock-icon-bounce {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
81
frontend/spa/src/components/FullScreenImageViewer.vue
Normal file
81
frontend/spa/src/components/FullScreenImageViewer.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="fullscreen-image-viewer 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',
|
||||
}"
|
||||
:initialSlide="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" class="w-full h-full"/>
|
||||
</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";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const props = defineProps({
|
||||
images: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
activeIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['close']);
|
||||
const haptic = useHapticFeedback();
|
||||
let canVibrate = true;
|
||||
|
||||
function vibrate() {
|
||||
if (!canVibrate) return;
|
||||
haptic.impactOccurred('soft');
|
||||
canVibrate = false;
|
||||
setTimeout(() => {
|
||||
canVibrate = true;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
haptic.impactOccurred('medium');
|
||||
emits('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.fullscreen-image-viewer .swiper-button-next,
|
||||
.fullscreen-image-viewer .swiper-button-prev {
|
||||
color: white;
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
|
||||
.fullscreen-image-viewer .swiper-pagination-fraction {
|
||||
bottom: calc(var(--tg-safe-area-inset-bottom, 0px) + var(--tg-content-safe-area-inset-bottom, px));
|
||||
}
|
||||
</style>
|
||||
7
frontend/spa/src/components/Icons/IconFunnel.vue
Normal file
7
frontend/spa/src/components/Icons/IconFunnel.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z"/>
|
||||
</svg>
|
||||
</template>
|
||||
7
frontend/spa/src/components/Icons/IconWarning.vue
Normal file
7
frontend/spa/src/components/Icons/IconWarning.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
|
||||
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>
|
||||
</template>
|
||||
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>
|
||||
17
frontend/spa/src/components/LoadingFullScreen.vue
Normal file
17
frontend/spa/src/components/LoadingFullScreen.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div style="z-index: 99999" class="fixed left-0 w-full h-full bg-base-100 top-0">
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<span class="loading loading-infinity loading-xl"></span>
|
||||
<h1>{{ text }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: 'Получение данных...',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
35
frontend/spa/src/components/MainPage/Blocks/BaseBlock.vue
Normal file
35
frontend/spa/src/components/MainPage/Blocks/BaseBlock.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<section class="px-4">
|
||||
<header class="flex justify-between items-end mb-4">
|
||||
<div>
|
||||
<div v-if="title" class="font-bold uppercase">{{ title }}</div>
|
||||
<div v-if="description" class="text-sm text-base-content/50">{{ description }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="moreLink">
|
||||
<RouterLink :to="moreLink" class="btn btn-soft btn-xs" @click="haptic.selectionChanged">
|
||||
{{ moreText || 'Смотреть всё' }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<slot></slot>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
description: String,
|
||||
moreLink: [String, Object],
|
||||
moreText: String,
|
||||
});
|
||||
|
||||
const haptic = useHapticFeedback();
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<section>
|
||||
<header>
|
||||
<div v-if="block.title" class="font-bold uppercase text-center">{{ block.title }}</div>
|
||||
<div v-if="block.description" class="text-sm text-center">{{ block.description }}</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="flex items-center justify-center p-5 gap-2 flex-wrap">
|
||||
<RouterLink class="btn btn-md" to="/categories">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z"/>
|
||||
</svg>
|
||||
Каталог
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
v-for="category in block.data?.categories || []"
|
||||
class="btn btn-md max-w-[12rem]"
|
||||
:to="{name: 'product.categories.show', params: {category_id: category.id}}"
|
||||
@click="onCategoryClick"
|
||||
>
|
||||
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{{ category.name }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref} from "vue";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const isLoading = ref(false);
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
const props = defineProps({
|
||||
block: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
function onCategoryClick() {
|
||||
haptic.impactOccurred('soft');
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div role="alert" class="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" 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>
|
||||
<span>Проблема при отображении блока.</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<BaseBlock
|
||||
:title="block.title"
|
||||
:description="block.description"
|
||||
:moreLink="{name: 'product.categories.show', params: { category_id: block.data.category_id }}"
|
||||
:moreText="block.data.all_text"
|
||||
>
|
||||
<Swiper
|
||||
class="select-none block-products-carousel"
|
||||
:slides-per-view="block.data?.carousel?.slides_per_view || 2.5"
|
||||
:space-between="block.data?.carousel?.space_between || 20"
|
||||
:autoplay="block.data?.carousel?.autoplay || false"
|
||||
:freeMode="freeModeSettings"
|
||||
:lazy="true"
|
||||
@sliderMove="hapticScroll"
|
||||
>
|
||||
<SwiperSlide
|
||||
v-for="product in block.data.products.data"
|
||||
:key="product.id"
|
||||
class="pb-1"
|
||||
>
|
||||
<div class="radius-box bg-base-100 shadow-sm p-2">
|
||||
<RouterLink
|
||||
:to="{name: 'product.show', params: {id: product.id}}"
|
||||
@click="slideClick(product)"
|
||||
>
|
||||
<div class="text-center">
|
||||
<img :src="product.images[0].url" :alt="product.name" loading="lazy" class="product-image"/>
|
||||
<PriceTitle :product="product"/>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
</BaseBlock>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {Swiper, SwiperSlide} from "swiper/vue";
|
||||
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||
import BaseBlock from "@/components/MainPage/Blocks/BaseBlock.vue";
|
||||
import PriceTitle from "@/components/ProductItem/PriceTitle.vue";
|
||||
|
||||
const hapticScroll = useHapticScroll();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
|
||||
const props = defineProps({
|
||||
block: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
const freeModeSettings = {
|
||||
enabled: props.block.data?.carousel?.freemode?.enabled || false,
|
||||
};
|
||||
|
||||
function slideClick(product) {
|
||||
if (props.block.goal_name) {
|
||||
yaMetrika.reachGoal(props.block.goal_name, {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-image {
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<section class="px-4">
|
||||
<header class="mb-4">
|
||||
<div v-if="block.title" class="font-bold uppercase">{{ block.title }}</div>
|
||||
<div v-if="block.description" class="text-sm">{{ block.description }}</div>
|
||||
</header>
|
||||
<main>
|
||||
<ProductsList
|
||||
:products="products"
|
||||
:hasMore="hasMore"
|
||||
:isLoading="isLoading"
|
||||
:isLoadingMore="isLoadingMore"
|
||||
@loadMore="onLoadMore"
|
||||
/>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {onMounted, ref, toRaw} from "vue";
|
||||
import ProductsList from "@/components/ProductsList.vue";
|
||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import ftch from "@/utils/ftch.js";
|
||||
|
||||
const filtersStore = useProductFiltersStore();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const products = ref([]);
|
||||
const hasMore = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const isLoadingMore = ref(false);
|
||||
const page = ref(1);
|
||||
const perPage = 10;
|
||||
|
||||
const props = defineProps({
|
||||
block: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchProducts() {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
console.debug('[Products Feed]: Start to load products for page 1. Filters: ', toRaw(filtersStore.applied));
|
||||
const response = await ftch('products', null, toRaw({
|
||||
page: 1,
|
||||
maxPages: props.block.data.max_page_count,
|
||||
perPage: perPage,
|
||||
filters: filtersStore.applied,
|
||||
}));
|
||||
products.value = response.data;
|
||||
hasMore.value = response.meta.hasMore;
|
||||
console.debug('[Products Feed]: Products loaded for page 1. Has More: ', hasMore.value);
|
||||
|
||||
yaMetrika.dataLayerPush({
|
||||
ecommerce: {
|
||||
currencyCode: settings.currency_code,
|
||||
impressions: products.value.map((product, index) => {
|
||||
return {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.final_price_numeric,
|
||||
brand: product.manufacturer_name,
|
||||
category: product.category_name,
|
||||
list: 'Главная страница',
|
||||
position: index,
|
||||
discount: product.price_numeric - product.final_price_numeric,
|
||||
quantity: product.product_quantity,
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onLoadMore() {
|
||||
try {
|
||||
console.debug('[Products Feed]: onLoadMore');
|
||||
if (isLoading.value === true || isLoadingMore.value === true || hasMore.value === false) return;
|
||||
isLoadingMore.value = true;
|
||||
page.value++;
|
||||
console.debug('[Products Feed]: Load more for page ', page.value, ' using filters: ', toRaw(filtersStore.applied));
|
||||
const response = await ftch('products', null, toRaw({
|
||||
page: page.value,
|
||||
perPage: perPage,
|
||||
maxPages: props.block.data.max_page_count,
|
||||
filters: filtersStore.applied,
|
||||
}));
|
||||
products.value.push(...response.data);
|
||||
hasMore.value = response.meta.hasMore;
|
||||
console.debug(`[Products Feed]: Products loaded for page ${page.value}. Has More: `, hasMore.value);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
console.debug("[Products Feed] Mounted");
|
||||
await fetchProducts();
|
||||
});
|
||||
</script>
|
||||
26
frontend/spa/src/components/MainPage/Blocks/SliderBlock.vue
Normal file
26
frontend/spa/src/components/MainPage/Blocks/SliderBlock.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<section class="mainpage-block">
|
||||
<header class="mainpage-block__header">
|
||||
<div v-if="block.title" class="font-bold uppercase text-center">{{ block.title }}</div>
|
||||
<div v-if="block.description" class="text-sm text-center mb-2">{{ block.description }}</div>
|
||||
</header>
|
||||
<main>
|
||||
<Slider :config="block.data" :goalName="block.goal_name"/>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Slider from "@/components/Slider.vue";
|
||||
|
||||
const props = defineProps({
|
||||
block: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
34
frontend/spa/src/components/MainPage/EmptyBlocks.vue
Normal file
34
frontend/spa/src/components/MainPage/EmptyBlocks.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center text-center py-16 px-4">
|
||||
<div class="mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-16 text-base-content/40">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold mb-3">Главная страница пуста</h2>
|
||||
<p class="text-sm text-base-content/70 mb-2 max-w-md">
|
||||
На главной странице не сконфигурировано ни одного блока для отображения.
|
||||
</p>
|
||||
<p class="text-sm text-base-content/70 max-w-md">
|
||||
Перейдите в настройки модуля <span class="font-semibold">MegaPay</span> и добавьте блоки на главную страницу.
|
||||
</p>
|
||||
<div class="mt-6 p-4 bg-base-200 rounded-lg max-w-md">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 text-info shrink-0 mt-0.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||
</svg>
|
||||
<p class="text-xs text-base-content/60 text-left">
|
||||
Вы можете добавить слайдеры, категории, ленты товаров и другие блоки для создания красивой главной страницы.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
46
frontend/spa/src/components/MainPage/MainPage.vue
Normal file
46
frontend/spa/src/components/MainPage/MainPage.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="blocks.blocks?.length > 0"
|
||||
v-for="(block, index) in blocks.blocks"
|
||||
>
|
||||
<template v-if="blockTypeToComponentMap[block.type]">
|
||||
<component
|
||||
v-if="block.is_enabled"
|
||||
:is="blockTypeToComponentMap[block.type]"
|
||||
:block="block"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else-if="blockTypeToComponentMap[block.type] === undefined">
|
||||
<div role="alert" class="alert alert-error mx-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" 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>
|
||||
<span>Unsupported Block Type: <span class="font-bold">{{ block.type }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmptyBlocks v-else/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SliderBlock from "@/components/MainPage/Blocks/SliderBlock.vue";
|
||||
import CategoriesTopBlock from "@/components/MainPage/Blocks/CategoriesTopBlock.vue";
|
||||
import {useBlocksStore} from "@/stores/BlocksStore.js";
|
||||
import ErrorBlock from "@/components/MainPage/Blocks/ErrorBlock.vue";
|
||||
import ProductsFeedBlock from "@/components/MainPage/Blocks/ProductsFeedBlock.vue";
|
||||
import EmptyBlocks from "@/components/MainPage/EmptyBlocks.vue";
|
||||
import ProductsCarouselBlock from "@/components/MainPage/Blocks/ProductsCarouselBlock.vue";
|
||||
|
||||
const blockTypeToComponentMap = {
|
||||
slider: SliderBlock,
|
||||
categories_top: CategoriesTopBlock,
|
||||
products_feed: ProductsFeedBlock,
|
||||
products_carousel: ProductsCarouselBlock,
|
||||
error: ErrorBlock,
|
||||
};
|
||||
|
||||
const blocks = useBlocksStore();
|
||||
</script>
|
||||
37
frontend/spa/src/components/Navbar.vue
Normal file
37
frontend/spa/src/components/Navbar.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="navbar">
|
||||
<div class="navbar-start">
|
||||
<div v-if="false" class="dropdown">
|
||||
<button class="btn btn-ghost btn-circle" @click="toggleDrawer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" /> </svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-center">
|
||||
<RouterLink :to="{name: 'home'}" class="font-medium text-xl flex items-center">
|
||||
<div class="mr-2">
|
||||
<div v-if="settings.app_icon" class="max-h-10">
|
||||
<img :src="settings.app_icon" class="max-h-10" :alt="settings.app_name"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ settings.app_name }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const emits = defineEmits(['drawer']);
|
||||
|
||||
function toggleDrawer() {
|
||||
emits('drawer');
|
||||
}
|
||||
</script>
|
||||
15
frontend/spa/src/components/NoProducts.vue
Normal file
15
frontend/spa/src/components/NoProducts.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center text-center py-16">
|
||||
<span class="text-5xl mb-4">🛒</span>
|
||||
<h2 class="text-xl font-semibold mb-2">Здесь нет товаров</h2>
|
||||
<p class="text-sm mb-4">
|
||||
Попробуйте изменить настройки фильтров
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
const router = useRouter();
|
||||
const goBack = () => router.back();
|
||||
</script>
|
||||
13
frontend/spa/src/components/Price.vue
Normal file
13
frontend/spa/src/components/Price.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<span>{{ formatPrice(value) }} ₽</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {formatPrice} from "@/helpers.js";
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
53
frontend/spa/src/components/PrivacyPolicy.vue
Normal file
53
frontend/spa/src/components/PrivacyPolicy.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div v-if="isShown" class="toast toast-center toast-bottom-fix z-50">
|
||||
<div class="alert alert-info">
|
||||
<span>
|
||||
Используя магазин, вы соглашаетесь с
|
||||
<a v-if="settings.privacy_policy_link"
|
||||
href="#" class="underline"
|
||||
@click.prevent="showPrivacyPolicy"
|
||||
>обработкой персональных данных</a>
|
||||
<span v-else>обработкой персональных данных</span>.
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-outline"
|
||||
@click="privacyConsent"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {userPrivacyConsent} from "@/utils/ftch.js";
|
||||
import {ref} from "vue";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
|
||||
const isShown = ref(true);
|
||||
const settings = useSettingsStore();
|
||||
|
||||
async function privacyConsent() {
|
||||
isShown.value = false;
|
||||
await userPrivacyConsent();
|
||||
}
|
||||
|
||||
function showPrivacyPolicy() {
|
||||
if (settings.privacy_policy_link) {
|
||||
window.Telegram.WebApp.openLink(settings.privacy_policy_link, {
|
||||
try_instant_view: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-bottom-fix {
|
||||
bottom: calc(
|
||||
var(--dock-height, 0px)
|
||||
+ var(--tg-content-safe-area-inset-bottom, 0px)
|
||||
+ var(--tg-safe-area-inset-bottom, 0px)
|
||||
+ 5px
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="border-b mb-5 border-b-base-200 flex justify-between">
|
||||
<div class="mb-2">Рекомендуемые товары</div>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
v-model="filter.criteria.product_for_main_page.params.value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
filter: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="border-b mb-5 border-b-base-200">
|
||||
<div class="mb-2">Категория</div>
|
||||
<div v-if="categoriesStore.isLoading" class="skeleton h-10 w-full"></div>
|
||||
<select
|
||||
v-else
|
||||
v-model.number="props.filter.criteria.product_category_id.params.value"
|
||||
class="select w-full"
|
||||
>
|
||||
<option :value="null">Любая категория</option>
|
||||
<SelectOption
|
||||
v-for="category in categoriesStore.categories"
|
||||
:key="category.id"
|
||||
:level="0"
|
||||
:category="category"
|
||||
/>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
|
||||
import {onMounted} from "vue";
|
||||
import SelectOption from "@/components/ProductFilters/Components/ProductCategory/SelectOption.vue";
|
||||
|
||||
const props = defineProps({
|
||||
filter: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const categoriesStore = useCategoriesStore();
|
||||
|
||||
onMounted(() => {
|
||||
categoriesStore.fetchCategories();
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<option :value="category.id">
|
||||
{{ "-".repeat(level) }} {{ category.name }}
|
||||
</option>
|
||||
|
||||
<SelectOption
|
||||
v-if="category.children"
|
||||
v-for="child in category.children"
|
||||
:key="child.id"
|
||||
:category="child"
|
||||
:level="level + 1"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
category: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="border-b mb-5 border-b-base-200">
|
||||
<div class="mb-2">Цена</div>
|
||||
<div class="flex justify-between">
|
||||
<label class="input mr-3">
|
||||
От
|
||||
<input
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
class="grow"
|
||||
min="0"
|
||||
step="50"
|
||||
:placeholder="filter.criteria.product_price.params.value.from || '0'"
|
||||
v-model="filter.criteria.product_price.params.value.from"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="input">
|
||||
До
|
||||
<input
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
class="grow"
|
||||
min="0"
|
||||
step="50"
|
||||
:placeholder="filter.criteria.product_price.params.value.to || '∞'"
|
||||
:value="filter.criteria.product_price.params.value.to"
|
||||
v-model="filter.criteria.product_price.params.value.to"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
filter: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
58
frontend/spa/src/components/ProductImageSwiper.vue
Normal file
58
frontend/spa/src/components/ProductImageSwiper.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<Swiper
|
||||
:modules="modules"
|
||||
:pagination="pagination"
|
||||
:virtual="virtual"
|
||||
@sliderMove="hapticScroll"
|
||||
class="radius-box"
|
||||
>
|
||||
<SwiperSlide
|
||||
v-for="(image, index) in images"
|
||||
:key="image.url"
|
||||
:virtualIndex="index"
|
||||
>
|
||||
<img
|
||||
:src="image.url"
|
||||
loading="lazy"
|
||||
:alt="image.alt"
|
||||
class="w-full h-full radius-box"
|
||||
@load="onLoad(image.url)"
|
||||
@error="onLoad(image.url)"
|
||||
/>
|
||||
<div v-if="!loaded[image.url]" class="swiper-lazy-preloader"></div>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref} from 'vue';
|
||||
import {Swiper, SwiperSlide} from 'swiper/vue';
|
||||
import {Pagination, Virtual} from 'swiper/modules';
|
||||
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/pagination';
|
||||
|
||||
const props = defineProps({
|
||||
images: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const modules = [Pagination, Virtual];
|
||||
const hapticScroll = useHapticScroll();
|
||||
const pagination = {
|
||||
clickable: true,
|
||||
dynamicBullets: false,
|
||||
};
|
||||
const virtual = {
|
||||
enabled: true,
|
||||
addSlidesAfter: 1,
|
||||
addSlidesBefore: 0,
|
||||
};
|
||||
|
||||
const loaded = ref({});
|
||||
const onLoad = (url) => {
|
||||
if (url) loaded.value[url] = true;
|
||||
};
|
||||
</script>
|
||||
24
frontend/spa/src/components/ProductItem/Price.vue
Normal file
24
frontend/spa/src/components/ProductItem/Price.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div v-if="special">
|
||||
<span class="old-price text-base-content/50 line-through mr-1">{{ price }}</span>
|
||||
<span class="curr-price font-bold">{{ special }}</span>
|
||||
</div>
|
||||
<div v-else class="font-bold">{{ price }}</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
price: String,
|
||||
special: [String, Boolean],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.old-price {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.curr-price {
|
||||
font-size: .9rem;
|
||||
}
|
||||
</style>
|
||||
15
frontend/spa/src/components/ProductItem/PriceTitle.vue
Normal file
15
frontend/spa/src/components/ProductItem/PriceTitle.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="mt-2 text-center space-y-0.5">
|
||||
<Price :price="product.price" :special="product.special"/>
|
||||
<ProductTitle :title="product.name"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProductTitle from "@/components/ProductItem/ProductTitle.vue";
|
||||
import Price from "@/components/ProductItem/Price.vue";
|
||||
|
||||
const props = defineProps({
|
||||
product: Object,
|
||||
});
|
||||
</script>
|
||||
21
frontend/spa/src/components/ProductItem/ProductTitle.vue
Normal file
21
frontend/spa/src/components/ProductItem/ProductTitle.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<h3 class="product-title text-sm/4">{{ title }}</h3>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: null,
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-title {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: var(--product_list_title_max_lines, 3);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
19
frontend/spa/src/components/ProductNotFound.vue
Normal file
19
frontend/spa/src/components/ProductNotFound.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center text-center py-16">
|
||||
<span class="text-5xl mb-4">😔</span>
|
||||
<h2 class="text-xl font-semibold mb-2">Товар не найден</h2>
|
||||
<p class="text-sm mb-4">К сожалению, запрошенный товар недоступен или был удалён.</p>
|
||||
<button class="btn btn-primary" @click="goBack">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
const router = useRouter();
|
||||
const goBack = () => router.back();
|
||||
</script>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<p>
|
||||
<span class="text-xs font-medium">
|
||||
{{ option.name }}: {{ option.value }} <span v-if="option.price"> ({{ option.price_prefix }}<Price :value="option.price"/>)</span>
|
||||
</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Price from "@/components/Price.vue";
|
||||
|
||||
const props = defineProps({
|
||||
option: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<p>
|
||||
<span class="text-xs font-medium">
|
||||
{{ option.name }}: {{ option.value }} <span v-if="option.price"> ({{ option.price_prefix }}<Price :value="option.price"/>)</span>
|
||||
</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Price from "@/components/Price.vue";
|
||||
|
||||
const props = defineProps({
|
||||
option: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<p>
|
||||
<span class="text-xs font-medium">
|
||||
{{ option.name }}: {{ option.value }} <span v-if="option.price"> ({{ option.price_prefix }}<Price :value="option.price"/>)</span>
|
||||
</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Price from "@/components/Price.vue";
|
||||
|
||||
const props = defineProps({
|
||||
option: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div v-for="option in options" :key="option.product_option_id" class="mt-3">
|
||||
<component
|
||||
v-if="SUPPORTED_OPTION_TYPES.includes(option.type) && componentMap[option.type]"
|
||||
:is="componentMap[option.type]"
|
||||
:modelValue="option"
|
||||
/>
|
||||
<div v-else class="text-sm text-error">
|
||||
Тип опции "{{ option.type }}" не поддерживается.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import OptionRadio from "./Types/OptionRadio.vue";
|
||||
import OptionCheckbox from "./Types/OptionCheckbox.vue";
|
||||
import OptionText from "./Types/OptionText.vue";
|
||||
import OptionTextarea from "./Types/OptionTextarea.vue";
|
||||
import OptionSelect from "./Types/OptionSelect.vue";
|
||||
import {SUPPORTED_OPTION_TYPES} from "@/constants/options.js";
|
||||
|
||||
const componentMap = {
|
||||
radio: OptionRadio,
|
||||
checkbox: OptionCheckbox,
|
||||
text: OptionText,
|
||||
textarea: OptionTextarea,
|
||||
select: OptionSelect,
|
||||
};
|
||||
|
||||
const options = defineModel();
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div>
|
||||
<OptionTemplate :name="model.name" :required="model.required">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="value in model.product_option_value"
|
||||
class="group relative flex items-center justify-center btn btn-soft btn-secondary btn-sm"
|
||||
:class="value.selected ? 'btn-active' : ''"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="value.product_option_value_id"
|
||||
:checked="value.selected"
|
||||
@change="select(value)"
|
||||
class="absolute inset-0 appearance-none focus:outline-none disabled:cursor-not-allowed"
|
||||
/>
|
||||
|
||||
<span class="text-xs font-medium group-has-checked:text-white">
|
||||
{{ value.name }}<span v-if="value.price"> ({{ value.price_prefix }}{{ value.price }})</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</OptionTemplate>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import OptionTemplate from "./OptionTemplate.vue";
|
||||
|
||||
const model = defineModel();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
function select(toggledValue) {
|
||||
model.value.product_option_value.forEach(value => {
|
||||
if (value === toggledValue) {
|
||||
value.selected = !value.selected;
|
||||
}
|
||||
});
|
||||
|
||||
model.value.value = model.value.product_option_value.filter(item => item.selected === true);
|
||||
|
||||
emit('update:modelValue', model.value);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<OptionTemplate :name="model.name" :required="model.required">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="value in model.product_option_value"
|
||||
class="group relative flex items-center justify-center btn btn-soft btn-secondary btn-sm"
|
||||
:class="value.selected ? 'btn-active' : ''"
|
||||
>
|
||||
|
||||
<input
|
||||
type="radio"
|
||||
:name="`option-${model.product_option_id}`"
|
||||
:value="value.product_option_value_id"
|
||||
:checked="value.selected"
|
||||
@change="select(value)"
|
||||
class="absolute inset-0 appearance-none focus:outline-none disabled:cursor-not-allowed"
|
||||
/>
|
||||
|
||||
<span class="text-xs font-medium">
|
||||
{{ value.name }}<span v-if="value.price"> ({{ value.price_prefix }}{{ value.price }})</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</OptionTemplate>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import OptionTemplate from "./OptionTemplate.vue";
|
||||
|
||||
const model = defineModel();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
function select(selectedValue) {
|
||||
model.value.product_option_value.forEach(value => {
|
||||
value.selected = (value === selectedValue);
|
||||
});
|
||||
|
||||
model.value.value = selectedValue;
|
||||
|
||||
emit('update:modelValue', model);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<OptionTemplate :name="model.name" :required="model.required">
|
||||
<select
|
||||
:name="`option-${model.product_option_id}`"
|
||||
class="select"
|
||||
@change="onChange"
|
||||
>
|
||||
<option value="" disabled selected>Выберите значение</option>
|
||||
<option
|
||||
v-for="value in model.product_option_value"
|
||||
:key="value.product_option_value_id"
|
||||
:value="value.product_option_value_id"
|
||||
:selected="value.selected"
|
||||
>
|
||||
{{ value.name }}<span v-if="value.price"> ({{ value.price_prefix }}{{ value.price }})</span>
|
||||
</option>
|
||||
</select>
|
||||
</OptionTemplate>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import OptionTemplate from "./OptionTemplate.vue";
|
||||
|
||||
const model = defineModel();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
function onChange(event) {
|
||||
const selectedId = Number(event.target.value);
|
||||
|
||||
model.value.product_option_value.forEach(value => {
|
||||
value.selected = (value.product_option_value_id === selectedId);
|
||||
});
|
||||
|
||||
model.value.value = model.value.product_option_value.find(value => value.product_option_value_id === selectedId);
|
||||
|
||||
emit('update:modelValue', model.value);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="text-sm mb-2">
|
||||
{{ name }} <span v-if="required" class="text-error">*</span>
|
||||
</h3>
|
||||
|
||||
<fieldset>
|
||||
<slot></slot>
|
||||
</fieldset>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<OptionTemplate :name="model.name" :required="model.required">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="model.name"
|
||||
:value="model.value"
|
||||
@input="input(model, $event.target.value)"
|
||||
/>
|
||||
</OptionTemplate>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import OptionTemplate from "./OptionTemplate.vue";
|
||||
|
||||
const model = defineModel();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
function input(model, newValue) {
|
||||
model.value = newValue;
|
||||
emit('update:modelValue', model);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<OptionTemplate :name="model.name" :required="model.required">
|
||||
<textarea
|
||||
type="text"
|
||||
class="textarea"
|
||||
:placeholder="model.name"
|
||||
v-text="model.value"
|
||||
@input="input(model, $event.target.value)"
|
||||
/>
|
||||
</OptionTemplate>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import OptionTemplate from "./OptionTemplate.vue";
|
||||
|
||||
const model = defineModel();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
function input(model, newValue) {
|
||||
model.value = newValue;
|
||||
emit('update:modelValue', model);
|
||||
}
|
||||
</script>
|
||||
159
frontend/spa/src/components/ProductsList.vue
Normal file
159
frontend/spa/src/components/ProductsList.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<h2 v-if="categoryName" class="text-lg font-bold mb-5">{{ categoryName }}</h2>
|
||||
|
||||
<template v-if="products.length > 0">
|
||||
<div class="products-grid grid grid-cols-2 gap-x-3 gap-y-3">
|
||||
<RouterLink
|
||||
v-for="(product, index) in products"
|
||||
:key="product.id"
|
||||
class="radius-box bg-base-100 shadow-sm p-2"
|
||||
:to="`/product/${product.id}`"
|
||||
@click="productClick(product, index)"
|
||||
>
|
||||
<ProductImageSwiper :images="product.images"/>
|
||||
<PriceTitle :product="product"/>
|
||||
</RouterLink>
|
||||
<div ref="bottom" style="height: 1px;"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingMore" class="text-center mt-5">
|
||||
<span class="loading loading-spinner loading-md"></span> Загрузка товаров...
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasMore === false" class="text-xs text-center mt-4 pt-4 mb-2 border-t">
|
||||
{{ settings.texts.text_no_more_products }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="isLoading === true"
|
||||
class="grid grid-cols-2 gap-x-6 gap-y-10">
|
||||
<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>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NoProducts v-else/>
|
||||
</div>
|
||||
|
||||
<div v-if="false" class="fixed z-50 w-full opacity-90" style="bottom: calc(var(--tg-safe-area-inset-bottom, 0px) + 80px);">
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
@click="showFilters"
|
||||
class="btn shadow-xl relative"
|
||||
:class="{'btn-accent' : filtersStore.isFiltersChanged}"
|
||||
>
|
||||
<IconFunnel/>
|
||||
Фильтры
|
||||
<span v-if="filtersStore.isFiltersChanged" class="status status-primary"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NoProducts from "@/components/NoProducts.vue";
|
||||
import ProductImageSwiper from "@/components/ProductImageSwiper.vue";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import {ref} from "vue";
|
||||
import {useIntersectionObserver} from '@vueuse/core';
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import IconFunnel from "@/components/Icons/IconFunnel.vue";
|
||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||
import {useRouter} from "vue-router";
|
||||
import PriceTitle from "@/components/ProductItem/PriceTitle.vue";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const router = useRouter();
|
||||
const haptic = useHapticFeedback();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const settings = useSettingsStore();
|
||||
const filtersStore = useProductFiltersStore();
|
||||
const bottom = ref(null);
|
||||
|
||||
const emits = defineEmits(['loadMore']);
|
||||
|
||||
const props = defineProps({
|
||||
products: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
hasMore: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
categoryName: {
|
||||
type: String,
|
||||
default: () => '',
|
||||
},
|
||||
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
isLoadingMore: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
|
||||
function productClick(product, index) {
|
||||
haptic.selectionChanged();
|
||||
yaMetrika.dataLayerPush({
|
||||
"ecommerce": {
|
||||
"currencyCode": settings.currency_code,
|
||||
"click": {
|
||||
"products": [
|
||||
{
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"price": product.final_price_numeric,
|
||||
"brand": product.manufacturer_name,
|
||||
"category": product.category_name,
|
||||
"list": "Главная страница",
|
||||
"position": index,
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useIntersectionObserver(
|
||||
bottom,
|
||||
([entry]) => {
|
||||
console.debug('[Product List]: Check Intersection: ', entry?.isIntersecting);
|
||||
if (entry?.isIntersecting === true
|
||||
&& props.hasMore === true
|
||||
&& props.isLoading === false
|
||||
&& props.isLoadingMore === false
|
||||
) {
|
||||
console.debug('[Product List]: Send Load More signal');
|
||||
emits('loadMore');
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null, // viewport
|
||||
rootMargin: '200px 0px 400px 0px', // top right bottom left
|
||||
threshold: 0, // срабатывает, как только элемент пересекает viewport
|
||||
}
|
||||
);
|
||||
|
||||
function showFilters() {
|
||||
haptic.impactOccurred('soft');
|
||||
router.push({name: 'filters'});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-grid-card {
|
||||
|
||||
}
|
||||
</style>
|
||||
58
frontend/spa/src/components/Quantity.vue
Normal file
58
frontend/spa/src/components/Quantity.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="flex items-center text-center">
|
||||
<button class="btn" :class="btnClassList" @click="dec" :disabled="disabled">-</button>
|
||||
<div class="w-10 h-10 flex items-center justify-center font-bold">{{ model }}</div>
|
||||
<button class="btn" :class="btnClassList" @click="inc" :disabled="disabled">+</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const model = defineModel();
|
||||
const props = defineProps({
|
||||
max: Number,
|
||||
size: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
const btnClassList = computed(() => {
|
||||
let classList = ['btn'];
|
||||
if (props.size) {
|
||||
classList.push(`btn-${props.size}`);
|
||||
}
|
||||
return classList;
|
||||
});
|
||||
|
||||
function inc() {
|
||||
if (props.disabled) return;
|
||||
|
||||
haptic.selectionChanged();
|
||||
|
||||
if (props.max && model.value + 1 > props.max) {
|
||||
model.value = props.max;
|
||||
return;
|
||||
}
|
||||
|
||||
model.value++;
|
||||
}
|
||||
|
||||
function dec() {
|
||||
if (props.disabled) return;
|
||||
|
||||
haptic.selectionChanged();
|
||||
|
||||
if (model.value - 1 >= 1) {
|
||||
model.value--;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
41
frontend/spa/src/components/SearchInput.vue
Normal file
41
frontend/spa/src/components/SearchInput.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="search-wrapper w-full">
|
||||
<label class="input w-full">
|
||||
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.3-4.3"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<input
|
||||
readonly
|
||||
class="grow input-lg w-full"
|
||||
placeholder="Поиск по магазину"
|
||||
@click="showSearchPage"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useRouter} from "vue-router";
|
||||
import {useSearchStore} from "@/stores/SearchStore.js";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const router = useRouter();
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
function showSearchPage() {
|
||||
router.push({name: 'search'});
|
||||
useSearchStore().reset();
|
||||
haptic.impactOccurred('medium');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
113
frontend/spa/src/components/SingleProductImageSwiper.vue
Normal file
113
frontend/spa/src/components/SingleProductImageSwiper.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<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";
|
||||
import {useRouter} from "vue-router";
|
||||
|
||||
const emit = defineEmits(['onLoad']);
|
||||
|
||||
const props = defineProps({
|
||||
images: {
|
||||
type: Array,
|
||||
default: [],
|
||||
}
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
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>
|
||||
209
frontend/spa/src/components/Slider.vue
Normal file
209
frontend/spa/src/components/Slider.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div v-if="config.slides.length > 0" class="app-banner" :class="classList">
|
||||
<Swiper
|
||||
:effect="slideEffect"
|
||||
class="mainpage-slider select-none"
|
||||
:slides-per-view="1"
|
||||
:space-between="config.space_between"
|
||||
:pagination="pagination"
|
||||
:lazy="true"
|
||||
:modules="modules"
|
||||
:scrollbar="scrollbar"
|
||||
:free-mode="config.free_mode"
|
||||
:loop="config.loop"
|
||||
:autoplay="autoplay"
|
||||
@swiper="onSwiper"
|
||||
@slideChange="onSlideChange"
|
||||
@sliderMove="hapticScroll"
|
||||
>
|
||||
<SwiperSlide v-for="slide in config.slides" :key="slide.id">
|
||||
<RouterLink
|
||||
v-if="slide?.link?.type === 'category'"
|
||||
:to="{name: 'product.categories.show', params: {category_id: slide.link.value.category_id}}"
|
||||
@click="sliderClick(slide)"
|
||||
>
|
||||
<img :src="slide.image" :alt="slide.title" loading="lazy">
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
v-else-if="slide?.link?.type === 'product'"
|
||||
:to="{name: 'product.show', params: {id: slide.link.value.product_id}}"
|
||||
@click="sliderClick(slide)"
|
||||
>
|
||||
<img :src="slide.image" :alt="slide.title" loading="lazy">
|
||||
</RouterLink>
|
||||
|
||||
<img
|
||||
v-else-if="slide?.link?.type === 'url'"
|
||||
:src="slide.image"
|
||||
:alt="slide.title"
|
||||
loading="lazy"
|
||||
@click="openExternalLink(slide.link.value.url, slide)"
|
||||
>
|
||||
|
||||
<img v-else :src="slide.image" :alt="slide.title" loading="lazy"/>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div role="alert" class="alert alert-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" 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>
|
||||
<span>У слайдера не загружены изображения.</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {Swiper, SwiperSlide} from 'swiper/vue';
|
||||
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {Autoplay, EffectCards, EffectCoverflow, EffectCube, EffectFlip, Scrollbar} from 'swiper/modules';
|
||||
import {computed, onMounted} from "vue";
|
||||
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
goalName: {
|
||||
type: String,
|
||||
default: null,
|
||||
}
|
||||
});
|
||||
|
||||
const hapticScroll = useHapticScroll();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const modules = [
|
||||
Autoplay,
|
||||
EffectCards,
|
||||
EffectFlip,
|
||||
EffectCube,
|
||||
Scrollbar,
|
||||
EffectCoverflow,
|
||||
];
|
||||
|
||||
const classList = computed(() => {
|
||||
if (props.config.effect === 'cards') {
|
||||
return ['px-8'];
|
||||
}
|
||||
|
||||
if (props.config.effect === 'flip') {
|
||||
return ['px-4', 'pb-4', 'pt-4'];
|
||||
}
|
||||
|
||||
if (props.config.effect === 'cube') {
|
||||
return ['px-4', 'pb-10'];
|
||||
}
|
||||
|
||||
return ['px-4'];
|
||||
});
|
||||
|
||||
const onSwiper = (swiper) => {
|
||||
|
||||
};
|
||||
const onSlideChange = () => {
|
||||
|
||||
};
|
||||
|
||||
const slideEffect = computed(() => {
|
||||
if (props.config.effect === 'slide') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return props.config.effect;
|
||||
});
|
||||
|
||||
const pagination = computed(() => {
|
||||
if (props.config.pagination) {
|
||||
return {
|
||||
clickable: true, dynamicBullets: false,
|
||||
};
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const scrollbar = computed(() => {
|
||||
if (props.config.scrollbar) {
|
||||
return {
|
||||
hide: true,
|
||||
};
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const autoplay = computed(() => {
|
||||
if (props.config.autoplay) {
|
||||
return {
|
||||
delay: 3000,
|
||||
reverseDirection: false,
|
||||
};
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
function sliderClick(slide) {
|
||||
if (props.goalName) {
|
||||
yaMetrika.reachGoal(props.goalName, {
|
||||
banner: slide.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openExternalLink(link, slide) {
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.SLIDER_HOME_CLICK, {
|
||||
banner: slide.title,
|
||||
});
|
||||
|
||||
window.Telegram.WebApp.openLink(link, {try_instant_view: false});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.debug('[Mainpage Slider] Init with config: ', props.config);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.app-banner {
|
||||
aspect-ratio: 740 / 400;
|
||||
}
|
||||
|
||||
.app-banner .swiper {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-banner .swiper-horizontal > .swiper-pagination-bullets {
|
||||
bottom: -20px;
|
||||
}
|
||||
|
||||
.app-banner .swiper-horizontal .swiper-slide {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-banner .swiper-horizontal .swiper-slide img {
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
|
||||
.app-banner .mainpage-slider .swiper-pagination-bullet {
|
||||
background-color: var(--color-neutral-content); /* неактивные точки */
|
||||
opacity: 0.6; /* чуть прозрачнее, чтобы не отвлекали */
|
||||
}
|
||||
|
||||
.app-banner .mainpage-slider .swiper-pagination-bullet-active {
|
||||
background-color: var(--color-primary); /* активная точка */
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
10
frontend/spa/src/components/SwipeToBack.vue
Normal file
10
frontend/spa/src/components/SwipeToBack.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSwipeBack} from "@/composables/useSwipeBack.js";
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user