feat: improve mainpage ui/ux

This commit is contained in:
2025-11-16 01:38:57 +03:00
parent f0837e5c94
commit f5d9d417b3
26 changed files with 222 additions and 184 deletions

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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')"
> >

View File

@@ -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')"
> >

View File

@@ -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')"
> >

View File

@@ -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')"
> >

View File

@@ -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));
} }

View File

@@ -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]">
<div class="tw:bg-white tw:rounded-lg tw:p-6 tw:border tw:border-slate-200">
<component <component
:is="blockToComponentMap[element.type]" :is="blockToComponentMap[element.type]"
:value="element" :value="element"
@onRemove="removeBlock(index)" @onRemove="removeBlock(index)"
@onShowSettings="showDrawer(index)" @onShowSettings="showDrawer(index)"
/> />
</div>
</template> </template>
<div v-else>неподдерживаемый блок</div> <div v-else>неподдерживаемый блок</div>

View File

@@ -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,
}
}, },
}, },
}, },

View File

@@ -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 }">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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;
padding-top: 15px;
}
`],
pagination: {
clickable: true, clickable: true,
}, dynamicBullets: false,
} };
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>

View 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>

View File

@@ -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>

View File

@@ -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 {

View 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;
}
};
}

View File

@@ -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*/
); );
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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([