feat: improve mainpage ui/ux
This commit is contained in:
@@ -46,3 +46,7 @@ legend.p-fieldset-legend {
|
||||
width: auto;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.telecart-admin-app {
|
||||
color: var(--color-slate-700);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<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>
|
||||
<div class="tw:flex tw:justify-between tw:items-start">
|
||||
<div>
|
||||
<h3 class="p-card-title">
|
||||
@@ -15,7 +14,6 @@
|
||||
severity="contrast"
|
||||
rounded
|
||||
text
|
||||
size="large"
|
||||
@click="$emit('onShowSettings')"
|
||||
/>
|
||||
|
||||
@@ -24,7 +22,6 @@
|
||||
severity="danger"
|
||||
rounded
|
||||
text
|
||||
size="large"
|
||||
@click="confirmedRemove($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseBlock
|
||||
:title="`Топ категорий - ${value.title || 'Без заголовока'}`"
|
||||
:title="`Топ категорий - ${value.title || 'Без заголовка'}`"
|
||||
@onRemove="$emit('onRemove')"
|
||||
@onShowSettings="$emit('onShowSettings')"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseBlock
|
||||
:title="`Карусель товаров - ${value.title || 'Без заголовока'}`"
|
||||
:title="`Карусель товаров - ${value.title || 'Без заголовка'}`"
|
||||
@onRemove="$emit('onRemove')"
|
||||
@onShowSettings="$emit('onShowSettings')"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseBlock
|
||||
:title="`Лента товаров - ${value.title || 'Без заголовока'}`"
|
||||
:title="`Лента товаров - ${value.title || 'Без заголовка'}`"
|
||||
@onRemove="$emit('onRemove')"
|
||||
@onShowSettings="$emit('onShowSettings')"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseBlock
|
||||
:title="`Слайдер - ${value.title || 'Без заголовока'}`"
|
||||
:title="`Слайдер - ${value.title || 'Без заголовка'}`"
|
||||
@onRemove="$emit('onRemove')"
|
||||
@onShowSettings="$emit('onShowSettings')"
|
||||
>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
@cancel="$emit('cancel')"
|
||||
>
|
||||
<template #default>
|
||||
<pre>{{ draft }}</pre>
|
||||
<div class="tw:space-y-6">
|
||||
<Panel header="Основные настройки">
|
||||
<div class="tw:space-y-6">
|
||||
@@ -111,6 +110,17 @@
|
||||
Задержка между переходами в миллисекундах. Минимум 1000, максимум 10000.
|
||||
</template>
|
||||
</FormItem>
|
||||
|
||||
<!-- Свободный режим -->
|
||||
<FormItem label="Свободный режим">
|
||||
<template #default>
|
||||
<ToggleSwitch v-model="freeMode"/>
|
||||
</template>
|
||||
<template #help>
|
||||
Включает «свободный режим» прокрутки слайдов без привязки к конкретным индексам.
|
||||
Слайды прокручиваются плавно, скорость зависит от инерции свайпа.
|
||||
</template>
|
||||
</FormItem>
|
||||
</div>
|
||||
</Panel>
|
||||
</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() {
|
||||
model.value = JSON.parse(JSON.stringify(draft.value));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="tw:flex tw:gap-4">
|
||||
<section class="tw:w-1/3 tw:p-4 tw:bg-gray-100 tw:rounded-xl">
|
||||
<header class="tw:font-bold tw:uppercase">Доступные блоки</header>
|
||||
<section class="tw:w-1/3 tw:p-4 tw:bg-slate-100 tw:rounded-lg">
|
||||
<header class="tw:font-semibold tw:text-lg tw:uppercase">Доступные блоки</header>
|
||||
<div class="tw:mb-6">Перетяните блок, чтобы добавить на главную страницу</div>
|
||||
|
||||
<draggable
|
||||
@@ -28,25 +28,27 @@
|
||||
</draggable>
|
||||
</section>
|
||||
|
||||
<section class="tw:w-full tw:rounded-xl tw:p-4 tw:bg-gray-100 tw:min-h-[400px] tw:relative">
|
||||
<header class="tw:font-bold tw:uppercase">Блоки на главной странице</header>
|
||||
<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-semibold tw:text-lg tw:uppercase">Блоки на главной странице</header>
|
||||
<div class="tw:mb-6">Эти блоки будут отображены на главной странице в том же порядке. Перетяните блок, если хотите изменить порядок.</div>
|
||||
|
||||
<draggable
|
||||
v-model="settings.items.mainpage_blocks"
|
||||
:group="{ name: 'blocks', put: true }"
|
||||
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"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<template v-if="blockToComponentMap[element.type]">
|
||||
<component
|
||||
:is="blockToComponentMap[element.type]"
|
||||
:value="element"
|
||||
@onRemove="removeBlock(index)"
|
||||
@onShowSettings="showDrawer(index)"
|
||||
/>
|
||||
<div class="tw:bg-white tw:rounded-lg tw:p-6 tw:border tw:border-slate-200">
|
||||
<component
|
||||
:is="blockToComponentMap[element.type]"
|
||||
:value="element"
|
||||
@onRemove="removeBlock(index)"
|
||||
@onShowSettings="showDrawer(index)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else>неподдерживаемый блок</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ export const blocks = [
|
||||
pagination: true,
|
||||
scrollbar: false,
|
||||
free_mode: false,
|
||||
space_between: 30,
|
||||
space_between: 5,
|
||||
autoplay: false,
|
||||
loop: false,
|
||||
slides: [],
|
||||
@@ -73,6 +73,9 @@ export const blocks = [
|
||||
slides_per_view: null,
|
||||
space_between: null,
|
||||
autoplay: false,
|
||||
freemode: {
|
||||
enabled: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
<div class="drawer-content">
|
||||
<div class="app-container h-full">
|
||||
<header class="app-header w-full" v-if="platform === 'ios'"></header>
|
||||
|
||||
<Navbar @drawer="toggleDrawer"/>
|
||||
<header class="app-header bg-neutral text-neutral-content w-full" v-if="platform === 'ios'"></header>
|
||||
|
||||
<section class="telecart-main-section">
|
||||
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/>
|
||||
|
||||
<Navbar @drawer="toggleDrawer"/>
|
||||
|
||||
<AppDebugMessage v-if="settings.app_debug"/>
|
||||
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<a
|
||||
href="#"
|
||||
:key="category.id"
|
||||
class="py-2 px-4 flex items-center mb-3"
|
||||
class="flex items-center"
|
||||
@click.prevent="$emit('onSelect', category)"
|
||||
>
|
||||
<div class="avatar">
|
||||
@@ -11,7 +11,7 @@
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<RouterLink
|
||||
:to="{name: 'product.categories.show',
|
||||
params: { category_id: block.data.category_id }}"
|
||||
class="btn btn-outline btn-xs"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
{{ block.data.all_text || 'Смотреть всё' }}
|
||||
</RouterLink>
|
||||
@@ -24,6 +24,7 @@
|
||||
:autoplay="block.data?.carousel?.autoplay || false"
|
||||
:freeMode="freeModeSettings"
|
||||
:lazy="true"
|
||||
@sliderMove="hapticScroll"
|
||||
>
|
||||
<SwiperSlide v-for="product in block.data.products.data" :key="product.id">
|
||||
<RouterLink
|
||||
@@ -31,14 +32,14 @@
|
||||
@click="slideClick(product)"
|
||||
>
|
||||
<div class="text-center">
|
||||
<img :src="product.images[0].url" :alt="product.name" loading="lazy">
|
||||
<h3 class="product-title mt-4 text-sm">{{ product.name }}</h3>
|
||||
<img :src="product.images[0].url" :alt="product.name" loading="lazy" class="product-image"/>
|
||||
<ProductTitle :title="product.name"/>
|
||||
|
||||
<div v-if="product.special" class="mt-1">
|
||||
<p class="text-xs line-through mr-2">{{ product.price }}</p>
|
||||
<p class="text-lg font-medium">{{ product.special }}</p>
|
||||
<span class="text-xs line-through mr-2">{{ product.price }}</span>
|
||||
<span class="text-base font-medium">{{ product.special }}</span>
|
||||
</div>
|
||||
<p v-else class="mt-1 text-lg font-medium">{{ product.price }}</p>
|
||||
<p v-else class="font-medium">{{ product.price }}</p>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</SwiperSlide>
|
||||
@@ -50,13 +51,12 @@
|
||||
<script setup>
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
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 freeModeSettings = {
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
block: {
|
||||
type: Object,
|
||||
@@ -64,6 +64,10 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const freeModeSettings = {
|
||||
enabled: props.block.data?.carousel?.freemode?.enabled || false,
|
||||
};
|
||||
|
||||
function slideClick(product) {
|
||||
if (props.block.goal_name) {
|
||||
yaMetrika.reachGoal(props.block.goal_name, {
|
||||
@@ -73,3 +77,9 @@ function slideClick(product) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-image {
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,8 @@
|
||||
<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>
|
||||
<section class="px-4">
|
||||
<header class="mb-2">
|
||||
<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
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<div
|
||||
v-if="blocks.blocks?.length > 0"
|
||||
v-for="(block, index) in blocks.blocks"
|
||||
class="mb-5"
|
||||
>
|
||||
<template v-if="blockTypeToComponentMap[block.type]">
|
||||
<component
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 v-if="false" class="dropdown">
|
||||
<button class="btn btn-ghost btn-circle" @click="toggleDrawer">
|
||||
@@ -9,10 +9,10 @@
|
||||
</div>
|
||||
|
||||
<div class="navbar-center">
|
||||
<RouterLink :to="{name: 'home'}" class="text-xl flex items-center">
|
||||
<div class="avatar mr-2">
|
||||
<div v-if="settings.app_icon" class="h-8 rounded-full bg-base-100">
|
||||
<img :src="settings.app_icon" class="h-8" alt=""/>
|
||||
<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>
|
||||
|
||||
@@ -47,10 +47,3 @@ function toggleDrawer() {
|
||||
emits('drawer');
|
||||
}
|
||||
</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>
|
||||
<swiper-container ref="swiperEl" init="false" pagination-dynamic-bullets="true">
|
||||
<swiper-slide
|
||||
<Swiper
|
||||
:lazy="true"
|
||||
:modules="modules"
|
||||
:pagination="pagination"
|
||||
@sliderMove="hapticScroll"
|
||||
class="radius-box"
|
||||
>
|
||||
<SwiperSlide
|
||||
v-for="image in images"
|
||||
:key="image.url"
|
||||
class="bg-base-100 overflow-hidden"
|
||||
style="aspect-ratio:1/1; border-radius:12px;"
|
||||
>
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="image.alt"
|
||||
loading="lazy"
|
||||
class="w-full h-full"
|
||||
style="object-fit: contain"
|
||||
class="w-full h-full radius-box"
|
||||
/>
|
||||
</swiper-slide>
|
||||
</swiper-container>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {onActivated, onMounted, onUnmounted, ref} from "vue";
|
||||
import {Swiper, SwiperSlide} from 'swiper/vue';
|
||||
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||
|
||||
const props = defineProps({
|
||||
images: {
|
||||
@@ -27,61 +31,16 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const params = {
|
||||
injectStyles: [`
|
||||
.swiper-pagination {
|
||||
position: relative;
|
||||
padding-top: 15px;
|
||||
}
|
||||
`],
|
||||
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()
|
||||
})
|
||||
})
|
||||
const modules = [];
|
||||
const hapticScroll = useHapticScroll();
|
||||
const pagination = {
|
||||
clickable: true,
|
||||
dynamicBullets: false,
|
||||
};
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<template v-if="products.length > 0">
|
||||
<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
|
||||
v-for="(product, index) in products"
|
||||
@@ -15,7 +15,7 @@
|
||||
@click="productClick(product, index)"
|
||||
>
|
||||
<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">
|
||||
<p class="text-xs line-through mr-2">{{ product.price }}</p>
|
||||
@@ -48,7 +48,7 @@
|
||||
<NoProducts v-else/>
|
||||
</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">
|
||||
<button
|
||||
@click="showFilters"
|
||||
@@ -74,6 +74,7 @@ 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 ProductTitle from "@/components/ProductItem/ProductTitle.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const haptic = window.Telegram.WebApp.HapticFeedback;
|
||||
@@ -156,12 +157,3 @@ function showFilters() {
|
||||
router.push({name: 'filters'});
|
||||
}
|
||||
</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"
|
||||
@swiper="onSwiper"
|
||||
@slideChange="onSlideChange"
|
||||
@sliderMove="hapticScroll"
|
||||
>
|
||||
<SwiperSlide v-for="slide in config.slides" :key="slide.id">
|
||||
<RouterLink
|
||||
@@ -58,12 +59,11 @@
|
||||
|
||||
<script setup>
|
||||
import {Swiper, SwiperSlide} from 'swiper/vue';
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/navigation';
|
||||
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: {
|
||||
@@ -77,6 +77,7 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const hapticScroll = useHapticScroll(20, 'impactOccurred', 'soft');
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const modules = [
|
||||
Autoplay,
|
||||
@@ -182,8 +183,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.app-banner .swiper-horizontal > .swiper-pagination-bullets {
|
||||
position: relative;
|
||||
bottom: 10px;
|
||||
bottom: -20px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
:root {
|
||||
--swiper-pagination-bullet-horizontal-gap: 1px;
|
||||
--swiper-pagination-bullet-size: 6px;
|
||||
--swiper-pagination-color: #777;
|
||||
--swiper-pagination-bottom: -5px;
|
||||
}
|
||||
|
||||
html {
|
||||
--swiper-pagination-color: var(--color-primary);
|
||||
--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;
|
||||
--product_list_title_max_lines: 2;
|
||||
--tc-navbar-min-height: 3rem;
|
||||
}
|
||||
|
||||
@@ -31,7 +34,10 @@ html {
|
||||
|
||||
.app-container {
|
||||
/*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-right: var(--tg-safe-area-inset-right, 0px);
|
||||
}
|
||||
@@ -43,7 +49,6 @@ html {
|
||||
.app-header {
|
||||
z-index: 60;
|
||||
position: fixed;
|
||||
background: var(--color-primary);
|
||||
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));
|
||||
@@ -59,8 +64,8 @@ html {
|
||||
padding-top: calc(
|
||||
var(--tg-content-safe-area-inset-top, 0rem)
|
||||
+ var(--tg-safe-area-inset-top, 0rem)
|
||||
+ var(--tc-navbar-min-height)
|
||||
+ 1rem
|
||||
/*+ var(--tc-navbar-min-height)*/
|
||||
/*+ 1rem*/
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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">
|
||||
<h2 class="text-3xl mb-5">Категории</h2>
|
||||
<div class="px-4 mt-4">
|
||||
<h2 class="font-bold uppercase mb-4">Категории</h2>
|
||||
|
||||
<div v-if="categoriesStore.isLoading" class="flex flex-col gap-4">
|
||||
<div class="skeleton h-14 w-full"></div>
|
||||
@@ -35,16 +35,18 @@
|
||||
name="stagger"
|
||||
tag="ul"
|
||||
appear
|
||||
class="space-y-4"
|
||||
>
|
||||
<li
|
||||
v-for="(category, i) in categories"
|
||||
:key="category.id"
|
||||
:style="{ '--i': i }"
|
||||
|
||||
>
|
||||
<CategoryItem
|
||||
:category="category"
|
||||
@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>
|
||||
</TransitionGroup>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="goodsRef" class="pb-20">
|
||||
<div ref="goodsRef" class="space-y-4 mt-4">
|
||||
<MainPage/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<swiper-container ref="swiperEl" init="false">
|
||||
<swiper-slide
|
||||
<Swiper
|
||||
:lazy="true"
|
||||
:modules="modules"
|
||||
:pagination="pagination"
|
||||
@sliderMove="hapticScroll"
|
||||
>
|
||||
<SwiperSlide
|
||||
v-for="(image, index) in product.images"
|
||||
lazy="true"
|
||||
:key="image.url"
|
||||
>
|
||||
<img
|
||||
:src="image.thumbnailURL"
|
||||
:alt="image.alt"
|
||||
loading="lazy"
|
||||
@click="showFullScreen(index)"
|
||||
/>
|
||||
</swiper-slide>
|
||||
</swiper-container>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
|
||||
<FullScreenImageViewer
|
||||
v-if="isFullScreen"
|
||||
@@ -157,7 +163,7 @@
|
||||
|
||||
<script setup>
|
||||
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 {useCartStore} from "../stores/CartStore.js";
|
||||
import Quantity from "../components/Quantity.vue";
|
||||
@@ -169,6 +175,8 @@ import ProductNotFound from "@/components/ProductNotFound.vue";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.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 productId = computed(() => route.params.id);
|
||||
@@ -272,7 +280,11 @@ function setQuantity(newQuantity) {
|
||||
window.Telegram.WebApp.HapticFeedback.selectionChanged();
|
||||
}
|
||||
|
||||
let canVibrate = true;
|
||||
const hapticScroll = useHapticScroll();
|
||||
const pagination = {
|
||||
clickable: true,
|
||||
};
|
||||
const modules = [];
|
||||
|
||||
function onPopState() {
|
||||
if (isFullScreen.value) {
|
||||
@@ -283,8 +295,6 @@ function onPopState() {
|
||||
}
|
||||
}
|
||||
|
||||
const swiperEl = ref(null);
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('popstate', onPopState);
|
||||
});
|
||||
@@ -334,29 +344,5 @@ onMounted(async () => {
|
||||
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="goodsRef" class="pb-10">
|
||||
<div class="px-4 mt-4">
|
||||
<ProductsList
|
||||
:products="productsStore.products.data"
|
||||
: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['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;
|
||||
$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([
|
||||
|
||||
Reference in New Issue
Block a user