refactor: move spa to frontend folder

This commit is contained in:
2025-10-27 12:32:38 +03:00
parent 617b5491a1
commit 5681ac592a
77 changed files with 13 additions and 2 deletions

View File

@@ -0,0 +1,9 @@
<template>
<div role="alert" class="alert alert-warning rounded-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span>Включен режим разработчика!</span>
</div>
</template>

View File

@@ -0,0 +1,60 @@
<template>
<div v-if="slides.length > 0" class="app-banner px-4">
<Swiper
class="select-none"
:slides-per-view="1"
:space-between="50"
pagination
:pagination="{ clickable: true }"
@swiper="onSwiper"
@slideChange="onSlideChange"
>
<SwiperSlide v-for="slide in slides" :key="slide.id">
<img :src="slide.image" :alt="slide.title">
</SwiperSlide>
</Swiper>
</div>
</template>
<script setup>
import {Swiper, SwiperSlide} from 'swiper/vue';
import 'swiper/css';
import 'swiper/css/navigation';
import {onMounted, ref} from "vue";
import {fetchBanner} from "@/utils/ftch.js";
const slides = ref([]);
const onSwiper = (swiper) => {
console.log(swiper);
};
const onSlideChange = () => {
console.log('slide change');
};
onMounted(async () => {
const response = await fetchBanner();
slides.value = response.data;
})
</script>
<style>
.app-banner {
overflow: hidden;
}
.app-banner .swiper-horizontal > .swiper-pagination-bullets {
position: relative;
bottom: 0;
}
.app-banner .swiper-horizontal .swiper-slide {
display: flex;
align-items: center;
justify-content: center;
}
.app-banner .swiper-horizontal .swiper-slide > img {
border-radius: var(--radius-box);
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<transition name="fade">
<div
v-if="visible"
class="fixed inset-0 bg-black/50 z-40"
@click.self="close"
>
<transition name="slide-up">
<div
class="fixed bottom-0 left-0 w-full h-[80vh] bg-white rounded-t-2xl shadow-xl z-50 p-4"
>
<slot />
</div>
</transition>
</div>
</transition>
</template>
<script setup>
import {computed} from "vue";
const props = defineProps({
modelValue: Boolean,
})
const emit = defineEmits(['update:modelValue'])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
function close() {
visible.value = false
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.25s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
}
</style>

View File

@@ -0,0 +1,5 @@
<template>
<div class="fixed px-4 pb-10 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col gap-2 border-t-1 border-t-base-300">
<slot></slot>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<template>
<div v-if="isCartBtnShow" class="fixed right-2 bottom-30 z-50 opacity-90">
<div class="indicator">
<span class="indicator-item indicator-top indicator-start badge badge-secondary">{{ cart.productsCount }}</span>
<button class="btn btn-primary btn-xl btn-circle" @click="openCart">
<span v-if="cart.isLoading" class="loading loading-spinner"></span>
<template v-else>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
</template>
</button>
</div>
</div>
</template>
<script setup>
import {computed, onMounted} from "vue";
import {useCartStore} from "@/stores/CartStore.js";
import {useRoute, useRouter} from "vue-router";
const cart = useCartStore();
const router = useRouter();
const route = useRoute();
const isCartBtnShow = computed(() => {
return route.name === 'product.show';
});
function openCart() {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
router.push({name: 'cart'});
}
onMounted(async () => {
await cart.getProducts();
});
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,28 @@
<template>
<div class="flex items-center justify-center p-5 gap-2 flex-wrap">
<RouterLink class="btn btn-md" to="/categories">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
</svg>
Каталог
</RouterLink>
<RouterLink
v-for="category in categoriesStore.topCategories"
class="btn btn-md max-w-[12rem]"
:to="{name: 'product.categories.show', params: {category_id: category.id}}"
@click="onCategoryClick"
>
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{{ category.name }}</span>
</RouterLink>
</div>
</template>
<script setup>
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
const categoriesStore = useCategoriesStore();
function onCategoryClick() {
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
}
</script>

View File

@@ -0,0 +1,27 @@
<template>
<a
href="#"
:key="category.id"
class="py-2 px-4 flex items-center mb-3"
@click.prevent="$emit('onSelect', category)"
>
<div class="avatar">
<div class="w-8 h-8 rounded">
<img :src="category.image" :alt="category.name" loading="lazy" width="30" height="30" class="bg-base-400"/>
</div>
</div>
<h3 class="ml-5 text-lg line-clamp-2">{{ category.name }}</h3>
</a>
</template>
<script setup>
const props = defineProps({
category: {
type: Object,
required: true,
}
});
const emit = defineEmits(["onSelect"]);
</script>

View File

@@ -0,0 +1,100 @@
<template>
<div class="telecart-dock fixed bottom-0 w-full z-50 px-10">
<div
class="telecart-dock-inner flex justify-around items-center bg-base-300/10 h-full backdrop-blur-md border-base-300/90 border">
<RouterLink
:to="{name: 'home'}"
:class="{'active': route.name === 'home'}"
class="telecart-dock-item"
@click="onDockItemClick"
>
<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g fill="currentColor" stroke-linejoin="miter" stroke-linecap="butt">
<polyline points="1 11 12 2 23 11" fill="none" stroke="currentColor" stroke-miterlimit="10"
stroke-width="2"></polyline>
<path d="m5,13v7c0,1.105.895,2,2,2h10c1.105,0,2-.895,2-2v-7" fill="none" stroke="currentColor"
stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></path>
<line x1="12" y1="22" x2="12" y2="18" fill="none" stroke="currentColor" stroke-linecap="square"
stroke-miterlimit="10" stroke-width="2"></line>
</g>
</svg>
<span class="dock-label">Главная</span>
</RouterLink>
<RouterLink
:to="{name: 'search'}"
:class="{'active': route.name === 'search'}"
class="telecart-dock-item"
@click="onDockItemClick"
>
<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/>
</svg>
<span class="dock-label">Поиск</span>
</RouterLink>
<RouterLink
v-if="settings.store_enabled"
:to="{name: 'cart'}"
:class="{'active': route.name === 'cart'}"
class="telecart-dock-item"
@click="onDockItemClick"
>
<div class="indicator">
<span class="indicator-item indicator-end badge badge-secondary badge-xs">{{ cart.productsCount }}</span>
<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/>
</svg>
</div>
<span class="dock-label">Корзина</span>
</RouterLink>
</div>
</div>
</template>
<script setup>
import {useRoute} from "vue-router";
import {useCartStore} from "@/stores/CartStore.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
const route = useRoute();
const cart = useCartStore();
const settings = useSettingsStore();
const haptic = window.Telegram.WebApp.HapticFeedback;
function onDockItemClick() {
haptic.selectionChanged();
}
</script>
<style scoped>
.telecart-dock {
padding-bottom: calc(var(--tg-safe-area-inset-bottom, 0px) + 5px);
height: calc(70px + var(--tg-safe-area-inset-bottom, 0px));
}
.telecart-dock-inner {
border-radius: var(--radius-field);
border-width: var(--border);
border-style: solid;
}
.telecart-dock-item {
display: flex;
flex-direction: column;
align-items: center;
border-radius: var(--radius-field);
padding: 5px 13px;
min-width: 90px;
}
.telecart-dock-item.active {
background-color: var(--color-primary);
backdrop-filter: blur(var(--blur-sm));
color: var(--color-primary-content);
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<fieldset class="fieldset mb-0">
<input
:type="type"
:inputmode="inputMode"
class="input input-lg w-full"
:class="error ? 'input-error' : ''"
:placeholder="placeholder"
v-model="model"
@input="$emit('clearError')"
:maxlength="maxlength"
/>
<p v-if="error" class="label text-error">{{ error }}</p>
</fieldset>
</template>
<script setup lang="ts">
import {computed} from "vue";
const model = defineModel();
const props = defineProps({
error: {
type: String,
default: null,
},
placeholder: {
type: String,
default: null,
},
type: {
type: String,
default: 'text',
},
maxlength: {
type: Number,
default: 1000,
}
});
const emits = defineEmits(['clearError']);
const inputMode = computed(() => {
switch (props.type) {
case 'email': return 'email';
case 'tel': return 'tel';
case 'number': return 'numeric';
default: return 'text';
}
});
</script>

View File

@@ -0,0 +1,36 @@
<template>
<fieldset class="fieldset mb-0">
<textarea
class="input input-lg w-full h-50"
:class="error ? 'input-error' : ''"
:placeholder="placeholder"
v-model="model"
@input="$emit('clearError')"
rows="8"
:maxlength="maxlength"
/>
<p v-if="error" class="label">{{ error }}</p>
</fieldset>
</template>
<script setup lang="ts">
const model = defineModel();
const props = defineProps({
error: {
type: String,
default: null,
},
placeholder: {
type: String,
default: null,
},
maxlength: {
type: Number,
default: 1000,
}
});
const emits = defineEmits(['clearError']);
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div class="fullscreen-image-viewer fixed z-200 top-0 inset-0 flex justify-center align-center flex-col h-full bg-black">
<Swiper
:zoom="true"
:navigation="true"
:pagination="{
type: 'fraction',
}"
:initialSlide="activeIndex"
:modules="[Zoom, Navigation, Pagination]"
class="mySwiper w-full h-full"
@slider-move="vibrate"
>
<SwiperSlide v-for="image in images">
<div class="swiper-zoom-container">
<img :src="image.largeURL" :alt="image.alt" class="w-full h-full"/>
</div>
</SwiperSlide>
</Swiper>
<button
class="absolute z-50 text-white text-xl right-5 cursor-pointer"
style="top: calc(var(--tg-safe-area-inset-top, 5px) + var(--tg-content-safe-area-inset-top, 5px))"
@click="onClose"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-10">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<script setup>
import {Navigation, Pagination, Zoom} from "swiper/modules";
import {Swiper, SwiperSlide} from "swiper/vue";
import {onMounted} from "vue";
const props = defineProps({
images: {
type: Array,
default: () => [],
},
activeIndex: {
type: Number,
default: 0,
},
});
const emits = defineEmits(['close']);
let canVibrate = true;
function vibrate() {
if (!canVibrate) return;
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
canVibrate = false;
setTimeout(() => {
canVibrate = true;
}, 50);
}
function onClose() {
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
emits('close');
}
</script>
<style>
.fullscreen-image-viewer .swiper-button-next,
.fullscreen-image-viewer .swiper-button-prev {
color: white;
mix-blend-mode: difference;
}
.fullscreen-image-viewer .swiper-pagination-fraction {
bottom: calc(var(--tg-safe-area-inset-bottom, 0px) + var(--tg-content-safe-area-inset-bottom, px));
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z"/>
</svg>
</template>

View File

@@ -0,0 +1,17 @@
<template>
<div style="z-index: 99999" class="fixed left-0 w-full h-full bg-base-100 top-0">
<div class="flex flex-col items-center justify-center h-full">
<span class="loading loading-infinity loading-xl"></span>
<h1>{{ text }}</h1>
</div>
</div>
</template>
<script setup>
const props = defineProps({
text: {
type: String,
default: 'Получение данных...',
},
});
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div class="telecart-navbar fixed navbar bg-primary text-primary-content z-50 shadow-md" :class="{'pb-0' : platform !== 'ios'}">
<div class="navbar-start">
<div v-if="false" class="dropdown">
<button class="btn btn-ghost btn-circle" @click="toggleDrawer">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" /> </svg>
</button>
</div>
</div>
<div class="navbar-center">
<RouterLink :to="{name: 'home'}" class="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=""/>
</div>
</div>
{{ settings.app_name }}
</RouterLink>
</div>
<div class="navbar-end">
<button v-if="false" class="btn btn-ghost btn-circle">
<div class="indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /> </svg>
<span class="badge badge-xs badge-secondary indicator-item">1</span>
</div>
</button>
</div>
</div>
</template>
<script setup>
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {useMiniApp} from "vue-tg";
import {ref} from "vue";
const settings = useSettingsStore();
const emits = defineEmits(['drawer']);
const tg = useMiniApp();
const platform = ref();
platform.value = tg.platform;
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>

View File

@@ -0,0 +1,15 @@
<template>
<div class="flex flex-col items-center justify-center text-center py-16">
<span class="text-5xl mb-4">🛒</span>
<h2 class="text-xl font-semibold mb-2">Здесь нет товаров</h2>
<p class="text-sm mb-4">
Попробуйте изменить настройки фильтров
</p>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();
const goBack = () => router.back();
</script>

View File

@@ -0,0 +1,13 @@
<template>
<span>{{ formatPrice(value) }} </span>
</template>
<script setup>
import {formatPrice} from "@/helpers.js";
const props = defineProps({
value: {
default: 0,
},
});
</script>

View File

@@ -0,0 +1,20 @@
<template>
<div class="border-b mb-5 border-b-base-200 flex justify-between">
<div class="mb-2">Рекомендуемые товары</div>
<div>
<input
type="checkbox"
class="toggle"
v-model="filter.criteria.product_for_main_page.params.value"
/>
</div>
</div>
</template>
<script setup>
const props = defineProps({
filter: {
required: true,
},
});
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div class="border-b mb-5 border-b-base-200">
<div class="mb-2">Категория</div>
<div v-if="categoriesStore.isLoading" class="skeleton h-10 w-full"></div>
<select
v-else
v-model.number="props.filter.criteria.product_category_id.params.value"
class="select w-full"
>
<option :value="null">Любая категория</option>
<SelectOption
v-for="category in categoriesStore.categories"
:key="category.id"
:level="0"
:category="category"
/>
</select>
</div>
</template>
<script setup>
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
import {onMounted} from "vue";
import SelectOption from "@/components/ProductFilters/Components/ProductCategory/SelectOption.vue";
const props = defineProps({
filter: {
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const categoriesStore = useCategoriesStore();
onMounted(() => {
categoriesStore.fetchCategories();
})
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,31 @@
<template>
<option :value="category.id">
{{ "-".repeat(level) }} {{ category.name }}
</option>
<SelectOption
v-if="category.children"
v-for="child in category.children"
:key="child.id"
:category="child"
:level="level + 1"
/>
</template>
<script setup>
const props = defineProps({
category: {
type: Object,
required: true,
},
level: {
type: Number,
default: 0,
}
})
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div class="border-b mb-5 border-b-base-200">
<div class="mb-2">Цена</div>
<div class="flex justify-between">
<label class="input mr-3">
От
<input
type="number"
inputmode="numeric"
class="grow"
min="0"
step="50"
:placeholder="filter.criteria.product_price.params.value.from || '0'"
v-model="filter.criteria.product_price.params.value.from"
/>
</label>
<label class="input">
До
<input
type="number"
inputmode="numeric"
class="grow"
min="0"
step="50"
:placeholder="filter.criteria.product_price.params.value.to || '∞'"
:value="filter.criteria.product_price.params.value.to"
v-model="filter.criteria.product_price.params.value.to"
/>
</label>
</div>
</div>
</template>
<script setup>
const props = defineProps({
filter: {
required: true,
},
});
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,87 @@
<template>
<swiper-container ref="swiperEl" init="false" pagination-dynamic-bullets="true">
<swiper-slide
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"
/>
</swiper-slide>
</swiper-container>
</template>
<script setup>
import {onActivated, onMounted, onUnmounted, ref} from "vue";
const props = defineProps({
images: {
type: Array,
default: () => [],
},
});
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()
})
})
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="flex flex-col items-center justify-center text-center py-16">
<span class="text-5xl mb-4">😔</span>
<h2 class="text-xl font-semibold mb-2">Товар не найден</h2>
<p class="text-sm mb-4">К сожалению, запрошенный товар недоступен или был удалён.</p>
<button class="btn btn-primary" @click="goBack">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Назад
</button>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();
const goBack = () => router.back();
</script>

View File

@@ -0,0 +1,18 @@
<template>
<p>
<span class="text-xs font-medium">
{{ option.name }}: {{ option.value }} <span v-if="option.price"> ({{ option.price_prefix }}<Price :value="option.price"/>)</span>
</span>
</p>
</template>
<script setup>
import Price from "@/components/Price.vue";
const props = defineProps({
option: {
type: Object,
required: true,
},
});
</script>

View File

@@ -0,0 +1,18 @@
<template>
<p>
<span class="text-xs font-medium">
{{ option.name }}: {{ option.value }} <span v-if="option.price"> ({{ option.price_prefix }}<Price :value="option.price"/>)</span>
</span>
</p>
</template>
<script setup>
import Price from "@/components/Price.vue";
const props = defineProps({
option: {
type: Object,
required: true,
},
});
</script>

View File

@@ -0,0 +1,18 @@
<template>
<p>
<span class="text-xs font-medium">
{{ option.name }}: {{ option.value }} <span v-if="option.price"> ({{ option.price_prefix }}<Price :value="option.price"/>)</span>
</span>
</p>
</template>
<script setup>
import Price from "@/components/Price.vue";
const props = defineProps({
option: {
type: Object,
required: true,
},
});
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div v-for="option in options" :key="option.product_option_id" class="mt-3">
<component
v-if="SUPPORTED_OPTION_TYPES.includes(option.type) && componentMap[option.type]"
:is="componentMap[option.type]"
:modelValue="option"
/>
<div v-else class="text-sm text-error">
Тип опции "{{ option.type }}" не поддерживается.
</div>
</div>
</template>
<script setup>
import OptionRadio from "./Types/OptionRadio.vue";
import OptionCheckbox from "./Types/OptionCheckbox.vue";
import OptionText from "./Types/OptionText.vue";
import OptionTextarea from "./Types/OptionTextarea.vue";
import OptionSelect from "./Types/OptionSelect.vue";
import {SUPPORTED_OPTION_TYPES} from "@/constants/options.js";
const componentMap = {
radio: OptionRadio,
checkbox: OptionCheckbox,
text: OptionText,
textarea: OptionTextarea,
select: OptionSelect,
};
const options = defineModel();
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div>
<OptionTemplate :name="model.name" :required="model.required">
<div class="flex flex-wrap gap-2">
<label
v-for="value in model.product_option_value"
class="group relative flex items-center justify-center btn btn-soft btn-secondary btn-sm"
:class="value.selected ? 'btn-active' : ''"
>
<input
type="checkbox"
:value="value.product_option_value_id"
:checked="value.selected"
@change="select(value)"
class="absolute inset-0 appearance-none focus:outline-none disabled:cursor-not-allowed"
/>
<span class="text-xs font-medium group-has-checked:text-white">
{{ value.name }}<span v-if="value.price"> ({{ value.price_prefix }}{{ value.price }})</span>
</span>
</label>
</div>
</OptionTemplate>
</div>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function select(toggledValue) {
model.value.product_option_value.forEach(value => {
if (value === toggledValue) {
value.selected = !value.selected;
}
});
model.value.value = model.value.product_option_value.filter(item => item.selected === true);
emit('update:modelValue', model.value);
}
</script>

View File

@@ -0,0 +1,42 @@
<template>
<OptionTemplate :name="model.name" :required="model.required">
<div class="flex flex-wrap gap-2">
<label
v-for="value in model.product_option_value"
class="group relative flex items-center justify-center btn btn-soft btn-secondary btn-sm"
:class="value.selected ? 'btn-active' : ''"
>
<input
type="radio"
:name="`option-${model.product_option_id}`"
:value="value.product_option_value_id"
:checked="value.selected"
@change="select(value)"
class="absolute inset-0 appearance-none focus:outline-none disabled:cursor-not-allowed"
/>
<span class="text-xs font-medium">
{{ value.name }}<span v-if="value.price"> ({{ value.price_prefix }}{{ value.price }})</span>
</span>
</label>
</div>
</OptionTemplate>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function select(selectedValue) {
model.value.product_option_value.forEach(value => {
value.selected = (value === selectedValue);
});
model.value.value = selectedValue;
emit('update:modelValue', model);
}
</script>

View File

@@ -0,0 +1,38 @@
<template>
<OptionTemplate :name="model.name" :required="model.required">
<select
:name="`option-${model.product_option_id}`"
class="select"
@change="onChange"
>
<option value="" disabled selected>Выберите значение</option>
<option
v-for="value in model.product_option_value"
:key="value.product_option_value_id"
:value="value.product_option_value_id"
:selected="value.selected"
>
{{ value.name }}<span v-if="value.price"> ({{ value.price_prefix }}{{ value.price }})</span>
</option>
</select>
</OptionTemplate>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function onChange(event) {
const selectedId = Number(event.target.value);
model.value.product_option_value.forEach(value => {
value.selected = (value.product_option_value_id === selectedId);
});
model.value.value = model.value.product_option_value.find(value => value.product_option_value_id === selectedId);
emit('update:modelValue', model.value);
}
</script>

View File

@@ -0,0 +1,25 @@
<template>
<div>
<h3 class="text-sm mb-2">
{{ name }} <span v-if="required" class="text-error">*</span>
</h3>
<fieldset>
<slot></slot>
</fieldset>
</div>
</template>
<script setup>
defineProps({
name: {
type: String,
required: true,
},
required: {
type: Boolean,
default: false,
}
});
</script>

View File

@@ -0,0 +1,23 @@
<template>
<OptionTemplate :name="model.name" :required="model.required">
<input
type="text"
class="input"
:placeholder="model.name"
:value="model.value"
@input="input(model, $event.target.value)"
/>
</OptionTemplate>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function input(model, newValue) {
model.value = newValue;
emit('update:modelValue', model);
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<OptionTemplate :name="model.name" :required="model.required">
<textarea
type="text"
class="textarea"
:placeholder="model.name"
v-text="model.value"
@input="input(model, $event.target.value)"
/>
</OptionTemplate>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function input(model, newValue) {
model.value = newValue;
emit('update:modelValue', model);
}
</script>

View File

@@ -0,0 +1,140 @@
<template>
<div class="mx-auto max-w-2xl px-4 py-4 pb-14">
<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"
>
<RouterLink
v-for="(product, index) in products"
:key="product.id"
class="product-grid-card group"
:to="`/product/${product.id}`"
@click="productClick(product, index)"
>
<ProductImageSwiper :images="product.images"/>
<h3 class="product-title mt-4 text-sm">{{ product.name }}</h3>
<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>
</div>
<p v-else class="mt-1 text-lg font-medium">{{ product.price }}</p>
</RouterLink>
<div ref="bottom" style="height: 1px;"></div>
</div>
<div v-if="isLoadingMore" class="text-center mt-5">
<span class="loading loading-spinner loading-md"></span> Загрузка товаров...
</div>
<div v-else-if="hasMore === false" class="text-xs text-center mt-4 pt-4 mb-2 border-t">
{{ settings.texts.no_more_products }}
</div>
</template>
<div v-else-if="isLoading === true"
class="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
<div v-for="n in 8" :key="n" class="animate-pulse space-y-2">
<div class="aspect-square bg-gray-200 rounded-md"></div>
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
<NoProducts v-else/>
</div>
</template>
<script setup>
import NoProducts from "@/components/NoProducts.vue";
import ProductImageSwiper from "@/components/ProductImageSwiper.vue";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {ref} from "vue";
import {useIntersectionObserver} from '@vueuse/core';
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
const yaMetrika = useYaMetrikaStore();
const settings = useSettingsStore();
const bottom = ref(null);
const emits = defineEmits(['loadMore']);
const props = defineProps({
products: {
type: Array,
default: () => [],
},
hasMore: {
type: Boolean,
default: false,
},
categoryName: {
type: String,
default: () => '',
},
isLoading: {
type: Boolean,
default: false,
},
isLoadingMore: {
type: Boolean,
default: false,
}
});
function productClick(product, index) {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
yaMetrika.dataLayerPush({
"ecommerce": {
"currencyCode": settings.currency_code,
"click": {
"products": [
{
"id": product.id,
"name": product.name,
"price": product.final_price_numeric,
"brand": product.manufacturer_name,
"category": product.category_name,
"list": "Главная страница",
"position": index,
}
]
}
}
});
}
useIntersectionObserver(
bottom,
([entry]) => {
console.debug('Check Intersection');
if (entry?.isIntersecting === true
&& props.hasMore === true
&& props.isLoading === false
&& props.isLoadingMore === false
) {
emits('loadMore');
}
},
{
root: null,
rootMargin: '400px 0',
}
);
</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

@@ -0,0 +1,55 @@
<template>
<div class="flex items-center text-center">
<button class="btn" :class="btnClassList" @click="dec" :disabled="disabled">-</button>
<div class="w-10 h-10 flex items-center justify-center font-bold">{{ model }}</div>
<button class="btn" :class="btnClassList" @click="inc" :disabled="disabled">+</button>
</div>
</template>
<script setup>
import {computed} from "vue";
const model = defineModel();
const props = defineProps({
max: Number,
size: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
}
});
const btnClassList = computed(() => {
let classList = ['btn'];
if (props.size) {
classList.push(`btn-${props.size}`);
}
return classList;
});
function inc() {
if (props.disabled) return;
window.Telegram.WebApp.HapticFeedback.selectionChanged();
if (props.max && model.value + 1 > props.max) {
model.value = props.max;
return;
}
model.value++;
}
function dec() {
if (props.disabled) return;
window.Telegram.WebApp.HapticFeedback.selectionChanged();
if (model.value - 1 >= 1) {
model.value--;
}
}
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="search-wrapper w-full">
<label class="input w-full">
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
readonly
class="grow input-lg w-full"
placeholder="Поиск по магазину"
@click="showSearchPage"
/>
</label>
</div>
</template>
<script setup>
import {useRouter} from "vue-router";
import {useSearchStore} from "@/stores/SearchStore.js";
const router = useRouter();
function showSearchPage() {
router.push({name: 'search'});
useSearchStore().reset();
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
}
</script>