feat: improve mainpage ui/ux
This commit is contained in:
@@ -46,3 +46,7 @@ legend.p-fieldset-legend {
|
|||||||
width: auto;
|
width: auto;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.telecart-admin-app {
|
||||||
|
color: var(--color-slate-700);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div>
|
||||||
class="tw:bg-white dark:tw:bg-slate-800 tw:rounded-lg tw:shadow-sm tw:border tw:border-slate-200 dark:tw:border-slate-700 tw:p-6 tw:mb-3">
|
|
||||||
<div class="tw:flex tw:justify-between tw:items-start">
|
<div class="tw:flex tw:justify-between tw:items-start">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="p-card-title">
|
<h3 class="p-card-title">
|
||||||
@@ -15,7 +14,6 @@
|
|||||||
severity="contrast"
|
severity="contrast"
|
||||||
rounded
|
rounded
|
||||||
text
|
text
|
||||||
size="large"
|
|
||||||
@click="$emit('onShowSettings')"
|
@click="$emit('onShowSettings')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -24,7 +22,6 @@
|
|||||||
severity="danger"
|
severity="danger"
|
||||||
rounded
|
rounded
|
||||||
text
|
text
|
||||||
size="large"
|
|
||||||
@click="confirmedRemove($event)"
|
@click="confirmedRemove($event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseBlock
|
<BaseBlock
|
||||||
:title="`Топ категорий - ${value.title || 'Без заголовока'}`"
|
:title="`Топ категорий - ${value.title || 'Без заголовка'}`"
|
||||||
@onRemove="$emit('onRemove')"
|
@onRemove="$emit('onRemove')"
|
||||||
@onShowSettings="$emit('onShowSettings')"
|
@onShowSettings="$emit('onShowSettings')"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseBlock
|
<BaseBlock
|
||||||
:title="`Карусель товаров - ${value.title || 'Без заголовока'}`"
|
:title="`Карусель товаров - ${value.title || 'Без заголовка'}`"
|
||||||
@onRemove="$emit('onRemove')"
|
@onRemove="$emit('onRemove')"
|
||||||
@onShowSettings="$emit('onShowSettings')"
|
@onShowSettings="$emit('onShowSettings')"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseBlock
|
<BaseBlock
|
||||||
:title="`Лента товаров - ${value.title || 'Без заголовока'}`"
|
:title="`Лента товаров - ${value.title || 'Без заголовка'}`"
|
||||||
@onRemove="$emit('onRemove')"
|
@onRemove="$emit('onRemove')"
|
||||||
@onShowSettings="$emit('onShowSettings')"
|
@onShowSettings="$emit('onShowSettings')"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseBlock
|
<BaseBlock
|
||||||
:title="`Слайдер - ${value.title || 'Без заголовока'}`"
|
:title="`Слайдер - ${value.title || 'Без заголовка'}`"
|
||||||
@onRemove="$emit('onRemove')"
|
@onRemove="$emit('onRemove')"
|
||||||
@onShowSettings="$emit('onShowSettings')"
|
@onShowSettings="$emit('onShowSettings')"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
@cancel="$emit('cancel')"
|
@cancel="$emit('cancel')"
|
||||||
>
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
<pre>{{ draft }}</pre>
|
|
||||||
<div class="tw:space-y-6">
|
<div class="tw:space-y-6">
|
||||||
<Panel header="Основные настройки">
|
<Panel header="Основные настройки">
|
||||||
<div class="tw:space-y-6">
|
<div class="tw:space-y-6">
|
||||||
@@ -111,6 +110,17 @@
|
|||||||
Задержка между переходами в миллисекундах. Минимум 1000, максимум 10000.
|
Задержка между переходами в миллисекундах. Минимум 1000, максимум 10000.
|
||||||
</template>
|
</template>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
||||||
|
<!-- Свободный режим -->
|
||||||
|
<FormItem label="Свободный режим">
|
||||||
|
<template #default>
|
||||||
|
<ToggleSwitch v-model="freeMode"/>
|
||||||
|
</template>
|
||||||
|
<template #help>
|
||||||
|
Включает «свободный режим» прокрутки слайдов без привязки к конкретным индексам.
|
||||||
|
Слайды прокручиваются плавно, скорость зависит от инерции свайпа.
|
||||||
|
</template>
|
||||||
|
</FormItem>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,6 +216,26 @@ const autoplayDelay = computed({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const freeMode = computed({
|
||||||
|
get() {
|
||||||
|
const freemode = draft.value.data.carousel?.freemode;
|
||||||
|
if (freemode && typeof freemode === 'object' && freemode.enabled) {
|
||||||
|
return freemode.enabled;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
ensureCarousel();
|
||||||
|
// Убеждаемся, что autoplay - это объект
|
||||||
|
if (!draft.value.data.carousel.freemode) {
|
||||||
|
draft.value.data.carousel.freemode = {};
|
||||||
|
draft.value.data.carousel.freemode.enabled = value;
|
||||||
|
} else {
|
||||||
|
draft.value.data.carousel.freemode.enabled = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function onApply() {
|
function onApply() {
|
||||||
model.value = JSON.parse(JSON.stringify(draft.value));
|
model.value = JSON.parse(JSON.stringify(draft.value));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="tw:flex tw:gap-4">
|
<div class="tw:flex tw:gap-4">
|
||||||
<section class="tw:w-1/3 tw:p-4 tw:bg-gray-100 tw:rounded-xl">
|
<section class="tw:w-1/3 tw:p-4 tw:bg-slate-100 tw:rounded-lg">
|
||||||
<header class="tw:font-bold tw:uppercase">Доступные блоки</header>
|
<header class="tw:font-semibold tw:text-lg tw:uppercase">Доступные блоки</header>
|
||||||
<div class="tw:mb-6">Перетяните блок, чтобы добавить на главную страницу</div>
|
<div class="tw:mb-6">Перетяните блок, чтобы добавить на главную страницу</div>
|
||||||
|
|
||||||
<draggable
|
<draggable
|
||||||
@@ -28,25 +28,27 @@
|
|||||||
</draggable>
|
</draggable>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="tw:w-full tw:rounded-xl tw:p-4 tw:bg-gray-100 tw:min-h-[400px] tw:relative">
|
<section class="tw:w-full tw:rounded-xl tw:p-4 tw:bg-slate-100 tw:min-h-[400px] tw:relative">
|
||||||
<header class="tw:font-bold tw:uppercase">Блоки на главной странице</header>
|
<header class="tw:font-semibold tw:text-lg tw:uppercase">Блоки на главной странице</header>
|
||||||
<div class="tw:mb-6">Эти блоки будут отображены на главной странице в том же порядке. Перетяните блок, если хотите изменить порядок.</div>
|
<div class="tw:mb-6">Эти блоки будут отображены на главной странице в том же порядке. Перетяните блок, если хотите изменить порядок.</div>
|
||||||
|
|
||||||
<draggable
|
<draggable
|
||||||
v-model="settings.items.mainpage_blocks"
|
v-model="settings.items.mainpage_blocks"
|
||||||
:group="{ name: 'blocks', put: true }"
|
:group="{ name: 'blocks', put: true }"
|
||||||
item-key="type"
|
item-key="type"
|
||||||
class="tw:w-full tw:h-full tw:min-h-[400px]"
|
class="tw:w-full tw:h-full tw:min-h-[400px] tw:space-y-2"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
>
|
>
|
||||||
<template #item="{ element, index }">
|
<template #item="{ element, index }">
|
||||||
<template v-if="blockToComponentMap[element.type]">
|
<template v-if="blockToComponentMap[element.type]">
|
||||||
<component
|
<div class="tw:bg-white tw:rounded-lg tw:p-6 tw:border tw:border-slate-200">
|
||||||
:is="blockToComponentMap[element.type]"
|
<component
|
||||||
:value="element"
|
:is="blockToComponentMap[element.type]"
|
||||||
@onRemove="removeBlock(index)"
|
:value="element"
|
||||||
@onShowSettings="showDrawer(index)"
|
@onRemove="removeBlock(index)"
|
||||||
/>
|
@onShowSettings="showDrawer(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else>неподдерживаемый блок</div>
|
<div v-else>неподдерживаемый блок</div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const blocks = [
|
|||||||
pagination: true,
|
pagination: true,
|
||||||
scrollbar: false,
|
scrollbar: false,
|
||||||
free_mode: false,
|
free_mode: false,
|
||||||
space_between: 30,
|
space_between: 5,
|
||||||
autoplay: false,
|
autoplay: false,
|
||||||
loop: false,
|
loop: false,
|
||||||
slides: [],
|
slides: [],
|
||||||
@@ -73,6 +73,9 @@ export const blocks = [
|
|||||||
slides_per_view: null,
|
slides_per_view: null,
|
||||||
space_between: null,
|
space_between: null,
|
||||||
autoplay: false,
|
autoplay: false,
|
||||||
|
freemode: {
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
|
|
||||||
<div class="drawer-content">
|
<div class="drawer-content">
|
||||||
<div class="app-container h-full">
|
<div class="app-container h-full">
|
||||||
<header class="app-header w-full" v-if="platform === 'ios'"></header>
|
<header class="app-header bg-neutral text-neutral-content w-full" v-if="platform === 'ios'"></header>
|
||||||
|
|
||||||
<Navbar @drawer="toggleDrawer"/>
|
|
||||||
|
|
||||||
<section class="telecart-main-section">
|
<section class="telecart-main-section">
|
||||||
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/>
|
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/>
|
||||||
|
|
||||||
|
<Navbar @drawer="toggleDrawer"/>
|
||||||
|
|
||||||
<AppDebugMessage v-if="settings.app_debug"/>
|
<AppDebugMessage v-if="settings.app_debug"/>
|
||||||
|
|
||||||
<RouterView v-slot="{ Component, route }">
|
<RouterView v-slot="{ Component, route }">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
:key="category.id"
|
:key="category.id"
|
||||||
class="py-2 px-4 flex items-center mb-3"
|
class="flex items-center"
|
||||||
@click.prevent="$emit('onSelect', category)"
|
@click.prevent="$emit('onSelect', category)"
|
||||||
>
|
>
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="ml-5 text-lg line-clamp-2">{{ category.name }}</h3>
|
<h3 class="ml-4 text-lg line-clamp-2">{{ category.name }}</h3>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{name: 'product.categories.show',
|
:to="{name: 'product.categories.show',
|
||||||
params: { category_id: block.data.category_id }}"
|
params: { category_id: block.data.category_id }}"
|
||||||
class="btn btn-outline btn-xs"
|
class="btn btn-ghost btn-xs"
|
||||||
>
|
>
|
||||||
{{ block.data.all_text || 'Смотреть всё' }}
|
{{ block.data.all_text || 'Смотреть всё' }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
:autoplay="block.data?.carousel?.autoplay || false"
|
:autoplay="block.data?.carousel?.autoplay || false"
|
||||||
:freeMode="freeModeSettings"
|
:freeMode="freeModeSettings"
|
||||||
:lazy="true"
|
:lazy="true"
|
||||||
|
@sliderMove="hapticScroll"
|
||||||
>
|
>
|
||||||
<SwiperSlide v-for="product in block.data.products.data" :key="product.id">
|
<SwiperSlide v-for="product in block.data.products.data" :key="product.id">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
@@ -31,14 +32,14 @@
|
|||||||
@click="slideClick(product)"
|
@click="slideClick(product)"
|
||||||
>
|
>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<img :src="product.images[0].url" :alt="product.name" loading="lazy">
|
<img :src="product.images[0].url" :alt="product.name" loading="lazy" class="product-image"/>
|
||||||
<h3 class="product-title mt-4 text-sm">{{ product.name }}</h3>
|
<ProductTitle :title="product.name"/>
|
||||||
|
|
||||||
<div v-if="product.special" class="mt-1">
|
<div v-if="product.special" class="mt-1">
|
||||||
<p class="text-xs line-through mr-2">{{ product.price }}</p>
|
<span class="text-xs line-through mr-2">{{ product.price }}</span>
|
||||||
<p class="text-lg font-medium">{{ product.special }}</p>
|
<span class="text-base font-medium">{{ product.special }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="mt-1 text-lg font-medium">{{ product.price }}</p>
|
<p v-else class="font-medium">{{ product.price }}</p>
|
||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
@@ -50,13 +51,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||||
import {Swiper, SwiperSlide} from "swiper/vue";
|
import {Swiper, SwiperSlide} from "swiper/vue";
|
||||||
|
import ProductTitle from "@/components/ProductItem/ProductTitle.vue";
|
||||||
|
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||||
|
|
||||||
|
const hapticScroll = useHapticScroll(20, 'selectionChanged');
|
||||||
const yaMetrika = useYaMetrikaStore();
|
const yaMetrika = useYaMetrikaStore();
|
||||||
|
|
||||||
const freeModeSettings = {
|
|
||||||
enabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
block: {
|
block: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -64,6 +64,10 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const freeModeSettings = {
|
||||||
|
enabled: props.block.data?.carousel?.freemode?.enabled || false,
|
||||||
|
};
|
||||||
|
|
||||||
function slideClick(product) {
|
function slideClick(product) {
|
||||||
if (props.block.goal_name) {
|
if (props.block.goal_name) {
|
||||||
yaMetrika.reachGoal(props.block.goal_name, {
|
yaMetrika.reachGoal(props.block.goal_name, {
|
||||||
@@ -73,3 +77,9 @@ function slideClick(product) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.product-image {
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<section>
|
<section class="px-4">
|
||||||
<header>
|
<header class="mb-2">
|
||||||
<div v-if="block.title" class="font-bold uppercase text-center">{{ block.title }}</div>
|
<div v-if="block.title" class="font-bold uppercase">{{ block.title }}</div>
|
||||||
<div v-if="block.description" class="text-sm text-center">{{ block.description }}</div>
|
<div v-if="block.description" class="text-sm">{{ block.description }}</div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<ProductsList
|
<ProductsList
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
<div
|
<div
|
||||||
v-if="blocks.blocks?.length > 0"
|
v-if="blocks.blocks?.length > 0"
|
||||||
v-for="(block, index) in blocks.blocks"
|
v-for="(block, index) in blocks.blocks"
|
||||||
class="mb-5"
|
|
||||||
>
|
>
|
||||||
<template v-if="blockTypeToComponentMap[block.type]">
|
<template v-if="blockTypeToComponentMap[block.type]">
|
||||||
<component
|
<component
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="telecart-navbar fixed navbar bg-primary text-primary-content z-50 shadow-md" :class="{'pb-0' : platform !== 'ios'}">
|
<div class="navbar bg-neutral text-neutral-content">
|
||||||
<div class="navbar-start">
|
<div class="navbar-start">
|
||||||
<div v-if="false" class="dropdown">
|
<div v-if="false" class="dropdown">
|
||||||
<button class="btn btn-ghost btn-circle" @click="toggleDrawer">
|
<button class="btn btn-ghost btn-circle" @click="toggleDrawer">
|
||||||
@@ -9,10 +9,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar-center">
|
<div class="navbar-center">
|
||||||
<RouterLink :to="{name: 'home'}" class="text-xl flex items-center">
|
<RouterLink :to="{name: 'home'}" class="font-medium text-xl flex items-center">
|
||||||
<div class="avatar mr-2">
|
<div class="mr-2">
|
||||||
<div v-if="settings.app_icon" class="h-8 rounded-full bg-base-100">
|
<div v-if="settings.app_icon" class="max-h-10">
|
||||||
<img :src="settings.app_icon" class="h-8" alt=""/>
|
<img :src="settings.app_icon" class="max-h-10" :alt="settings.app_name"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,10 +47,3 @@ function toggleDrawer() {
|
|||||||
emits('drawer');
|
emits('drawer');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.telecart-navbar {
|
|
||||||
padding-top: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
|
|
||||||
min-height: var(--tc-navbar-min-height);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<swiper-container ref="swiperEl" init="false" pagination-dynamic-bullets="true">
|
<Swiper
|
||||||
<swiper-slide
|
:lazy="true"
|
||||||
|
:modules="modules"
|
||||||
|
:pagination="pagination"
|
||||||
|
@sliderMove="hapticScroll"
|
||||||
|
class="radius-box"
|
||||||
|
>
|
||||||
|
<SwiperSlide
|
||||||
v-for="image in images"
|
v-for="image in images"
|
||||||
:key="image.url"
|
:key="image.url"
|
||||||
class="bg-base-100 overflow-hidden"
|
|
||||||
style="aspect-ratio:1/1; border-radius:12px;"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="image.url"
|
:src="image.url"
|
||||||
:alt="image.alt"
|
:alt="image.alt"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
class="w-full h-full"
|
class="w-full h-full radius-box"
|
||||||
style="object-fit: contain"
|
|
||||||
/>
|
/>
|
||||||
</swiper-slide>
|
</SwiperSlide>
|
||||||
</swiper-container>
|
</Swiper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {onActivated, onMounted, onUnmounted, ref} from "vue";
|
import {Swiper, SwiperSlide} from 'swiper/vue';
|
||||||
|
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
images: {
|
images: {
|
||||||
@@ -27,61 +31,16 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const params = {
|
const modules = [];
|
||||||
injectStyles: [`
|
const hapticScroll = useHapticScroll();
|
||||||
.swiper-pagination {
|
const pagination = {
|
||||||
position: relative;
|
clickable: true,
|
||||||
padding-top: 15px;
|
dynamicBullets: false,
|
||||||
}
|
};
|
||||||
`],
|
|
||||||
pagination: {
|
|
||||||
clickable: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const swiperEl = ref(null);
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const el = swiperEl.value;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
el.addEventListener('swiperactiveindexchange', () => {
|
|
||||||
window.Telegram?.WebApp?.HapticFeedback?.selectionChanged();
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.assign(el, params);
|
|
||||||
el.initialize();
|
|
||||||
|
|
||||||
// 👇 важно, особенно если картинки подгружаются не сразу
|
|
||||||
el.addEventListener('swiperinit', () => {
|
|
||||||
el.swiper.update();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onActivated(() => {
|
|
||||||
const el = swiperEl.value
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
// Если swiper есть, но pagination потерялся — уничтожаем
|
|
||||||
if (el.swiper) {
|
|
||||||
try {
|
|
||||||
el.swiper.destroy(true, true)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to destroy swiper', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Переинициализация с параметрами
|
|
||||||
Object.assign(el, params)
|
|
||||||
el.initialize()
|
|
||||||
|
|
||||||
// Пересчёт пагинации после инициализации
|
|
||||||
el.addEventListener('swiperinit', () => {
|
|
||||||
el.swiper.update()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.radius-box {
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
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 mt-4 text-sm">{{ 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>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="mx-auto max-w-2xl px-4 py-4 pb-14">
|
<div>
|
||||||
<h2 v-if="categoryName" class="text-lg font-bold mb-5 text-center">{{ categoryName }}</h2>
|
<h2 v-if="categoryName" class="text-lg font-bold mb-5 text-center">{{ categoryName }}</h2>
|
||||||
|
|
||||||
<template v-if="products.length > 0">
|
<template v-if="products.length > 0">
|
||||||
<div
|
<div
|
||||||
class="products-grid grid grid-cols-2 gap-x-5 gap-y-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8"
|
class="products-grid grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8"
|
||||||
>
|
>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="(product, index) in products"
|
v-for="(product, index) in products"
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
@click="productClick(product, index)"
|
@click="productClick(product, index)"
|
||||||
>
|
>
|
||||||
<ProductImageSwiper :images="product.images"/>
|
<ProductImageSwiper :images="product.images"/>
|
||||||
<h3 class="product-title mt-4 text-sm">{{ product.name }}</h3>
|
<ProductTitle :title="product.name"/>
|
||||||
|
|
||||||
<div v-if="product.special" class="mt-1">
|
<div v-if="product.special" class="mt-1">
|
||||||
<p class="text-xs line-through mr-2">{{ product.price }}</p>
|
<p class="text-xs line-through mr-2">{{ product.price }}</p>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
<NoProducts v-else/>
|
<NoProducts v-else/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fixed z-50 w-full opacity-90" style="bottom: calc(var(--tg-safe-area-inset-bottom, 0px) + 80px);">
|
<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">
|
<div class="flex justify-center">
|
||||||
<button
|
<button
|
||||||
@click="showFilters"
|
@click="showFilters"
|
||||||
@@ -74,6 +74,7 @@ import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
|||||||
import IconFunnel from "@/components/Icons/IconFunnel.vue";
|
import IconFunnel from "@/components/Icons/IconFunnel.vue";
|
||||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||||
import {useRouter} from "vue-router";
|
import {useRouter} from "vue-router";
|
||||||
|
import ProductTitle from "@/components/ProductItem/ProductTitle.vue";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const haptic = window.Telegram.WebApp.HapticFeedback;
|
const haptic = window.Telegram.WebApp.HapticFeedback;
|
||||||
@@ -156,12 +157,3 @@ function showFilters() {
|
|||||||
router.push({name: 'filters'});
|
router.push({name: 'filters'});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.product-grid-card .product-title {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: var(--product_list_title_max_lines, 3);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
:autoplay="autoplay"
|
:autoplay="autoplay"
|
||||||
@swiper="onSwiper"
|
@swiper="onSwiper"
|
||||||
@slideChange="onSlideChange"
|
@slideChange="onSlideChange"
|
||||||
|
@sliderMove="hapticScroll"
|
||||||
>
|
>
|
||||||
<SwiperSlide v-for="slide in config.slides" :key="slide.id">
|
<SwiperSlide v-for="slide in config.slides" :key="slide.id">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
@@ -58,12 +59,11 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {Swiper, SwiperSlide} from 'swiper/vue';
|
import {Swiper, SwiperSlide} from 'swiper/vue';
|
||||||
import 'swiper/css';
|
|
||||||
import 'swiper/css/navigation';
|
|
||||||
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||||
import {Autoplay, EffectCards, EffectCoverflow, EffectCube, EffectFlip, Scrollbar} from 'swiper/modules';
|
import {Autoplay, EffectCards, EffectCoverflow, EffectCube, EffectFlip, Scrollbar} from 'swiper/modules';
|
||||||
import {computed, onMounted} from "vue";
|
import {computed, onMounted} from "vue";
|
||||||
|
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
config: {
|
config: {
|
||||||
@@ -77,6 +77,7 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hapticScroll = useHapticScroll(20, 'impactOccurred', 'soft');
|
||||||
const yaMetrika = useYaMetrikaStore();
|
const yaMetrika = useYaMetrikaStore();
|
||||||
const modules = [
|
const modules = [
|
||||||
Autoplay,
|
Autoplay,
|
||||||
@@ -182,8 +183,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-banner .swiper-horizontal > .swiper-pagination-bullets {
|
.app-banner .swiper-horizontal > .swiper-pagination-bullets {
|
||||||
position: relative;
|
bottom: -20px;
|
||||||
bottom: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-banner .swiper-horizontal .swiper-slide {
|
.app-banner .swiper-horizontal .swiper-slide {
|
||||||
|
|||||||
35
frontend/spa/src/composables/useHapticScroll.js
Normal file
35
frontend/spa/src/composables/useHapticScroll.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import {ref} from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable для Haptic Feedback по свайпу
|
||||||
|
* @param {number} threshold - минимальное смещение для триггера
|
||||||
|
* @param type impactOccurred или selectionChanged
|
||||||
|
* @param feedback только для impactOccurred: 'light' | 'medium' | 'heavy' | 'soft' | 'rigid'
|
||||||
|
* @returns {(swiper: Swiper) => void}
|
||||||
|
*/
|
||||||
|
export function useHapticScroll(
|
||||||
|
threshold = 20,
|
||||||
|
type = 'impactOccurred',
|
||||||
|
feedback = 'soft'
|
||||||
|
) {
|
||||||
|
const lastTranslate = ref(0);
|
||||||
|
|
||||||
|
return function (
|
||||||
|
swiper
|
||||||
|
) {
|
||||||
|
const current = Math.abs(swiper.translate);
|
||||||
|
const delta = Math.abs(current - lastTranslate.value);
|
||||||
|
|
||||||
|
if (delta > threshold) {
|
||||||
|
const haptic = window.Telegram?.WebApp?.HapticFeedback;
|
||||||
|
if (haptic) {
|
||||||
|
if (type === 'impactOccurred' && haptic.impactOccurred) {
|
||||||
|
haptic.impactOccurred(feedback);
|
||||||
|
} else if (type === 'selectionChanged' && haptic.selectionChanged) {
|
||||||
|
haptic.selectionChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastTranslate.value = current;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,12 +7,15 @@ html, body, #app {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--swiper-pagination-bullet-horizontal-gap: 1px;
|
||||||
|
--swiper-pagination-bullet-size: 6px;
|
||||||
|
--swiper-pagination-color: #777;
|
||||||
|
--swiper-pagination-bottom: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
--swiper-pagination-color: var(--color-primary);
|
--product_list_title_max_lines: 2;
|
||||||
--swiper-navigation-color: var(--color-primary);
|
|
||||||
--swiper-pagination-bullet-inactive-color: var(--color-base-content);
|
|
||||||
--swiper-pagination-fraction-color: var(--color-neutral-content);
|
|
||||||
--product_list_title_max_lines: 1;
|
|
||||||
--tc-navbar-min-height: 3rem;
|
--tc-navbar-min-height: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +34,10 @@ html {
|
|||||||
|
|
||||||
.app-container {
|
.app-container {
|
||||||
/*padding-top: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));*/
|
/*padding-top: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));*/
|
||||||
padding-bottom: var(--tg-safe-area-inset-bottom, 0px);
|
padding-bottom: calc(
|
||||||
|
var(--tg-safe-area-inset-bottom, 0px)
|
||||||
|
+ 72px
|
||||||
|
);
|
||||||
padding-left: var(--tg-safe-area-inset-left, 0px);
|
padding-left: var(--tg-safe-area-inset-left, 0px);
|
||||||
padding-right: var(--tg-safe-area-inset-right, 0px);
|
padding-right: var(--tg-safe-area-inset-right, 0px);
|
||||||
}
|
}
|
||||||
@@ -43,7 +49,6 @@ html {
|
|||||||
.app-header {
|
.app-header {
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background: var(--color-primary);
|
|
||||||
height: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
|
height: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
|
||||||
min-height: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
|
min-height: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
|
||||||
max-height: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
|
max-height: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
|
||||||
@@ -59,8 +64,8 @@ html {
|
|||||||
padding-top: calc(
|
padding-top: calc(
|
||||||
var(--tg-content-safe-area-inset-top, 0rem)
|
var(--tg-content-safe-area-inset-top, 0rem)
|
||||||
+ var(--tg-safe-area-inset-top, 0rem)
|
+ var(--tg-safe-area-inset-top, 0rem)
|
||||||
+ var(--tc-navbar-min-height)
|
/*+ var(--tc-navbar-min-height)*/
|
||||||
+ 1rem
|
/*+ 1rem*/
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-2xl px-4 py-4 sm:px-6 sm:py-24 lg:max-w-7xl lg:px-8 mb-5 pb-20">
|
<div class="px-4 mt-4">
|
||||||
<h2 class="text-3xl mb-5">Категории</h2>
|
<h2 class="font-bold uppercase mb-4">Категории</h2>
|
||||||
|
|
||||||
<div v-if="categoriesStore.isLoading" class="flex flex-col gap-4">
|
<div v-if="categoriesStore.isLoading" class="flex flex-col gap-4">
|
||||||
<div class="skeleton h-14 w-full"></div>
|
<div class="skeleton h-14 w-full"></div>
|
||||||
@@ -35,16 +35,18 @@
|
|||||||
name="stagger"
|
name="stagger"
|
||||||
tag="ul"
|
tag="ul"
|
||||||
appear
|
appear
|
||||||
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-for="(category, i) in categories"
|
v-for="(category, i) in categories"
|
||||||
:key="category.id"
|
:key="category.id"
|
||||||
:style="{ '--i': i }"
|
:style="{ '--i': i }"
|
||||||
|
|
||||||
>
|
>
|
||||||
<CategoryItem
|
<CategoryItem
|
||||||
:category="category"
|
:category="category"
|
||||||
@onSelect="onSelect"
|
@onSelect="onSelect"
|
||||||
class="block px-1 rounded-xl transition hover:bg-base-100/60 active:scale-[0.98] will-change-transform"
|
class="block transition hover:bg-base-100/60 will-change-transform"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="goodsRef" class="pb-20">
|
<div ref="goodsRef" class="space-y-4 mt-4">
|
||||||
<MainPage/>
|
<MainPage/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<swiper-container ref="swiperEl" init="false">
|
<Swiper
|
||||||
<swiper-slide
|
:lazy="true"
|
||||||
|
:modules="modules"
|
||||||
|
:pagination="pagination"
|
||||||
|
@sliderMove="hapticScroll"
|
||||||
|
>
|
||||||
|
<SwiperSlide
|
||||||
v-for="(image, index) in product.images"
|
v-for="(image, index) in product.images"
|
||||||
lazy="true"
|
:key="image.url"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="image.thumbnailURL"
|
:src="image.thumbnailURL"
|
||||||
:alt="image.alt"
|
:alt="image.alt"
|
||||||
|
loading="lazy"
|
||||||
@click="showFullScreen(index)"
|
@click="showFullScreen(index)"
|
||||||
/>
|
/>
|
||||||
</swiper-slide>
|
</SwiperSlide>
|
||||||
</swiper-container>
|
</Swiper>
|
||||||
|
|
||||||
<FullScreenImageViewer
|
<FullScreenImageViewer
|
||||||
v-if="isFullScreen"
|
v-if="isFullScreen"
|
||||||
@@ -157,7 +163,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {computed, onMounted, onUnmounted, 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 Quantity from "../components/Quantity.vue";
|
import Quantity from "../components/Quantity.vue";
|
||||||
@@ -169,6 +175,8 @@ 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 {Swiper, SwiperSlide} from 'swiper/vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const productId = computed(() => route.params.id);
|
const productId = computed(() => route.params.id);
|
||||||
@@ -272,7 +280,11 @@ function setQuantity(newQuantity) {
|
|||||||
window.Telegram.WebApp.HapticFeedback.selectionChanged();
|
window.Telegram.WebApp.HapticFeedback.selectionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
let canVibrate = true;
|
const hapticScroll = useHapticScroll();
|
||||||
|
const pagination = {
|
||||||
|
clickable: true,
|
||||||
|
};
|
||||||
|
const modules = [];
|
||||||
|
|
||||||
function onPopState() {
|
function onPopState() {
|
||||||
if (isFullScreen.value) {
|
if (isFullScreen.value) {
|
||||||
@@ -283,8 +295,6 @@ function onPopState() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const swiperEl = ref(null);
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('popstate', onPopState);
|
window.removeEventListener('popstate', onPopState);
|
||||||
});
|
});
|
||||||
@@ -334,29 +344,5 @@ onMounted(async () => {
|
|||||||
|
|
||||||
|
|
||||||
window.addEventListener('popstate', onPopState);
|
window.addEventListener('popstate', onPopState);
|
||||||
|
|
||||||
swiperEl.value.addEventListener('swiperslidermove', (event) => {
|
|
||||||
if (!canVibrate) return;
|
|
||||||
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
|
|
||||||
canVibrate = false;
|
|
||||||
setTimeout(() => {
|
|
||||||
canVibrate = true;
|
|
||||||
}, 50);
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.assign(swiperEl.value, {
|
|
||||||
injectStyles: [`
|
|
||||||
.swiper-pagination {
|
|
||||||
position: relative;
|
|
||||||
padding-top: 15px;
|
|
||||||
}
|
|
||||||
`],
|
|
||||||
pagination: {
|
|
||||||
dynamicBullets: true,
|
|
||||||
clickable: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
swiperEl.value.initialize();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="goodsRef" class="pb-10">
|
<div class="px-4 mt-4">
|
||||||
<ProductsList
|
<ProductsList
|
||||||
:products="productsStore.products.data"
|
:products="productsStore.products.data"
|
||||||
:hasMore="productsStore.products.meta.hasMore"
|
:hasMore="productsStore.products.meta.hasMore"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class SettingsHandler
|
|||||||
$icons['icon180'] = $this->imageTool->resize($appIcon, 180, 180, 'no_image.png', 'png') . '?_v=' . $hash;
|
$icons['icon180'] = $this->imageTool->resize($appIcon, 180, 180, 'no_image.png', 'png') . '?_v=' . $hash;
|
||||||
$icons['icon152'] = $this->imageTool->resize($appIcon, 152, 152, 'no_image.png', 'png') . '?_v=' . $hash;
|
$icons['icon152'] = $this->imageTool->resize($appIcon, 152, 152, 'no_image.png', 'png') . '?_v=' . $hash;
|
||||||
$icons['icon120'] = $this->imageTool->resize($appIcon, 120, 120, 'no_image.png', 'png') . '?_v=' . $hash;
|
$icons['icon120'] = $this->imageTool->resize($appIcon, 120, 120, 'no_image.png', 'png') . '?_v=' . $hash;
|
||||||
$appIcon = $this->imageTool->resize($appIcon, 32, 32, 'no_image.png', 'png') . '?_v=' . $hash;
|
$appIcon = $this->imageTool->resize($appIcon, null, 64, 'no_image.png', 'png') . '?_v=' . $hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
|
|||||||
Reference in New Issue
Block a user