Squashed commit message
Some checks failed
Telegram Mini App Shop Builder / Compute version metadata (push) Has been cancelled
Telegram Mini App Shop Builder / Run Frontend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run Backend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Has been cancelled
Telegram Mini App Shop Builder / Build module. (push) Has been cancelled
Telegram Mini App Shop Builder / release (push) Has been cancelled
Some checks failed
Telegram Mini App Shop Builder / Compute version metadata (push) Has been cancelled
Telegram Mini App Shop Builder / Run Frontend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run Backend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Has been cancelled
Telegram Mini App Shop Builder / Build module. (push) Has been cancelled
Telegram Mini App Shop Builder / release (push) Has been cancelled
This commit is contained in:
238
frontend/spa/src/views/Account.vue
Normal file
238
frontend/spa/src/views/Account.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<BaseViewWrapper>
|
||||
<div class="account-page">
|
||||
<!-- Профиль пользователя -->
|
||||
<div class="card card-border bg-base-100 mb-4">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="avatar">
|
||||
<div v-if="tgData?.user?.photo_url" class="w-16 h-16 rounded-full">
|
||||
<img :src="tgData?.user?.photo_url" alt="avatar"/>
|
||||
</div>
|
||||
<div v-else class="bg-primary text-primary-content w-16 h-16 rounded-full flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-bold">
|
||||
{{ username }}
|
||||
</h2>
|
||||
<p v-if="tgData?.user?.username" class="text-sm text-base-content/70">
|
||||
@{{ tgData.user.username }}
|
||||
</p>
|
||||
<p v-if="daysWithUs !== null" class="text-sm text-base-content/40">
|
||||
Вы с нами {{ daysWithUs }} {{ daysWord }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список пунктов меню -->
|
||||
<div class="menu bg-base-100 rounded-box card card-border w-full mb-4">
|
||||
<li class="w-full">
|
||||
<a @click="openManagerChat" class="flex items-center gap-3 w-full">
|
||||
<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="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 8.07 8.07 0 0 0-1.603-.093C9.5 7.5 8.25 8.25 8.25 9.75v4.286c0 .597.237 1.17.659 1.591l3.682 3.682a2.25 2.25 0 0 0 1.591.659h4.286c.597 0 1.17-.237 1.591-.659l3.682-3.682a2.25 2.25 0 0 0 .659-1.591V10.608c0-1.136-.847-2.1-1.98-2.193a48.138 48.138 0 0 0-1.02-.072Z" />
|
||||
</svg>
|
||||
<span>Связаться с нами</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 ml-auto">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка добавления на главный экран -->
|
||||
<div v-if="isHomeScreenSupported" class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<button
|
||||
v-if="!isHomeScreenAdded"
|
||||
@click="addToHomeScreen"
|
||||
class="btn btn-outline w-full justify-center gap-3 mb-2"
|
||||
>
|
||||
<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="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||
</svg>
|
||||
<span>Добавить на главный экран</span>
|
||||
</button>
|
||||
<div v-else class="flex items-center justify-center gap-3 mb-2 p-3 bg-success/10 rounded-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 text-success">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
<span class="text-success font-medium">Приложение уже добавлено на главный экран</span>
|
||||
</div>
|
||||
<p v-if="!isHomeScreenAdded" class="text-xs text-base-content/50 text-center">
|
||||
Добавьте приложение на главный экран для быстрого доступа
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseViewWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed, ref} from "vue";
|
||||
import {useTgData} from "@/composables/useTgData.js";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import BaseViewWrapper from "@/views/BaseViewWrapper.vue";
|
||||
import {onMounted, onUnmounted} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {usePulseStore} from "@/stores/Pulse.js";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
defineOptions({
|
||||
name: 'Account'
|
||||
});
|
||||
|
||||
const tgData = useTgData();
|
||||
const settings = useSettingsStore();
|
||||
const route = useRoute();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const pulse = usePulseStore();
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
const username = computed(() => {
|
||||
if (tgData?.user?.first_name || tgData?.user?.last_name) {
|
||||
const parts = [tgData.user.first_name, tgData.user.last_name].filter(Boolean);
|
||||
return parts.join(' ') || 'Пользователь';
|
||||
}
|
||||
return tgData?.user?.username ? `@${tgData.user.username}` : 'Пользователь';
|
||||
});
|
||||
|
||||
const isHomeScreenSupported = ref(false);
|
||||
const isHomeScreenAdded = ref(false);
|
||||
const daysWithUs = ref(null);
|
||||
|
||||
// Функция для склонения слова "день" по правилам русского языка
|
||||
function getDaysWord(days) {
|
||||
const lastDigit = days % 10;
|
||||
const lastTwoDigits = days % 100;
|
||||
|
||||
// Исключения для 11-14
|
||||
if (lastTwoDigits >= 11 && lastTwoDigits <= 14) {
|
||||
return 'дней';
|
||||
}
|
||||
|
||||
// Склонение по последней цифре
|
||||
if (lastDigit === 1) {
|
||||
return 'день';
|
||||
} else if (lastDigit >= 2 && lastDigit <= 4) {
|
||||
return 'дня';
|
||||
} else {
|
||||
return 'дней';
|
||||
}
|
||||
}
|
||||
|
||||
const daysWord = computed(() => {
|
||||
if (daysWithUs.value === null) return '';
|
||||
return getDaysWord(daysWithUs.value);
|
||||
});
|
||||
|
||||
function openManagerChat() {
|
||||
if (!settings.manager_username) {
|
||||
window.Telegram.WebApp.showAlert('Менеджер недоступен');
|
||||
return;
|
||||
}
|
||||
|
||||
haptic.selectionChanged();
|
||||
|
||||
// Формируем ссылку для открытия чата с менеджером
|
||||
const managerUsername = String(settings.manager_username).trim();
|
||||
const username = managerUsername.startsWith('@')
|
||||
? managerUsername.substring(1)
|
||||
: managerUsername;
|
||||
|
||||
const chatUrl = `https://t.me/${username}`;
|
||||
window.Telegram.WebApp.openTelegramLink(chatUrl);
|
||||
}
|
||||
|
||||
function handleHomeScreenAdded() {
|
||||
haptic.notificationOccurred('success');
|
||||
window.Telegram.WebApp.showAlert('Приложение успешно добавлено на главный экран!');
|
||||
isHomeScreenAdded.value = true;
|
||||
}
|
||||
|
||||
function checkHomeScreenSupport() {
|
||||
if (window.Telegram?.WebApp?.checkHomeScreenStatus) {
|
||||
window.Telegram.WebApp.checkHomeScreenStatus((status) => {
|
||||
isHomeScreenSupported.value = status !== 'unsupported';
|
||||
if (status === 'added') {
|
||||
isHomeScreenAdded.value = true;
|
||||
}
|
||||
});
|
||||
} else if (window.Telegram?.WebApp?.addToHomeScreen) {
|
||||
// Если есть метод addToHomeScreen, значит функция поддерживается
|
||||
isHomeScreenSupported.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function addToHomeScreen() {
|
||||
haptic.selectionChanged();
|
||||
|
||||
// Используем Telegram Web App API для добавления на главный экран
|
||||
if (window.Telegram?.WebApp?.addToHomeScreen) {
|
||||
window.Telegram.WebApp.addToHomeScreen();
|
||||
} else {
|
||||
// Fallback для старых версий или если метод недоступен
|
||||
window.Telegram.WebApp.showAlert('Функция добавления на главный экран недоступна в вашей версии Telegram.');
|
||||
}
|
||||
}
|
||||
|
||||
function calculateDaysWithUs() {
|
||||
const createdAt = pulse.customer_created_at;
|
||||
|
||||
if (createdAt) {
|
||||
const createdDate = new Date(createdAt);
|
||||
const now = new Date();
|
||||
const diffTime = now - createdDate;
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
// Если 0 дней, показываем 1 день
|
||||
daysWithUs.value = diffDays === 0 ? 1 : diffDays;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.document.title = 'Профиль';
|
||||
yaMetrika.pushHit(route.path, {
|
||||
title: 'Профиль',
|
||||
});
|
||||
|
||||
// Вычисляем количество дней с момента первого визита
|
||||
calculateDaysWithUs();
|
||||
|
||||
// Проверяем поддержку функции добавления на главный экран
|
||||
checkHomeScreenSupport();
|
||||
|
||||
// Регистрируем обработчик события успешного добавления на главный экран
|
||||
if (window.Telegram?.WebApp?.onEvent) {
|
||||
window.Telegram.WebApp.onEvent('homeScreenAdded', handleHomeScreenAdded);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Удаляем обработчик события при размонтировании компонента
|
||||
if (window.Telegram?.WebApp?.offEvent) {
|
||||
window.Telegram.WebApp.offEvent('homeScreenAdded', handleHomeScreenAdded);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.account-page {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.menu li > a {
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menu li > a:active {
|
||||
background-color: hsl(var(--b2));
|
||||
}
|
||||
</style>
|
||||
|
||||
14
frontend/spa/src/views/BaseViewWrapper.vue
Normal file
14
frontend/spa/src/views/BaseViewWrapper.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<main class="px-4 mt-4">
|
||||
<header v-if="title" class="font-bold uppercase mb-4 text-center">{{ title }}</header>
|
||||
<section>
|
||||
<slot></slot>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
});
|
||||
</script>
|
||||
199
frontend/spa/src/views/Cart.vue
Normal file
199
frontend/spa/src/views/Cart.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<BaseViewWrapper title="Корзина">
|
||||
<div v-if="cart.attention" role="alert" class="alert alert-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>{{ cart.attention }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="cart.error_warning" role="alert" class="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ cart.error_warning }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="cart.error_coupon" role="alert" class="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ cart.error_coupon }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="cart.error_voucher" role="alert" class="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ cart.error_voucher }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="cart.items.length > 0" class="pb-10">
|
||||
<div
|
||||
v-for="(item, index) in cart.items"
|
||||
:key="item.cart_id"
|
||||
class="card card-border bg-base-100 card-sm mb-3"
|
||||
:class="item.stock === false ? 'border-error' : ''"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex">
|
||||
<div class="mr-2">
|
||||
<div class="w-16 rounded">
|
||||
<img :src="item.thumb"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<RouterLink :to="{name: 'product.show', params: {id: item.product_id}}" class="card-title">
|
||||
{{ item.name }} <span v-if="! item.stock" class="text-error font-bold">***</span>
|
||||
</RouterLink>
|
||||
<p class="text-sm font-bold">{{ item.total }}</p>
|
||||
<p>{{ item.price }}/ед</p>
|
||||
<div>
|
||||
<div v-for="option in item.option">
|
||||
<p><span class="font-bold">{{ option.name }}</span>: {{ option.value }}</p>
|
||||
<!-- <component-->
|
||||
<!-- v-if="SUPPORTED_OPTION_TYPES.includes(option.type) && componentMap[option.type]"-->
|
||||
<!-- :is="componentMap[option.type]"-->
|
||||
<!-- :option="option"-->
|
||||
<!-- />-->
|
||||
<!-- <div v-else class="text-sm text-error">-->
|
||||
<!-- Тип опции "{{ option.type }}" не поддерживается.-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-between">
|
||||
<Quantity
|
||||
:disabled="cart.isLoading"
|
||||
v-model="item.quantity"
|
||||
@update:modelValue="cart.setQuantity(item.cart_id, $event)"
|
||||
/>
|
||||
<button class="btn btn-error" @click="removeItem(item, item.cart_id, index)" :disabled="cart.isLoading">
|
||||
<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="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-border bg-base-100 mb-3">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Ваша корзина</h2>
|
||||
<div v-for="total in cart.totals">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-base-content mr-2">{{ total.title }}:</span>
|
||||
<span v-if="cart.isLoading" class="loading loading-spinner loading-xs"></span>
|
||||
<span v-else class="text-xs font-bold">{{ total.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="settings.feature_coupons || settings.feature_vouchers"
|
||||
class="card card-border bg-base-100 mb-3"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div v-if="settings.feature_coupons" class="join">
|
||||
<input v-model="cart.coupon" type="text" class="input" placeholder="Промокод"/>
|
||||
<button
|
||||
class="btn"
|
||||
:disabled="!cart.coupon"
|
||||
@click="cart.applyCoupon"
|
||||
>Применить</button>
|
||||
</div>
|
||||
|
||||
<div v-if="settings.feature_vouchers" class="join">
|
||||
<input v-model="cart.voucher" type="text" class="input" placeholder="Подарочный сертификат"/>
|
||||
<button
|
||||
class="btn"
|
||||
:disabled="!cart.voucher"
|
||||
@click="cart.applyVoucher"
|
||||
>Применить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-checkout fixed px-4 pt-4 left-0 w-full z-50 flex justify-end items-center gap-2">
|
||||
<button
|
||||
class="btn btn-primary select-none shadow-xl"
|
||||
:disabled="cart.canCheckout === false"
|
||||
@click="goToCheckout"
|
||||
>
|
||||
Перейти к оформлению
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="text-center rounded-2xl"
|
||||
>
|
||||
<div class="text-5xl mb-4">🛒</div>
|
||||
<p class="text-lg mb-3">{{ settings.texts.text_empty_cart }}</p>
|
||||
<RouterLink class="btn btn-primary" to="/">Начать покупки</RouterLink>
|
||||
</div>
|
||||
</BaseViewWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useCartStore } from '../stores/CartStore.js'
|
||||
import Quantity from "@/components/Quantity.vue";
|
||||
// import {SUPPORTED_OPTION_TYPES} from "@/constants/options.js";
|
||||
import OptionRadio from "@/components/ProductOptions/Cart/OptionRadio.vue";
|
||||
import OptionCheckbox from "@/components/ProductOptions/Cart/OptionCheckbox.vue";
|
||||
import OptionText from "@/components/ProductOptions/Cart/OptionText.vue";
|
||||
import {computed, onMounted} from "vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||
import BaseViewWrapper from "@/views/BaseViewWrapper.vue";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const route = useRoute();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const cart = useCartStore();
|
||||
const router = useRouter();
|
||||
const settings = useSettingsStore();
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
// const componentMap = {
|
||||
// radio: OptionRadio,
|
||||
// select: OptionRadio,
|
||||
// checkbox: OptionCheckbox,
|
||||
// text: OptionText,
|
||||
// textarea: OptionText,
|
||||
// };
|
||||
|
||||
const lastTotal = computed(() => {
|
||||
return cart.totals.at(-1) ?? null;
|
||||
});
|
||||
|
||||
function removeItem(cartItem, cartId, index) {
|
||||
cart.removeItem(cartItem, cartId, index);
|
||||
haptic.notificationOccurred('error');
|
||||
}
|
||||
|
||||
function goToCheckout() {
|
||||
router.push({name: 'checkout'});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.document.title = 'Корзина покупок';
|
||||
yaMetrika.pushHit(route.path, {
|
||||
title: 'Корзина покупок',
|
||||
});
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_CART);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.btn-checkout {
|
||||
bottom: calc(var(--spacing, 0px) * 22 + var(--tg-safe-area-inset-bottom, 0px))
|
||||
}
|
||||
</style>
|
||||
140
frontend/spa/src/views/CategoriesList.vue
Normal file
140
frontend/spa/src/views/CategoriesList.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<BaseViewWrapper title="Категории">
|
||||
<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>
|
||||
<div class="skeleton h-14 w-full"></div>
|
||||
<div class="skeleton h-14 w-full"></div>
|
||||
<div class="skeleton h-14 w-full"></div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<button v-if="parentId && parentCategory" class="py-1 px-4 flex items-center mb-3 cursor-pointer" @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 min-w-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
|
||||
<span class="ml-2 line-clamp-2">Назад к "{{ parentCategory.name }}"</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="parentCategory && parentCategory.children?.length && settings.show_category_products_button"
|
||||
class="py-2 px-4 flex items-center mb-3 cursor-pointer border-b w-full pb-2 border-base-200"
|
||||
@click.prevent="showProductsInParentCategory"
|
||||
>
|
||||
<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="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
|
||||
</svg>
|
||||
|
||||
<span class="ml-2">Показать товары из "{{ parentCategory.name }}"</span>
|
||||
</button>
|
||||
|
||||
<TransitionGroup
|
||||
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 transition hover:bg-base-100/60 will-change-transform"
|
||||
/>
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
</BaseViewWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed, onMounted} from "vue";
|
||||
import {router} from "@/router.js";
|
||||
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
|
||||
import {useRoute} from "vue-router";
|
||||
import CategoryItem from "@/components/CategoriesList/CategoryItem.vue";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import BaseViewWrapper from "@/views/BaseViewWrapper.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const categoriesStore = useCategoriesStore();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const parentId = computed(() => route.params.id ? Number(route.params.id) : null);
|
||||
|
||||
// 🔧 Рекурсивный поиск по всему дереву
|
||||
function findCategoryById(id, list = categoriesStore.categories) {
|
||||
if (id == null) return null;
|
||||
for (const cat of list) {
|
||||
if (cat.id === id) return cat;
|
||||
if (cat.children?.length) {
|
||||
const found = findCategoryById(id, cat.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const parentCategory = computed(() => findCategoryById(parentId.value));
|
||||
|
||||
// Если мы в корне — показываем корневые категории,
|
||||
// если внутри — показываем детей найденной категории (или пустой массив, если не нашли)
|
||||
const categories = computed(() => {
|
||||
if (!parentId.value) return categoriesStore.categories;
|
||||
return parentCategory.value?.children ?? [];
|
||||
});
|
||||
|
||||
function onSelect(category) {
|
||||
if (!category?.children?.length) {
|
||||
router.push({name: "product.categories.show", params: {category_id: category.id}});
|
||||
} else {
|
||||
router.push({name: "category.show", params: {id: category.id}});
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function showProductsInParentCategory() {
|
||||
if (parentId.value != null) {
|
||||
router.push({name: "product.categories.show", params: {category_id: parentId.value}});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.document.title = 'Каталог';
|
||||
yaMetrika.pushHit(route.path, {
|
||||
title: 'Каталог',
|
||||
});
|
||||
await categoriesStore.fetchCategories();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Стаггер для элементов списка */
|
||||
.stagger-enter-from,
|
||||
.stagger-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
.stagger-enter-active {
|
||||
transition: opacity .28s ease, transform .28s cubic-bezier(.22,.61,.36,1);
|
||||
transition-delay: calc(var(--i) * 40ms); /* задержка по индексу */
|
||||
}
|
||||
.stagger-leave-active {
|
||||
position: absolute; /* чтобы соседей не дёргало */
|
||||
width: 100%;
|
||||
transition: opacity .18s ease, transform .18s ease;
|
||||
}
|
||||
.stagger-move {
|
||||
transition: transform .28s ease; /* анимация перестановки элементов */
|
||||
}
|
||||
</style>
|
||||
135
frontend/spa/src/views/Checkout.vue
Normal file
135
frontend/spa/src/views/Checkout.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<BaseViewWrapper title="Оформление заказа" class="pb-10">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center h-20">
|
||||
<span class="loading loading-spinner loading-xl"></span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="checkoutFormSchema" class="w-full">
|
||||
<FormKit
|
||||
type="form"
|
||||
id="form-checkout"
|
||||
ref="checkoutFormRef"
|
||||
v-model="checkout.form"
|
||||
:actions="false"
|
||||
@submit="onFormSubmit"
|
||||
>
|
||||
<FormKitSchema :schema="checkoutFormSchema" :data="data"/>
|
||||
</FormKit>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div role="alert" class="alert alert-warning alert-outline">
|
||||
<IconWarning/>
|
||||
<span>
|
||||
Форма заказа не сконфигурирована. <br>
|
||||
Пожалуйста, укажите параметры формы в настройках модуля, чтобы эта секция работала корректно.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="fixed bottom-fix px-4 pb-4 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col justify-between items-center gap-2 border-t-1 border-t-base-300">
|
||||
|
||||
<div class="text-error">{{ checkout.errorMessage }}</div>
|
||||
<div>
|
||||
<FormKitMessages :node="checkoutFormRef?.node"/>
|
||||
</div>
|
||||
<button
|
||||
:disabled="isLoading || ! checkoutFormSchema || checkout.isLoading"
|
||||
class="btn btn-primary w-full"
|
||||
@click="onCreateBtnClick"
|
||||
>
|
||||
<span v-if="checkout.isLoading" class="loading loading-spinner loading-sm"></span>
|
||||
{{ btnText }}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</BaseViewWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useCheckoutStore} from "@/stores/CheckoutStore.js";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||
import {FormKit, FormKitMessages, FormKitSchema} from '@formkit/vue';
|
||||
import {submitForm} from '@formkit/core';
|
||||
import IconWarning from "@/components/Icons/IconWarning.vue";
|
||||
import {useFormsStore} from "@/stores/FormsStore.js";
|
||||
import BaseViewWrapper from "@/views/BaseViewWrapper.vue";
|
||||
|
||||
const checkout = useCheckoutStore();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const forms = useFormsStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const error = ref(null);
|
||||
|
||||
const isLoading = ref(false);
|
||||
const checkoutFormSchema = ref(null);
|
||||
const checkoutFormRef = ref(null);
|
||||
|
||||
const btnText = computed(() => {
|
||||
return checkout.isLoading ? 'Подождите...' : 'Создать заказ';
|
||||
});
|
||||
|
||||
import { reactive } from 'vue';
|
||||
const data = reactive(checkout.form);
|
||||
|
||||
function onCreateBtnClick() {
|
||||
try {
|
||||
submitForm('form-checkout');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
error.value = 'Невозможно создать заказ.';
|
||||
}
|
||||
}
|
||||
|
||||
async function onFormSubmit() {
|
||||
console.log('[Checkout]: submit form');
|
||||
|
||||
try {
|
||||
error.value = null;
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.CREATE_ORDER, {
|
||||
price: checkout.order?.final_total_numeric,
|
||||
currency: checkout.order?.currency,
|
||||
});
|
||||
|
||||
await checkout.makeOrder();
|
||||
|
||||
router.push({name: 'order_created'});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
error.value = 'Невозможно создать заказ.';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCheckoutFormSchema() {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const response = await forms.getFormByAlias('checkout');
|
||||
if (response?.data?.schema && response.data.schema.length > 0) {
|
||||
checkoutFormSchema.value = response.data.schema;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load checkout form: ', error);
|
||||
checkoutFormSchema.value = false;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.document.title = 'Оформление заказа';
|
||||
|
||||
await loadCheckoutFormSchema();
|
||||
|
||||
yaMetrika.pushHit(route.path, {
|
||||
title: 'Оформление заказа',
|
||||
});
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_CHECKOUT);
|
||||
});
|
||||
</script>
|
||||
110
frontend/spa/src/views/Filters.vue
Normal file
110
frontend/spa/src/views/Filters.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div ref="goodsRef" class="pb-10">
|
||||
<div class="flex flex-col">
|
||||
<header class="text-center shrink-0 p-3 font-bold text-xl">
|
||||
Фильтры
|
||||
</header>
|
||||
|
||||
<main class="mt-5 px-5 pt-5 bg-base-200">
|
||||
<div
|
||||
v-if="filtersStore.draft?.rules && Object.keys(filtersStore.draft.rules).length > 0"
|
||||
v-for="(filter, filterId) in filtersStore.draft.rules"
|
||||
>
|
||||
<component
|
||||
v-if="componentMap[filterId]"
|
||||
:is="componentMap[filterId]"
|
||||
:filter="filter"
|
||||
/>
|
||||
|
||||
<p v-else>Not supported: {{ filterId }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
Нет фильтров
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="fixed px-4 pb-10 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col justify-between items-center gap-2 border-t-1 border-t-base-300">
|
||||
<button
|
||||
class="btn btn-link w-full"
|
||||
@click="resetFilters"
|
||||
>
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
@click="applyFilters"
|
||||
>
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {nextTick, onMounted} from "vue";
|
||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||
import ProductPrice from "@/components/ProductFilters/Components/ProductPrice.vue";
|
||||
import ForMainPage from "@/components/ProductFilters/Components/ForMainPage.vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import ProductCategory from "@/components/ProductFilters/Components/ProductCategory/ProductCategory.vue";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
defineOptions({
|
||||
name: 'Filters'
|
||||
});
|
||||
|
||||
const componentMap = {
|
||||
RULE_PRODUCT_PRICE: ProductPrice,
|
||||
RULE_PRODUCT_FOR_MAIN_PAGE: ForMainPage,
|
||||
RULE_PRODUCT_CATEGORY: ProductCategory,
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const route = useRoute();
|
||||
const emit = defineEmits(['close', 'apply']);
|
||||
|
||||
const filtersStore = useProductFiltersStore();
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
const applyFilters = async () => {
|
||||
filtersStore.applied = JSON.parse(JSON.stringify(filtersStore.draft));
|
||||
console.debug('Filters: apply filters. Hash for router: ', filtersStore.paramsHashForRouter);
|
||||
haptic.impactOccurred('soft');
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.FILTERS_APPLY);
|
||||
await nextTick();
|
||||
router.back();
|
||||
};
|
||||
|
||||
const resetFilters = async () => {
|
||||
filtersStore.applied = filtersStore.default;
|
||||
console.debug('Filters: reset filters. Hash for router: ', filtersStore.paramsHashForRouter);
|
||||
haptic.notificationOccurred('success');
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.FILTERS_RESET);
|
||||
await nextTick();
|
||||
window.scrollTo(0, 0);
|
||||
router.back();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
console.debug('Filters: OnMounted');
|
||||
window.document.title = 'Фильтры';
|
||||
|
||||
yaMetrika.pushHit(route.path, {title: 'Фильтры'});
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_FILTERS);
|
||||
|
||||
if (filtersStore.applied?.rules) {
|
||||
console.debug('Filters: Found applied filters.');
|
||||
filtersStore.draft = JSON.parse(JSON.stringify(filtersStore.applied));
|
||||
} else {
|
||||
console.debug('No filters. Load filters from server');
|
||||
filtersStore.draft = await filtersStore.fetchFiltersForMainPage();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
59
frontend/spa/src/views/Home.vue
Normal file
59
frontend/spa/src/views/Home.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div>
|
||||
<Navbar/>
|
||||
<div ref="goodsRef" class="space-y-8 mt-4">
|
||||
<MainPage/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {onActivated, onMounted} from "vue";
|
||||
import {useRouter} from "vue-router";
|
||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import MainPage from "@/components/MainPage/MainPage.vue";
|
||||
import {useBlocksStore} from "@/stores/BlocksStore.js";
|
||||
import Navbar from "@/components/Navbar.vue";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
defineOptions({
|
||||
name: 'Home'
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const filtersStore = useProductFiltersStore();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const haptic = useHapticFeedback();
|
||||
const settings = useSettingsStore();
|
||||
const blocks = useBlocksStore();
|
||||
|
||||
onActivated(() => {
|
||||
console.debug("[Home] Home Activated");
|
||||
yaMetrika.pushHit('/', {
|
||||
title: 'Главная страница',
|
||||
});
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_HOME);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
window.document.title = 'Главная страница';
|
||||
console.debug("[Home] Home Mounted");
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filters-status {
|
||||
background-color: var(--color-info);
|
||||
color: var(--color-info);
|
||||
box-shadow: 0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000);
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-selector);
|
||||
width: .5rem;
|
||||
height: .5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
57
frontend/spa/src/views/OrderCreated.vue
Normal file
57
frontend/spa/src/views/OrderCreated.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto p-4 space-y-6 pb-30 flex flex-col items-center h-full justify-center">
|
||||
<div class="flex flex-col justify-center items-center px-5">
|
||||
<div class="mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-25 text-success">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="text-2xl font-bold mb-3">Спасибо за заказ!</p>
|
||||
<p class="text-center mb-4">{{ settings.texts.text_order_created_success }}</p>
|
||||
|
||||
<ul v-if="checkout.order" class="list w-full bg-base-200 mb-4">
|
||||
<li class="list-row flex justify-between">
|
||||
<div>Номер заказа:</div>
|
||||
<div class="font-bold">#{{ checkout.order.id }}</div>
|
||||
</li>
|
||||
|
||||
<li class="list-row flex justify-between">
|
||||
<div>Дата:</div>
|
||||
<div class="font-bold">{{ checkout.order.created_at }}</div>
|
||||
</li>
|
||||
|
||||
<li class="list-row flex justify-between">
|
||||
<div>Сумма:</div>
|
||||
<div class="font-bold">{{ checkout.order.total }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="text-xs mb-10">
|
||||
Подтверждение отправлено Вам в личных сообщениях.
|
||||
</p>
|
||||
|
||||
<RouterLink class="btn btn-primary" to="/">На главную</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useCheckoutStore} from "@/stores/CheckoutStore.js";
|
||||
import {onMounted} from "vue";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {useRoute} from "vue-router";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
|
||||
const checkout = useCheckoutStore();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const settings = useSettingsStore();
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
window.document.title = 'Заказ оформлен';
|
||||
yaMetrika.pushHit(route.path, {
|
||||
title: 'Заказ оформлен',
|
||||
});
|
||||
});
|
||||
</script>
|
||||
414
frontend/spa/src/views/Product.vue
Normal file
414
frontend/spa/src/views/Product.vue
Normal file
@@ -0,0 +1,414 @@
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<div v-if="!imagesLoaded" class="w-full aspect-square bg-base-200 flex items-center justify-center">
|
||||
<Loader/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="imagesLoaded && images.length === 0">
|
||||
<div class="bg-base-200 aspect-square flex items-center justify-center flex-col text-base-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-18">
|
||||
<path fill-rule="evenodd"
|
||||
d="M1.5 6a2.25 2.25 0 0 1 2.25-2.25h16.5A2.25 2.25 0 0 1 22.5 6v12a2.25 2.25 0 0 1-2.25 2.25H3.75A2.25 2.25 0 0 1 1.5 18V6ZM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0 0 21 18v-1.94l-2.69-2.689a1.5 1.5 0 0 0-2.12 0l-.88.879.97.97a.75.75 0 1 1-1.06 1.06l-5.16-5.159a1.5 1.5 0 0 0-2.12 0L3 16.061Zm10.125-7.81a1.125 1.125 0 1 1 2.25 0 1.125 1.125 0 0 1-2.25 0Z"
|
||||
clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-xl">Нет изображений</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SingleProductImageSwiper v-else :images="images"/>
|
||||
|
||||
<div class="px-4 pt-6 pb-6">
|
||||
<!-- Header section -->
|
||||
<div class="mb-6">
|
||||
<div v-if="product.manufacturer" class="mb-2">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">{{
|
||||
product.manufacturer
|
||||
}}</span>
|
||||
</div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold leading-tight mb-4">{{ product.name }}</h1>
|
||||
|
||||
<!-- Stock status badge -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div
|
||||
class="badge badge-sm"
|
||||
:class="product.stock === 'В наличии' ? 'badge-success' : 'badge-warning'"
|
||||
>
|
||||
{{ product.stock }}
|
||||
</div>
|
||||
<span v-if="product.minimum && product.minimum > 1" class="text-xs text-base-content/60">
|
||||
Мин. заказ: {{ product.minimum }} шт.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price section -->
|
||||
<div class="card bg-base-100 rounded-xl p-5 mb-6 shadow-sm">
|
||||
<div class="flex items-baseline gap-3 flex-wrap">
|
||||
<div v-if="product.special" class="flex items-baseline gap-3 flex-wrap">
|
||||
<span class="text-3xl sm:text-4xl font-bold text-primary">{{ product.special }}</span>
|
||||
<span class="text-lg text-base-content/50 line-through">{{ product.price }}</span>
|
||||
</div>
|
||||
<span v-else class="text-3xl sm:text-4xl font-bold text-primary">{{ product.price }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-1">
|
||||
<p v-if="product.tax" class="text-sm text-base-content/70">
|
||||
<span class="font-medium">Без НДС:</span> {{ product.tax }}
|
||||
</p>
|
||||
<p v-if="product.points && product.points > 0" class="text-sm text-base-content/70">
|
||||
<span class="font-medium">Бонусные баллы:</span> {{ product.points }}
|
||||
</p>
|
||||
<div v-if="product.discounts && product.discounts.length > 0" class="mt-2 pt-2 border-t border-base-300">
|
||||
<p class="text-xs font-semibold text-base-content/60 mb-1">Скидки при покупке:</p>
|
||||
<p v-for="discount in product.discounts" :key="discount.quantity" class="text-sm text-base-content/70">
|
||||
{{ discount.quantity }} шт. или больше — <span class="font-semibold text-primary">{{
|
||||
discount.price
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options section -->
|
||||
<div v-if="product.options && product.options.length" class="mb-6">
|
||||
<ProductOptions v-model="product.options"/>
|
||||
</div>
|
||||
|
||||
<!-- Description and details -->
|
||||
<div class="space-y-6">
|
||||
<!-- Description -->
|
||||
<div v-if="product.description" class="card bg-base-100 rounded-xl p-5 shadow-sm">
|
||||
<h3 class="text-lg font-bold mb-4 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/>
|
||||
</svg>
|
||||
Описание
|
||||
</h3>
|
||||
<div class="prose prose-sm max-w-none text-base-content/80" v-html="product.description"></div>
|
||||
</div>
|
||||
|
||||
<!-- Attributes -->
|
||||
<div v-if="product.attribute_groups && product.attribute_groups.length > 0"
|
||||
class="card bg-base-100 rounded-2xl p-5 shadow-sm">
|
||||
<h3 class="text-lg font-bold mb-4 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.593m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h11.25c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z"/>
|
||||
</svg>
|
||||
Характеристики
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<template v-for="attrGroup in product.attribute_groups" :key="attrGroup.attribute_group_id">
|
||||
<div class="border-b border-base-300 pb-3 last:border-0 last:pb-0">
|
||||
<h4 class="text-sm font-semibold text-base-content/80 mb-2">{{ attrGroup.name }}</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="attr in attrGroup.attribute" :key="attr.attribute_id"
|
||||
class="flex justify-between items-start gap-4 py-1">
|
||||
<span class="text-sm text-base-content/60 min-w-0 max-w-[45%] break-words">{{ attr.name }}</span>
|
||||
<span class="text-sm font-medium text-right min-w-0 flex-1 break-words">{{ attr.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="product.product_id"
|
||||
class="fixed bottom-fix px-4 pb-4 pt-4 left-0 w-full bg-base-100/95 backdrop-blur-md z-50 flex flex-col gap-3 border-t border-base-300 shadow-lg"
|
||||
>
|
||||
<!-- Режим: Создание заказа -->
|
||||
<template v-if="settings.product_interaction_mode === 'order'">
|
||||
<div v-if="error" class="alert alert-error alert-sm py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span class="text-xs">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="canAddToCart === false" class="alert alert-warning alert-sm py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" 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 class="text-xs">Выберите обязательные опции</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 items-center">
|
||||
<Quantity
|
||||
v-if="isInCart === false"
|
||||
:modelValue="quantity"
|
||||
@update:modelValue="setQuantity"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<button
|
||||
class="btn btn-lg w-full shadow-md"
|
||||
:class="isInCart ? 'btn-success' : 'btn-primary'"
|
||||
:disabled="cart.isLoading || canAddToCart === false"
|
||||
@click="onCartBtnClick"
|
||||
>
|
||||
<span v-if="cart.isLoading" class="loading loading-spinner loading-sm"></span>
|
||||
<template v-else>
|
||||
<svg v-if="!isInCart" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<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>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-5 h-5">
|
||||
<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>
|
||||
{{ btnText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Режим: Кнопка связи с менеджером -->
|
||||
<template v-else-if="settings.product_interaction_mode === 'manager'">
|
||||
<button
|
||||
class="btn btn-primary btn-lg w-full shadow-md"
|
||||
:disabled="!settings.manager_username"
|
||||
@click="openManagerChat"
|
||||
>
|
||||
<template v-if="settings.manager_username">
|
||||
{{ settings.texts?.text_manager_button || '💬 Связаться с менеджером' }}
|
||||
</template>
|
||||
<template v-else>Менеджер недоступен</template>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Режим: Открытие товара на сайте -->
|
||||
<template v-else-if="settings.product_interaction_mode === 'browser'">
|
||||
<button
|
||||
class="btn btn-primary btn-lg w-full shadow-md"
|
||||
:disabled="! product.share"
|
||||
@click="openProductInMarketplace"
|
||||
>
|
||||
<template v-if="product.share">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>
|
||||
</svg>
|
||||
Открыть товар
|
||||
</template>
|
||||
<template v-else>Товар недоступен</template>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<ProductNotFound v-else/>
|
||||
|
||||
<LoadingFullScreen v-if="isLoading"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
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";
|
||||
import {SUPPORTED_OPTION_TYPES} from "@/constants/options.js";
|
||||
import {fetchProductById, fetchProductImages} from "@/utils/ftch.js";
|
||||
import LoadingFullScreen from "@/components/LoadingFullScreen.vue";
|
||||
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 Loader from "@/components/Loader.vue";
|
||||
import SingleProductImageSwiper from "@/components/SingleProductImageSwiper.vue";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const route = useRoute();
|
||||
const productId = computed(() => route.params.id);
|
||||
const product = ref({});
|
||||
const cart = useCartStore();
|
||||
const quantity = ref(1);
|
||||
const error = ref('');
|
||||
const router = useRouter();
|
||||
const isInCart = ref(false);
|
||||
const btnText = computed(() => isInCart.value ? 'В корзине' : 'Купить');
|
||||
|
||||
const isLoading = ref(false);
|
||||
const settings = useSettingsStore();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const imagesLoaded = ref(false);
|
||||
const images = ref([]);
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
const canAddToCart = computed(() => {
|
||||
if (!product.value || product.value.options === undefined || product.value.options?.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const required = product.value.options.filter(item => {
|
||||
return SUPPORTED_OPTION_TYPES.includes(item.type)
|
||||
&& item.required === true
|
||||
&& !item.value;
|
||||
});
|
||||
|
||||
return required.length === 0;
|
||||
});
|
||||
|
||||
async function onCartBtnClick() {
|
||||
try {
|
||||
error.value = '';
|
||||
|
||||
if (isInCart.value === false) {
|
||||
await cart.addProduct(productId.value, product.value.name, product.value.price, quantity.value, product.value.options);
|
||||
isInCart.value = true;
|
||||
haptic.notificationOccurred('success');
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.ADD_TO_CART, {
|
||||
price: product.value.final_price_numeric,
|
||||
currency: product.value.currency,
|
||||
});
|
||||
yaMetrika.dataLayerPush({
|
||||
"ecommerce": {
|
||||
"currencyCode": settings.currency_code,
|
||||
"add": {
|
||||
"products": [
|
||||
{
|
||||
"id": product.value?.id,
|
||||
"name": product.value?.name,
|
||||
"price": product.value?.final_price_numeric,
|
||||
"brand": product.value?.manufacturer,
|
||||
"category": product.value?.category?.name,
|
||||
"quantity": 1,
|
||||
"list": "Выдача категории",
|
||||
"position": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
haptic.selectionChanged();
|
||||
await router.push({'name': 'cart'});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
await haptic.notificationOccurred('error');
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function openProductInMarketplace() {
|
||||
if (!product.value.share) {
|
||||
return;
|
||||
}
|
||||
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.PRODUCT_OPEN_EXTERNAL, {
|
||||
price: product.value?.final_price_numeric,
|
||||
currency: product.value?.currency,
|
||||
});
|
||||
|
||||
window.Telegram.WebApp.openLink(product.value.share, {try_instant_view: false});
|
||||
}
|
||||
|
||||
function openManagerChat() {
|
||||
if (!settings.manager_username) {
|
||||
return;
|
||||
}
|
||||
|
||||
haptic.selectionChanged();
|
||||
|
||||
// Формируем ссылку для открытия чата с менеджером
|
||||
// manager_username должен быть username (например, @username)
|
||||
const managerUsername = String(settings.manager_username).trim();
|
||||
|
||||
// Если username начинается с @, убираем @
|
||||
const username = managerUsername.startsWith('@')
|
||||
? managerUsername.substring(1)
|
||||
: managerUsername;
|
||||
|
||||
const chatUrl = `https://t.me/${username}`;
|
||||
|
||||
window.Telegram.WebApp.openTelegramLink(chatUrl);
|
||||
}
|
||||
|
||||
function setQuantity(newQuantity) {
|
||||
quantity.value = newQuantity;
|
||||
haptic.selectionChanged();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Явно сбрасываем скролл наверх при открытии страницы товара
|
||||
window.scrollTo({top: 0, behavior: 'instant'});
|
||||
|
||||
isLoading.value = true;
|
||||
imagesLoaded.value = false;
|
||||
|
||||
// Запускаем оба запроса параллельно
|
||||
const productPromise = fetchProductById(productId.value);
|
||||
const imagesPromise = fetchProductImages(productId.value);
|
||||
|
||||
try {
|
||||
// Ждем только загрузку продукта для рендеринга страницы
|
||||
const response = await productPromise;
|
||||
product.value = response.data;
|
||||
window.document.title = response.data.name;
|
||||
|
||||
// Страница готова к рендерингу
|
||||
isLoading.value = false;
|
||||
|
||||
// Отправляем метрики
|
||||
yaMetrika.pushHit(route.path, {
|
||||
title: response.data.name,
|
||||
params: {
|
||||
'Название товара': response.data.name,
|
||||
'ИД товара': response.data.product_id,
|
||||
'Цена': response.data.price,
|
||||
},
|
||||
});
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_PRODUCT, {
|
||||
price: response.data.final_price_numeric,
|
||||
currency: response.data.currency,
|
||||
});
|
||||
yaMetrika.dataLayerPush({
|
||||
"ecommerce": {
|
||||
"currencyCode": settings.currency_code,
|
||||
"detail": {
|
||||
"products": [
|
||||
{
|
||||
"id": response.data.product_id,
|
||||
"name": response.data.name,
|
||||
"price": response.data.final_price_numeric,
|
||||
"brand": response.data.manufacturer,
|
||||
"category": response.data.category?.name,
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
// Обрабатываем загрузку изображений в фоне (не блокируем рендеринг)
|
||||
try {
|
||||
const {data} = await imagesPromise;
|
||||
images.value = data;
|
||||
console.debug('[Product]: Images loaded: ', images.value);
|
||||
} catch (error) {
|
||||
console.error('Could not load images: ', error);
|
||||
images.value = [];
|
||||
} finally {
|
||||
imagesLoaded.value = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
160
frontend/spa/src/views/Products.vue
Normal file
160
frontend/spa/src/views/Products.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="px-4 mt-4">
|
||||
<ProductsList
|
||||
:products="productsStore.products.data"
|
||||
:hasMore="productsStore.products.meta.hasMore"
|
||||
:isLoading="productsStore.isLoading"
|
||||
:isLoadingMore="productsStore.isLoadingMore"
|
||||
:categoryName="category?.name"
|
||||
@loadMore="productsStore.loadMore"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProductsList from "@/components/ProductsList.vue";
|
||||
import {onMounted, onActivated, onDeactivated, ref, nextTick} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import {useProductsStore} from "@/stores/ProductsStore.js";
|
||||
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
|
||||
defineOptions({
|
||||
name: 'Products'
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const productsStore = useProductsStore();
|
||||
const categoriesStore = useCategoriesStore();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
|
||||
const categoryId = route.params.category_id ?? null;
|
||||
const category = ref(null);
|
||||
|
||||
// Опционально сохраняем позицию при деактивации (KeepAlive автоматически сохраняет DOM состояние)
|
||||
onDeactivated(() => {
|
||||
const currentCategoryId = route.params.category_id ?? null;
|
||||
if (currentCategoryId) {
|
||||
const scrollY = window.scrollY || window.pageYOffset || document.documentElement.scrollTop;
|
||||
if (scrollY > 0) {
|
||||
productsStore.saveScrollPosition(currentCategoryId, scrollY);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Обработчик активации компонента (KeepAlive)
|
||||
onActivated(async () => {
|
||||
console.debug("[Category] Products component activated");
|
||||
const currentCategoryId = route.params.category_id ?? null;
|
||||
|
||||
// Если категория изменилась, загружаем новые товары
|
||||
if (currentCategoryId !== categoryId) {
|
||||
console.debug("[Category] Category changed, reloading products");
|
||||
const newCategory = await categoriesStore.findCategoryById(currentCategoryId);
|
||||
category.value = newCategory;
|
||||
window.document.title = `${newCategory?.name ?? 'Неизвестная категория'}`;
|
||||
yaMetrika.pushHit(route.path, {
|
||||
title: `${newCategory?.name ?? 'Неизвестная категория'}`,
|
||||
});
|
||||
|
||||
productsStore.reset();
|
||||
productsStore.filtersFullUrl = route.fullPath;
|
||||
await productsStore.loadProducts({
|
||||
operand: "AND",
|
||||
rules: {
|
||||
RULE_PRODUCT_CATEGORIES: {
|
||||
criteria: {
|
||||
product_category_ids: {
|
||||
type: "product_categories",
|
||||
params: {
|
||||
operator: "contains",
|
||||
value: [currentCategoryId]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
productsStore.lastCategoryId = currentCategoryId;
|
||||
return;
|
||||
}
|
||||
|
||||
// Если возвращаемся на ту же категорию - не перезагружаем товары
|
||||
const isReturningToSameCategory = productsStore.lastCategoryId === currentCategoryId &&
|
||||
productsStore.products.data.length > 0 &&
|
||||
productsStore.filtersFullUrl === route.fullPath;
|
||||
|
||||
if (isReturningToSameCategory) {
|
||||
// KeepAlive должен восстановить позицию автоматически, но если нужно - восстанавливаем явно
|
||||
await nextTick();
|
||||
const savedPosition = productsStore.getScrollPosition(currentCategoryId);
|
||||
if (savedPosition > 0) {
|
||||
// Используем requestAnimationFrame для плавного восстановления
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({
|
||||
top: savedPosition,
|
||||
behavior: 'instant'
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Первая загрузка категории
|
||||
category.value = await categoriesStore.findCategoryById(currentCategoryId);
|
||||
window.document.title = `${category.value?.name ?? 'Неизвестная категория'}`;
|
||||
yaMetrika.pushHit(route.path, {
|
||||
title: `${category.value?.name ?? 'Неизвестная категория'}`,
|
||||
});
|
||||
|
||||
if (productsStore.filtersFullUrl === route.fullPath) {
|
||||
await productsStore.loadProducts(productsStore.filters ?? {
|
||||
operand: "AND",
|
||||
rules: {
|
||||
RULE_PRODUCT_CATEGORIES: {
|
||||
criteria: {
|
||||
product_category_ids: {
|
||||
type: "product_categories",
|
||||
params: {
|
||||
operator: "contains",
|
||||
value: [currentCategoryId]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
productsStore.reset();
|
||||
productsStore.filtersFullUrl = route.fullPath;
|
||||
await productsStore.loadProducts({
|
||||
operand: "AND",
|
||||
rules: {
|
||||
RULE_PRODUCT_CATEGORIES: {
|
||||
criteria: {
|
||||
product_category_ids: {
|
||||
type: "product_categories",
|
||||
params: {
|
||||
operator: "contains",
|
||||
value: [currentCategoryId]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
productsStore.lastCategoryId = currentCategoryId;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
// Только для первой загрузки, если компонент не был в KeepAlive
|
||||
const currentCategoryId = route.params.category_id ?? null;
|
||||
category.value = await categoriesStore.findCategoryById(currentCategoryId);
|
||||
window.document.title = `${category.value?.name ?? 'Неизвестная категория'}`;
|
||||
yaMetrika.pushHit(route.path, {
|
||||
title: `${category.value?.name ?? 'Неизвестная категория'}`,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
226
frontend/spa/src/views/Search.vue
Normal file
226
frontend/spa/src/views/Search.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-base-100/80 backdrop-blur-md z-10 fixed left-0 right-0 px-4 pt-4 shadow-sm" :style="searchWrapperStyle">
|
||||
<div class="flex gap-2 mb-4">
|
||||
<label class="input grow">
|
||||
<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
|
||||
ref="searchInput"
|
||||
type="search"
|
||||
class="grow input-lg"
|
||||
placeholder="Поиск по магазину"
|
||||
v-model="searchStore.search"
|
||||
@search="handleSearch"
|
||||
@keydown.enter="handleEnter"
|
||||
@input="debouncedSearch"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
v-if="searchStore.search"
|
||||
@click="clearSearch"
|
||||
class="btn btn-circle btn-ghost"
|
||||
type="button"
|
||||
aria-label="Очистить поиск"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseViewWrapper>
|
||||
<div class="pt-24">
|
||||
<div v-if="searchStore.isLoading === false && searchStore.products.data.length > 0">
|
||||
<ProductsList
|
||||
:products="searchStore.products.data"
|
||||
:hasMore="searchStore.hasMore"
|
||||
:isLoading="searchStore.isLoading"
|
||||
:isLoadingMore="searchStore.isLoadingMore"
|
||||
@loadMore="searchStore.loadMore"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="searchStore.isLoading === true">
|
||||
<div v-for="n in 3" class="flex w-full gap-4 mb-3">
|
||||
<div class="skeleton h-32 w-32"></div>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<div class="skeleton h-4 w-full"></div>
|
||||
<div class="skeleton h-4 w-28"></div>
|
||||
<div class="skeleton h-4 w-28"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="searchStore.isSearchPerformed && searchStore.isLoading === false && searchStore.products.data.length === 0"
|
||||
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>
|
||||
|
||||
<div
|
||||
v-if="! searchStore.isSearchPerformed && searchStore.isLoading === false && searchStore.products.data.length === 0"
|
||||
class="flex flex-col items-center justify-center text-center py-16 px-10"
|
||||
>
|
||||
<div class="avatar-group -space-x-6 mb-4">
|
||||
<!-- Skeleton при загрузке -->
|
||||
<template v-if="searchStore.isLoadingPlaceholder">
|
||||
<div v-for="n in 3" :key="n" class="avatar">
|
||||
<div class="w-12 skeleton rounded-full"></div>
|
||||
</div>
|
||||
<div class="avatar avatar-placeholder">
|
||||
<div class="bg-neutral text-neutral-content w-12 skeleton"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Товары после загрузки -->
|
||||
<template v-else>
|
||||
<template v-for="product in preloadedProducts" :key="product.id">
|
||||
<div v-if="product.image" class="avatar">
|
||||
<div class="w-12">
|
||||
<img :src="product.image" :alt="product.name"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="avatar avatar-placeholder">
|
||||
<div class="bg-neutral text-neutral-content w-12 rounded-full">
|
||||
<span class="text-3xl">{{ product.name.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="avatar avatar-placeholder">
|
||||
<div class="bg-neutral text-neutral-content w-12">
|
||||
<span>{{ renderSmartNumber(productsTotal) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold mb-2">Поиск товаров</h2>
|
||||
<p class="text-sm mb-4">Введите запрос, чтобы отобразить подходящие товары.</p>
|
||||
</div>
|
||||
</div>
|
||||
</BaseViewWrapper>
|
||||
|
||||
<button
|
||||
v-if="showHideKeyboardButton"
|
||||
@click="handleHideKeyboardClick"
|
||||
class="btn btn-circle btn-primary fixed bottom-4 right-4 z-50 shadow-lg"
|
||||
type="button"
|
||||
aria-label="Скрыть клавиатуру"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path d="m12 23 -3.675 -3.7H15.7L12 23ZM3.5 17c-0.4 0 -0.75 -0.15 -1.05 -0.45 -0.3 -0.3 -0.45 -0.65 -0.45 -1.05V4.5c0 -0.4 0.15 -0.75 0.45 -1.05C2.75 3.15 3.1 3 3.5 3h17c0.4 0 0.75 0.15 1.05 0.45 0.3 0.3 0.45 0.65 0.45 1.05v11c0 0.4 -0.15 0.75 -0.45 1.05 -0.3 0.3 -0.65 0.45 -1.05 0.45H3.5Zm0 -1.5h17V4.5H3.5v11Zm4.6 -1.625h7.825v-1.5H8.1v1.5ZM4.925 10.75h1.5v-1.5h-1.5v1.5Zm3.175 0h1.5v-1.5h-1.5v1.5Zm3.15 0h1.5v-1.5h-1.5v1.5Zm3.175 0h1.5v-1.5h-1.5v1.5Zm3.15 0h1.5v-1.5h-1.5v1.5Zm-12.65 -3.125h1.5v-1.5h-1.5v1.5Zm3.175 0h1.5v-1.5h-1.5v1.5Zm3.15 0h1.5v-1.5h-1.5v1.5Zm3.175 0h1.5v-1.5h-1.5v1.5Zm3.15 0h1.5v-1.5h-1.5v1.5Z" stroke-width="0.5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSearchStore} from "@/stores/SearchStore.js";
|
||||
import {useDebounceFn} from "@vueuse/core";
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {useKeyboardStore} from "@/stores/KeyboardStore.js";
|
||||
import {useRoute} from "vue-router";
|
||||
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||
import BaseViewWrapper from "@/views/BaseViewWrapper.vue";
|
||||
import ProductsList from "@/components/ProductsList.vue";
|
||||
import {renderSmartNumber} from "../helpers.js";
|
||||
|
||||
const route = useRoute();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const searchStore = useSearchStore();
|
||||
const keyboardStore = useKeyboardStore();
|
||||
const searchInput = ref(null);
|
||||
const debouncedSearch = useDebounceFn(() => searchStore.performSearch(), 800);
|
||||
|
||||
const clearSearch = () => {
|
||||
searchStore.reset();
|
||||
if (searchInput.value) {
|
||||
searchInput.value.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const hideKeyboard = () => {
|
||||
if (window.Telegram?.WebApp?.hideKeyboard) {
|
||||
window.Telegram.WebApp.hideKeyboard();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnter = (event) => {
|
||||
hideKeyboard();
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
hideKeyboard();
|
||||
showHideKeyboardButton.value = false;
|
||||
keyboardStore.setOpen(false);
|
||||
};
|
||||
|
||||
const showHideKeyboardButton = ref(false);
|
||||
|
||||
const handleFocus = () => {
|
||||
showHideKeyboardButton.value = true;
|
||||
keyboardStore.setOpen(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
// Не скрываем сразу, даем время на клик по кнопке
|
||||
setTimeout(() => {
|
||||
showHideKeyboardButton.value = false;
|
||||
keyboardStore.setOpen(false);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleHideKeyboardClick = () => {
|
||||
hideKeyboard();
|
||||
showHideKeyboardButton.value = false;
|
||||
keyboardStore.setOpen(false);
|
||||
if (searchInput.value) {
|
||||
searchInput.value.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const preloadedProducts = computed(() => searchStore.placeholderProducts.data);
|
||||
const productsTotal = computed(() => searchStore.placeholderProducts.total);
|
||||
|
||||
const searchWrapperStyle = computed(() => {
|
||||
const safeTop = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--tg-content-safe-area-inset-top') || '0px';
|
||||
const tgSafeTop = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--tg-safe-area-inset-top') || '0px';
|
||||
|
||||
// Учитываем safe area и отступ для header (если есть app-header)
|
||||
const topValue = `calc(${safeTop} + ${tgSafeTop})`;
|
||||
return {
|
||||
top: topValue
|
||||
};
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
window.document.title = 'Поиск';
|
||||
yaMetrika.pushHit(route.path, {
|
||||
title: 'Поиск',
|
||||
});
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_SEARCH);
|
||||
|
||||
await searchStore.loadSearchPlaceholder();
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user