Squashed commit message
Some checks are pending
Telegram Mini App Shop Builder / Compute version metadata (push) Waiting to run
Telegram Mini App Shop Builder / Run Frontend tests (push) Waiting to run
Telegram Mini App Shop Builder / Run Backend tests (push) Waiting to run
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Waiting to run
Telegram Mini App Shop Builder / Build module. (push) Blocked by required conditions
Telegram Mini App Shop Builder / release (push) Blocked by required conditions
Some checks are pending
Telegram Mini App Shop Builder / Compute version metadata (push) Waiting to run
Telegram Mini App Shop Builder / Run Frontend tests (push) Waiting to run
Telegram Mini App Shop Builder / Run Backend tests (push) Waiting to run
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Waiting to run
Telegram Mini App Shop Builder / Build module. (push) Blocked by required conditions
Telegram Mini App Shop Builder / release (push) Blocked by required conditions
This commit is contained in:
15
frontend/spa/index.html
Normal file
15
frontend/spa/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>AcmeShop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="app-error"></div>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js?59"></script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
5498
frontend/spa/package-lock.json
generated
Normal file
5498
frontend/spa/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/spa/package.json
Normal file
45
frontend/spa/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "tg-mini-app-shop",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/core": "^1.6.9",
|
||||
"@formkit/icons": "^1.6.9",
|
||||
"@formkit/vue": "^1.6.9",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"js-md5": "^0.8.3",
|
||||
"ofetch": "^1.4.1",
|
||||
"pinia": "^3.0.3",
|
||||
"swiper": "^12.1.2",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/ui": "^4.0.8",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"daisyui": "^5.3.10",
|
||||
"jsdom": "^27.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"terser": "^5.44.0",
|
||||
"vite": "^7.1.12",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
158
frontend/spa/src/App.vue
Normal file
158
frontend/spa/src/App.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<header class="app-header bg-base-200 w-full"></header>
|
||||
|
||||
<section class="app-content">
|
||||
|
||||
|
||||
<div class="fixed inset-0 z-50 bg-white flex items-center justify-center text-center p-4
|
||||
[@supports(color:oklch(0%_0_0))]:hidden">
|
||||
<BrowserNotSupported/>
|
||||
</div>
|
||||
|
||||
<AppDebugMessage v-if="settings.app_debug"/>
|
||||
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<KeepAlive include="Home,Products" :key="filtersStore.paramsHashForRouter">
|
||||
<component :is="Component" :key="route.fullPath"/>
|
||||
</KeepAlive>
|
||||
</RouterView>
|
||||
|
||||
<PrivacyPolicy v-if="! settings.is_privacy_consented"/>
|
||||
|
||||
<Dock v-if="isAppDockShown"/>
|
||||
<div class="dock-spacer bg-base-100 z-50"></div>
|
||||
|
||||
<div
|
||||
v-if="swiperBack.isActive.value"
|
||||
class="fixed top-1/2 left-0 z-50
|
||||
w-20
|
||||
h-20
|
||||
-translate-y-1/2
|
||||
-translate-x-18
|
||||
flex items-center justify-end
|
||||
shadow-xl
|
||||
rounded-full
|
||||
py-2
|
||||
text-primary-content
|
||||
"
|
||||
:class="{
|
||||
'bg-primary': swiperBack.deltaX.value < swiperBack.ACTIVATION_THRESHOLD,
|
||||
'bg-accent': swiperBack.deltaX.value >= swiperBack.ACTIVATION_THRESHOLD,
|
||||
}"
|
||||
:style="{ transform: `translate(${easeOut(swiperBack.deltaX.value/10, 30)}px, -50%)` }"
|
||||
|
||||
>
|
||||
<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="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed, onMounted, onUnmounted, watch} from "vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||
import {useKeyboardStore} from "@/stores/KeyboardStore.js";
|
||||
import Dock from "@/components/Dock.vue";
|
||||
import AppDebugMessage from "@/components/AppDebugMessage.vue";
|
||||
import PrivacyPolicy from "@/components/PrivacyPolicy.vue";
|
||||
import BrowserNotSupported from "@/BrowserNotSupported.vue";
|
||||
import {useSwipeBack} from "@/composables/useSwipeBack.js";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const settings = useSettingsStore();
|
||||
const filtersStore = useProductFiltersStore();
|
||||
const keyboardStore = useKeyboardStore();
|
||||
const backButton = window.Telegram.WebApp.BackButton;
|
||||
const haptic = useHapticFeedback();
|
||||
const swiperBack = useSwipeBack();
|
||||
|
||||
const routesToHideAppDock = [
|
||||
'product.show',
|
||||
'checkout',
|
||||
'order_created',
|
||||
'filters',
|
||||
];
|
||||
|
||||
function easeOut(value, max) {
|
||||
const x = Math.min(Math.abs(value) / max, 1)
|
||||
const eased = 1 - (1 - x) ** 3
|
||||
return Math.sign(value) * eased * max
|
||||
}
|
||||
|
||||
const isAppDockShown = computed(() => {
|
||||
if (routesToHideAppDock.indexOf(route.name) === -1) {
|
||||
// Скрываем Dock, если клавиатура открыта на странице поиска
|
||||
if (route.name === 'search' && keyboardStore.isOpen) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
function navigateBack() {
|
||||
haptic.impactOccurred('light');
|
||||
router.back();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
if (route.name === 'home') {
|
||||
backButton.hide();
|
||||
backButton.offClick(navigateBack);
|
||||
} else {
|
||||
backButton.show();
|
||||
backButton.onClick(navigateBack);
|
||||
}
|
||||
},
|
||||
{immediate: true}
|
||||
);
|
||||
|
||||
function handleClickOutside(e) {
|
||||
if (!e.target.closest('input,select,textarea')) {
|
||||
document.activeElement?.blur();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
(name) => {
|
||||
let height = '72px'; // дефолт
|
||||
if (name === 'product.show') height = '146px';
|
||||
document.documentElement.style.setProperty('--dock-height', height);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dock-spacer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
height: calc(
|
||||
var(--tg-content-safe-area-inset-bottom, 0px)
|
||||
+ var(--tg-safe-area-inset-bottom, 0px)
|
||||
);
|
||||
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
7
frontend/spa/src/AppLoading.vue
Normal file
7
frontend/spa/src/AppLoading.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<LoadingFullScreen text="Загрузка приложения..."/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import LoadingFullScreen from "@/components/LoadingFullScreen.vue";
|
||||
</script>
|
||||
20
frontend/spa/src/ApplicationError.vue
Normal file
20
frontend/spa/src/ApplicationError.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div style="z-index: 99999" class="fixed top-0 left-0 w-full h-full bg-base-100">
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-20">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9v6m-4.5 0V9M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
|
||||
<h1 class="font-semibold text-2xl mb-2">Магазин временно недоступен</h1>
|
||||
<p class="text-sm text-muted">Мы на перерыве, скоро всё снова заработает 🛠️</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
error: Error,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
21
frontend/spa/src/BrowserNotSupported.vue
Normal file
21
frontend/spa/src/BrowserNotSupported.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div style="z-index: 99999" class="fixed top-0 left-0 w-full h-full bg-base-100">
|
||||
<div class="flex flex-col items-center justify-center h-full px-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-20 mb-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||
</svg>
|
||||
|
||||
<h1 class="font-semibold text-2xl mb-2 text-center">Обновите браузер</h1>
|
||||
<p class="text-sm text-base-content/70 text-center mb-4">
|
||||
Ваш браузер не поддерживает необходимые функции для работы приложения.
|
||||
</p>
|
||||
<p class="text-xs text-base-content/50 text-center">
|
||||
Пожалуйста, обновите Telegram до последней версии или используйте более современный браузер.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
63
frontend/spa/src/ShoppingCart.js
Normal file
63
frontend/spa/src/ShoppingCart.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import {reactive} from "vue";
|
||||
|
||||
class ShoppingCart {
|
||||
constructor() {
|
||||
this.items = reactive([]);
|
||||
this.storageKey = 'shoppingCart';
|
||||
this.storage = Telegram.WebApp.DeviceStorage;
|
||||
|
||||
this._load()
|
||||
.then(items => {
|
||||
this.items = items;
|
||||
console.log(items);
|
||||
})
|
||||
.catch(error => console.log(error));
|
||||
}
|
||||
|
||||
async addItem(productId, productName, quantity, options = {}) {
|
||||
this.items.push({ productId: productId, productName: productName, quantity, options });
|
||||
this._save(this.items);
|
||||
}
|
||||
|
||||
has(productId) {
|
||||
const item = this.getItem(productId);
|
||||
console.log(item);
|
||||
return this.getItem(productId) !== null;
|
||||
}
|
||||
|
||||
getItem(productId) {
|
||||
return this.items.find(item => item.productId === productId) ?? null;
|
||||
}
|
||||
|
||||
getItems() {
|
||||
return this.items;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.storage.deleteItem(this.storageKey)
|
||||
}
|
||||
|
||||
async _load() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.storage.getItem(this.storageKey, (error, value) => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
reject([]);
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(value ? JSON.parse(value) : []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
reject([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_save(items) {
|
||||
this.storage.setItem(this.storageKey, JSON.stringify(items));
|
||||
}
|
||||
}
|
||||
|
||||
export default ShoppingCart;
|
||||
19
frontend/spa/src/WrongPlatformError.vue
Normal file
19
frontend/spa/src/WrongPlatformError.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div style="z-index: 99999" class="fixed top-0 left-0 w-full h-full bg-base-100">
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-18">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
|
||||
<h1 class="font-semibold text-2xl mb-2">Магазин работает только внутри Telegram.</h1>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
error: Error,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
9
frontend/spa/src/components/AppDebugMessage.vue
Normal file
9
frontend/spa/src/components/AppDebugMessage.vue
Normal 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>
|
||||
56
frontend/spa/src/components/BottomDrawer.vue
Normal file
56
frontend/spa/src/components/BottomDrawer.vue
Normal 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>
|
||||
5
frontend/spa/src/components/BottomPanel.vue
Normal file
5
frontend/spa/src/components/BottomPanel.vue
Normal 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>
|
||||
47
frontend/spa/src/components/CartButton.vue
Normal file
47
frontend/spa/src/components/CartButton.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<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="eCommerce">
|
||||
<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";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const cart = useCartStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
const isCartBtnShow = computed(() => {
|
||||
return route.name === 'product.show';
|
||||
});
|
||||
|
||||
|
||||
function eCommerce() {
|
||||
haptic.selectionChanged();
|
||||
router.push({name: 'cart'});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await cart.getProducts();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
27
frontend/spa/src/components/CategoriesList/CategoryItem.vue
Normal file
27
frontend/spa/src/components/CategoriesList/CategoryItem.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<a
|
||||
href="#"
|
||||
:key="category.id"
|
||||
class="flex items-center"
|
||||
@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-4 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>
|
||||
150
frontend/spa/src/components/Dock.vue
Normal file
150
frontend/spa/src/components/Dock.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="dock dock-lg select-none">
|
||||
<RouterLink
|
||||
:to="{name: 'home'}"
|
||||
:class="{'dock-active': route.name === 'home'}"
|
||||
@click="onDockItemClick"
|
||||
>
|
||||
<svg class="dock-icon size-[1.5em]" 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: 'categories'}"
|
||||
:class="{'dock-active': route.name === 'categories'}"
|
||||
@click="onDockItemClick"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
class="dock-icon size-7">
|
||||
<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="dock-label">Каталог</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
:to="{name: 'search'}"
|
||||
:class="{'dock-active': route.name === 'search'}"
|
||||
@click="onDockItemClick"
|
||||
>
|
||||
<svg class="dock-icon size-[1.5em]" 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.product_interaction_mode === 'order'"
|
||||
:to="{name: 'cart'}"
|
||||
:class="{'dock-active': route.name === 'cart'}"
|
||||
@click="onDockItemClick"
|
||||
>
|
||||
<div class="indicator">
|
||||
<span class="indicator-item indicator-end badge badge-secondary badge-xs">{{ cart.productsCount }}</span>
|
||||
<svg class="dock-icon size-[1.5em]" 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>
|
||||
|
||||
<RouterLink
|
||||
:to="{name: 'account'}"
|
||||
:class="{'dock-active': route.name === 'account'}"
|
||||
@click="onDockItemClick"
|
||||
>
|
||||
<div v-if="tgData?.user?.photo_url" class="dock-icon w-7 h-7 rounded-full overflow-hidden">
|
||||
<img :src="tgData?.user?.photo_url" alt="avatar" class="w-full h-full object-cover"/>
|
||||
</div>
|
||||
<div v-else class="dock-icon bg-primary text-primary-content w-7 h-7 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-5">
|
||||
<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>
|
||||
<span class="dock-label">Профиль</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useRoute} from "vue-router";
|
||||
import {useCartStore} from "@/stores/CartStore.js";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import {useTgData} from "@/composables/useTgData.js";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const route = useRoute();
|
||||
const cart = useCartStore();
|
||||
const settings = useSettingsStore();
|
||||
const tgData = useTgData();
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
function onDockItemClick(event) {
|
||||
haptic.selectionChanged();
|
||||
|
||||
// Находим иконку в кликнутом элементе
|
||||
const icon = event.currentTarget.querySelector('.dock-icon');
|
||||
if (icon) {
|
||||
icon.classList.add('dock-icon-clicked');
|
||||
setTimeout(() => {
|
||||
icon.classList.remove('dock-icon-clicked');
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dock {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
height: var(--dock-height);
|
||||
bottom: calc(
|
||||
var(--tg-content-safe-area-inset-bottom, 0px)
|
||||
+ var(--tg-safe-area-inset-bottom, 0px)
|
||||
);
|
||||
padding-bottom: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
|
||||
background: var(--color-base-100);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.dock-icon {
|
||||
transition: transform 0.2s ease-out;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.dock-icon-clicked {
|
||||
animation: dock-icon-bounce 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dock-icon-bounce {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
81
frontend/spa/src/components/FullScreenImageViewer.vue
Normal file
81
frontend/spa/src/components/FullScreenImageViewer.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<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";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const props = defineProps({
|
||||
images: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
activeIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['close']);
|
||||
const haptic = useHapticFeedback();
|
||||
let canVibrate = true;
|
||||
|
||||
function vibrate() {
|
||||
if (!canVibrate) return;
|
||||
haptic.impactOccurred('soft');
|
||||
canVibrate = false;
|
||||
setTimeout(() => {
|
||||
canVibrate = true;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
haptic.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>
|
||||
7
frontend/spa/src/components/Icons/IconFunnel.vue
Normal file
7
frontend/spa/src/components/Icons/IconFunnel.vue
Normal 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>
|
||||
7
frontend/spa/src/components/Icons/IconWarning.vue
Normal file
7
frontend/spa/src/components/Icons/IconWarning.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<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>
|
||||
</template>
|
||||
18
frontend/spa/src/components/Loader.vue
Normal file
18
frontend/spa/src/components/Loader.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="loader"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loader {
|
||||
width: 40px;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: #f03355;
|
||||
clip-path: polygon(0 0,100% 0,100% 100%,0 100%);
|
||||
animation: l1 2s infinite cubic-bezier(0.3,1,0,1);
|
||||
}
|
||||
@keyframes l1 {
|
||||
33% {border-radius: 0;background: #514b82 ;clip-path: polygon(0 0,100% 0,100% 100%,0 100%)}
|
||||
66% {border-radius: 0;background: #ffa516 ;clip-path: polygon(50% 0,50% 0,100% 100%,0 100%)}
|
||||
}
|
||||
</style>
|
||||
17
frontend/spa/src/components/LoadingFullScreen.vue
Normal file
17
frontend/spa/src/components/LoadingFullScreen.vue
Normal 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>
|
||||
35
frontend/spa/src/components/MainPage/Blocks/BaseBlock.vue
Normal file
35
frontend/spa/src/components/MainPage/Blocks/BaseBlock.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<section class="px-4">
|
||||
<header class="flex justify-between items-end mb-4">
|
||||
<div>
|
||||
<div v-if="title" class="font-bold uppercase">{{ title }}</div>
|
||||
<div v-if="description" class="text-sm text-base-content/50">{{ description }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="moreLink">
|
||||
<RouterLink :to="moreLink" class="btn btn-soft btn-xs" @click="haptic.selectionChanged">
|
||||
{{ moreText || 'Смотреть всё' }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<slot></slot>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
description: String,
|
||||
moreLink: [String, Object],
|
||||
moreText: String,
|
||||
});
|
||||
|
||||
const haptic = useHapticFeedback();
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<section>
|
||||
<header>
|
||||
<div v-if="block.title" class="font-bold uppercase text-center">{{ block.title }}</div>
|
||||
<div v-if="block.description" class="text-sm text-center">{{ block.description }}</div>
|
||||
</header>
|
||||
<main>
|
||||
<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 block.data?.categories || []"
|
||||
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>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref} from "vue";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const isLoading = ref(false);
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
const props = defineProps({
|
||||
block: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
function onCategoryClick() {
|
||||
haptic.impactOccurred('soft');
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div 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>Проблема при отображении блока.</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<BaseBlock
|
||||
:title="block.title"
|
||||
:description="block.description"
|
||||
:moreLink="{name: 'product.categories.show', params: { category_id: block.data.category_id }}"
|
||||
:moreText="block.data.all_text"
|
||||
>
|
||||
<Swiper
|
||||
class="select-none block-products-carousel"
|
||||
:slides-per-view="block.data?.carousel?.slides_per_view || 2.5"
|
||||
:space-between="block.data?.carousel?.space_between || 20"
|
||||
:autoplay="block.data?.carousel?.autoplay || false"
|
||||
:freeMode="freeModeSettings"
|
||||
:lazy="true"
|
||||
@sliderMove="hapticScroll"
|
||||
>
|
||||
<SwiperSlide
|
||||
v-for="product in block.data.products.data"
|
||||
:key="product.id"
|
||||
class="pb-1"
|
||||
>
|
||||
<div class="radius-box bg-base-100 shadow-sm p-2">
|
||||
<RouterLink
|
||||
:to="{name: 'product.show', params: {id: product.id}}"
|
||||
@click="slideClick(product)"
|
||||
>
|
||||
<div class="text-center">
|
||||
<img :src="product.images[0].url" :alt="product.name" loading="lazy" class="product-image"/>
|
||||
<PriceTitle :product="product"/>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
</BaseBlock>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {Swiper, SwiperSlide} from "swiper/vue";
|
||||
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||
import BaseBlock from "@/components/MainPage/Blocks/BaseBlock.vue";
|
||||
import PriceTitle from "@/components/ProductItem/PriceTitle.vue";
|
||||
|
||||
const hapticScroll = useHapticScroll();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
|
||||
const props = defineProps({
|
||||
block: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
const freeModeSettings = {
|
||||
enabled: props.block.data?.carousel?.freemode?.enabled || false,
|
||||
};
|
||||
|
||||
function slideClick(product) {
|
||||
if (props.block.goal_name) {
|
||||
yaMetrika.reachGoal(props.block.goal_name, {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-image {
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<section class="px-4">
|
||||
<header class="mb-4">
|
||||
<div v-if="block.title" class="font-bold uppercase">{{ block.title }}</div>
|
||||
<div v-if="block.description" class="text-sm">{{ block.description }}</div>
|
||||
</header>
|
||||
<main>
|
||||
<ProductsList
|
||||
:products="products"
|
||||
:hasMore="hasMore"
|
||||
:isLoading="isLoading"
|
||||
:isLoadingMore="isLoadingMore"
|
||||
@loadMore="onLoadMore"
|
||||
/>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {onMounted, ref, toRaw} from "vue";
|
||||
import ProductsList from "@/components/ProductsList.vue";
|
||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import ftch from "@/utils/ftch.js";
|
||||
|
||||
const filtersStore = useProductFiltersStore();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const products = ref([]);
|
||||
const hasMore = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const isLoadingMore = ref(false);
|
||||
const page = ref(1);
|
||||
const perPage = 10;
|
||||
|
||||
const props = defineProps({
|
||||
block: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchProducts() {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
console.debug('[Products Feed]: Start to load products for page 1. Filters: ', toRaw(filtersStore.applied));
|
||||
const response = await ftch('products', null, toRaw({
|
||||
page: 1,
|
||||
maxPages: props.block.data.max_page_count,
|
||||
perPage: perPage,
|
||||
filters: filtersStore.applied,
|
||||
}));
|
||||
products.value = response.data;
|
||||
hasMore.value = response.meta.hasMore;
|
||||
console.debug('[Products Feed]: Products loaded for page 1. Has More: ', hasMore.value);
|
||||
|
||||
yaMetrika.dataLayerPush({
|
||||
ecommerce: {
|
||||
currencyCode: settings.currency_code,
|
||||
impressions: products.value.map((product, index) => {
|
||||
return {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.final_price_numeric,
|
||||
brand: product.manufacturer_name,
|
||||
category: product.category_name,
|
||||
list: 'Главная страница',
|
||||
position: index,
|
||||
discount: product.price_numeric - product.final_price_numeric,
|
||||
quantity: product.product_quantity,
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onLoadMore() {
|
||||
try {
|
||||
console.debug('[Products Feed]: onLoadMore');
|
||||
if (isLoading.value === true || isLoadingMore.value === true || hasMore.value === false) return;
|
||||
isLoadingMore.value = true;
|
||||
page.value++;
|
||||
console.debug('[Products Feed]: Load more for page ', page.value, ' using filters: ', toRaw(filtersStore.applied));
|
||||
const response = await ftch('products', null, toRaw({
|
||||
page: page.value,
|
||||
perPage: perPage,
|
||||
maxPages: props.block.data.max_page_count,
|
||||
filters: filtersStore.applied,
|
||||
}));
|
||||
products.value.push(...response.data);
|
||||
hasMore.value = response.meta.hasMore;
|
||||
console.debug(`[Products Feed]: Products loaded for page ${page.value}. Has More: `, hasMore.value);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
console.debug("[Products Feed] Mounted");
|
||||
await fetchProducts();
|
||||
});
|
||||
</script>
|
||||
26
frontend/spa/src/components/MainPage/Blocks/SliderBlock.vue
Normal file
26
frontend/spa/src/components/MainPage/Blocks/SliderBlock.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<section class="mainpage-block">
|
||||
<header class="mainpage-block__header">
|
||||
<div v-if="block.title" class="font-bold uppercase text-center">{{ block.title }}</div>
|
||||
<div v-if="block.description" class="text-sm text-center mb-2">{{ block.description }}</div>
|
||||
</header>
|
||||
<main>
|
||||
<Slider :config="block.data" :goalName="block.goal_name"/>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Slider from "@/components/Slider.vue";
|
||||
|
||||
const props = defineProps({
|
||||
block: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
34
frontend/spa/src/components/MainPage/EmptyBlocks.vue
Normal file
34
frontend/spa/src/components/MainPage/EmptyBlocks.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center text-center py-16 px-4">
|
||||
<div class="mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-16 text-base-content/40">
|
||||
<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>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold mb-3">Главная страница пуста</h2>
|
||||
<p class="text-sm text-base-content/70 mb-2 max-w-md">
|
||||
На главной странице не сконфигурировано ни одного блока для отображения.
|
||||
</p>
|
||||
<p class="text-sm text-base-content/70 max-w-md">
|
||||
Перейдите в настройки модуля <span class="font-semibold">AcmeShop</span> и добавьте блоки на главную страницу.
|
||||
</p>
|
||||
<div class="mt-6 p-4 bg-base-200 rounded-lg max-w-md">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 text-info shrink-0 mt-0.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||
</svg>
|
||||
<p class="text-xs text-base-content/60 text-left">
|
||||
Вы можете добавить слайдеры, категории, ленты товаров и другие блоки для создания красивой главной страницы.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
46
frontend/spa/src/components/MainPage/MainPage.vue
Normal file
46
frontend/spa/src/components/MainPage/MainPage.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="blocks.blocks?.length > 0"
|
||||
v-for="(block, index) in blocks.blocks"
|
||||
>
|
||||
<template v-if="blockTypeToComponentMap[block.type]">
|
||||
<component
|
||||
v-if="block.is_enabled"
|
||||
:is="blockTypeToComponentMap[block.type]"
|
||||
:block="block"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else-if="blockTypeToComponentMap[block.type] === undefined">
|
||||
<div role="alert" class="alert alert-error mx-4">
|
||||
<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>Unsupported Block Type: <span class="font-bold">{{ block.type }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmptyBlocks v-else/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SliderBlock from "@/components/MainPage/Blocks/SliderBlock.vue";
|
||||
import CategoriesTopBlock from "@/components/MainPage/Blocks/CategoriesTopBlock.vue";
|
||||
import {useBlocksStore} from "@/stores/BlocksStore.js";
|
||||
import ErrorBlock from "@/components/MainPage/Blocks/ErrorBlock.vue";
|
||||
import ProductsFeedBlock from "@/components/MainPage/Blocks/ProductsFeedBlock.vue";
|
||||
import EmptyBlocks from "@/components/MainPage/EmptyBlocks.vue";
|
||||
import ProductsCarouselBlock from "@/components/MainPage/Blocks/ProductsCarouselBlock.vue";
|
||||
|
||||
const blockTypeToComponentMap = {
|
||||
slider: SliderBlock,
|
||||
categories_top: CategoriesTopBlock,
|
||||
products_feed: ProductsFeedBlock,
|
||||
products_carousel: ProductsCarouselBlock,
|
||||
error: ErrorBlock,
|
||||
};
|
||||
|
||||
const blocks = useBlocksStore();
|
||||
</script>
|
||||
37
frontend/spa/src/components/Navbar.vue
Normal file
37
frontend/spa/src/components/Navbar.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="navbar">
|
||||
<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="font-medium text-xl flex items-center">
|
||||
<div class="mr-2">
|
||||
<div v-if="settings.app_icon" class="max-h-10">
|
||||
<img :src="settings.app_icon" class="max-h-10" :alt="settings.app_name"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ settings.app_name }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const emits = defineEmits(['drawer']);
|
||||
|
||||
function toggleDrawer() {
|
||||
emits('drawer');
|
||||
}
|
||||
</script>
|
||||
15
frontend/spa/src/components/NoProducts.vue
Normal file
15
frontend/spa/src/components/NoProducts.vue
Normal 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>
|
||||
13
frontend/spa/src/components/Price.vue
Normal file
13
frontend/spa/src/components/Price.vue
Normal 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>
|
||||
53
frontend/spa/src/components/PrivacyPolicy.vue
Normal file
53
frontend/spa/src/components/PrivacyPolicy.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div v-if="isShown" class="toast toast-center toast-bottom-fix z-50">
|
||||
<div class="alert alert-info">
|
||||
<span>
|
||||
Используя магазин, вы соглашаетесь с
|
||||
<a v-if="settings.privacy_policy_link"
|
||||
href="#" class="underline"
|
||||
@click.prevent="showPrivacyPolicy"
|
||||
>обработкой персональных данных</a>
|
||||
<span v-else>обработкой персональных данных</span>.
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-outline"
|
||||
@click="privacyConsent"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {userPrivacyConsent} from "@/utils/ftch.js";
|
||||
import {ref} from "vue";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
|
||||
const isShown = ref(true);
|
||||
const settings = useSettingsStore();
|
||||
|
||||
async function privacyConsent() {
|
||||
isShown.value = false;
|
||||
await userPrivacyConsent();
|
||||
}
|
||||
|
||||
function showPrivacyPolicy() {
|
||||
if (settings.privacy_policy_link) {
|
||||
window.Telegram.WebApp.openLink(settings.privacy_policy_link, {
|
||||
try_instant_view: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-bottom-fix {
|
||||
bottom: calc(
|
||||
var(--dock-height, 0px)
|
||||
+ var(--tg-content-safe-area-inset-bottom, 0px)
|
||||
+ var(--tg-safe-area-inset-bottom, 0px)
|
||||
+ 5px
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
58
frontend/spa/src/components/ProductImageSwiper.vue
Normal file
58
frontend/spa/src/components/ProductImageSwiper.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<Swiper
|
||||
:modules="modules"
|
||||
:pagination="pagination"
|
||||
:virtual="virtual"
|
||||
@sliderMove="hapticScroll"
|
||||
class="radius-box"
|
||||
>
|
||||
<SwiperSlide
|
||||
v-for="(image, index) in images"
|
||||
:key="image.url"
|
||||
:virtualIndex="index"
|
||||
>
|
||||
<img
|
||||
:src="image.url"
|
||||
loading="lazy"
|
||||
:alt="image.alt"
|
||||
class="w-full h-full radius-box"
|
||||
@load="onLoad(image.url)"
|
||||
@error="onLoad(image.url)"
|
||||
/>
|
||||
<div v-if="!loaded[image.url]" class="swiper-lazy-preloader"></div>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref} from 'vue';
|
||||
import {Swiper, SwiperSlide} from 'swiper/vue';
|
||||
import {Pagination, Virtual} from 'swiper/modules';
|
||||
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/pagination';
|
||||
|
||||
const props = defineProps({
|
||||
images: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const modules = [Pagination, Virtual];
|
||||
const hapticScroll = useHapticScroll();
|
||||
const pagination = {
|
||||
clickable: true,
|
||||
dynamicBullets: false,
|
||||
};
|
||||
const virtual = {
|
||||
enabled: true,
|
||||
addSlidesAfter: 1,
|
||||
addSlidesBefore: 0,
|
||||
};
|
||||
|
||||
const loaded = ref({});
|
||||
const onLoad = (url) => {
|
||||
if (url) loaded.value[url] = true;
|
||||
};
|
||||
</script>
|
||||
24
frontend/spa/src/components/ProductItem/Price.vue
Normal file
24
frontend/spa/src/components/ProductItem/Price.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div v-if="special">
|
||||
<span class="old-price text-base-content/50 line-through mr-1">{{ price }}</span>
|
||||
<span class="curr-price font-bold">{{ special }}</span>
|
||||
</div>
|
||||
<div v-else class="font-bold">{{ price }}</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
price: String,
|
||||
special: [String, Boolean],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.old-price {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.curr-price {
|
||||
font-size: .9rem;
|
||||
}
|
||||
</style>
|
||||
15
frontend/spa/src/components/ProductItem/PriceTitle.vue
Normal file
15
frontend/spa/src/components/ProductItem/PriceTitle.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="mt-2 text-center space-y-0.5">
|
||||
<Price :price="product.price" :special="product.special"/>
|
||||
<ProductTitle :title="product.name"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProductTitle from "@/components/ProductItem/ProductTitle.vue";
|
||||
import Price from "@/components/ProductItem/Price.vue";
|
||||
|
||||
const props = defineProps({
|
||||
product: Object,
|
||||
});
|
||||
</script>
|
||||
21
frontend/spa/src/components/ProductItem/ProductTitle.vue
Normal file
21
frontend/spa/src/components/ProductItem/ProductTitle.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<h3 class="product-title text-sm/4">{{ title }}</h3>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: null,
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-title {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: var(--product_list_title_max_lines, 3);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
19
frontend/spa/src/components/ProductNotFound.vue
Normal file
19
frontend/spa/src/components/ProductNotFound.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
159
frontend/spa/src/components/ProductsList.vue
Normal file
159
frontend/spa/src/components/ProductsList.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<h2 v-if="categoryName" class="text-lg font-bold mb-5">{{ categoryName }}</h2>
|
||||
|
||||
<template v-if="products.length > 0">
|
||||
<div class="products-grid grid grid-cols-2 gap-x-3 gap-y-3">
|
||||
<RouterLink
|
||||
v-for="(product, index) in products"
|
||||
:key="product.id"
|
||||
class="radius-box bg-base-100 shadow-sm p-2"
|
||||
:to="`/product/${product.id}`"
|
||||
@click="productClick(product, index)"
|
||||
>
|
||||
<ProductImageSwiper :images="product.images"/>
|
||||
<PriceTitle :product="product"/>
|
||||
</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.text_no_more_products }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="isLoading === true"
|
||||
class="grid grid-cols-2 gap-x-6 gap-y-10">
|
||||
<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>
|
||||
|
||||
<div v-if="false" class="fixed z-50 w-full opacity-90" style="bottom: calc(var(--tg-safe-area-inset-bottom, 0px) + 80px);">
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
@click="showFilters"
|
||||
class="btn shadow-xl relative"
|
||||
:class="{'btn-accent' : filtersStore.isFiltersChanged}"
|
||||
>
|
||||
<IconFunnel/>
|
||||
Фильтры
|
||||
<span v-if="filtersStore.isFiltersChanged" class="status status-primary"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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";
|
||||
import IconFunnel from "@/components/Icons/IconFunnel.vue";
|
||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||
import {useRouter} from "vue-router";
|
||||
import PriceTitle from "@/components/ProductItem/PriceTitle.vue";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const router = useRouter();
|
||||
const haptic = useHapticFeedback();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const settings = useSettingsStore();
|
||||
const filtersStore = useProductFiltersStore();
|
||||
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) {
|
||||
haptic.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('[Product List]: Check Intersection: ', entry?.isIntersecting);
|
||||
if (entry?.isIntersecting === true
|
||||
&& props.hasMore === true
|
||||
&& props.isLoading === false
|
||||
&& props.isLoadingMore === false
|
||||
) {
|
||||
console.debug('[Product List]: Send Load More signal');
|
||||
emits('loadMore');
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null, // viewport
|
||||
rootMargin: '200px 0px 400px 0px', // top right bottom left
|
||||
threshold: 0, // срабатывает, как только элемент пересекает viewport
|
||||
}
|
||||
);
|
||||
|
||||
function showFilters() {
|
||||
haptic.impactOccurred('soft');
|
||||
router.push({name: 'filters'});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-grid-card {
|
||||
|
||||
}
|
||||
</style>
|
||||
58
frontend/spa/src/components/Quantity.vue
Normal file
58
frontend/spa/src/components/Quantity.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<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";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const model = defineModel();
|
||||
const props = defineProps({
|
||||
max: Number,
|
||||
size: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
const btnClassList = computed(() => {
|
||||
let classList = ['btn'];
|
||||
if (props.size) {
|
||||
classList.push(`btn-${props.size}`);
|
||||
}
|
||||
return classList;
|
||||
});
|
||||
|
||||
function inc() {
|
||||
if (props.disabled) return;
|
||||
|
||||
haptic.selectionChanged();
|
||||
|
||||
if (props.max && model.value + 1 > props.max) {
|
||||
model.value = props.max;
|
||||
return;
|
||||
}
|
||||
|
||||
model.value++;
|
||||
}
|
||||
|
||||
function dec() {
|
||||
if (props.disabled) return;
|
||||
|
||||
haptic.selectionChanged();
|
||||
|
||||
if (model.value - 1 >= 1) {
|
||||
model.value--;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
41
frontend/spa/src/components/SearchInput.vue
Normal file
41
frontend/spa/src/components/SearchInput.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<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";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
|
||||
const router = useRouter();
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
function showSearchPage() {
|
||||
router.push({name: 'search'});
|
||||
useSearchStore().reset();
|
||||
haptic.impactOccurred('medium');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
113
frontend/spa/src/components/SingleProductImageSwiper.vue
Normal file
113
frontend/spa/src/components/SingleProductImageSwiper.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="product-image-swiper">
|
||||
<Swiper
|
||||
:lazy="true"
|
||||
:modules="modules"
|
||||
:pagination="pagination"
|
||||
@sliderMove="hapticScroll"
|
||||
>
|
||||
<SwiperSlide
|
||||
v-for="(image, index) in images"
|
||||
:key="image.url"
|
||||
>
|
||||
<img
|
||||
:src="image.thumbnailURL"
|
||||
:alt="image.alt"
|
||||
loading="lazy"
|
||||
@click="showFullScreen(index)"
|
||||
/>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
|
||||
<FullScreenImageViewer
|
||||
v-if="isFullScreen"
|
||||
:images="images"
|
||||
:activeIndex="initialFullScreenIndex"
|
||||
@close="closeFullScreen"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {Swiper, SwiperSlide} from 'swiper/vue';
|
||||
import FullScreenImageViewer from "@/components/FullScreenImageViewer.vue";
|
||||
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
|
||||
import {onMounted, onUnmounted, ref} from "vue";
|
||||
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||
import {useRouter} from "vue-router";
|
||||
|
||||
const emit = defineEmits(['onLoad']);
|
||||
|
||||
const props = defineProps({
|
||||
images: {
|
||||
type: Array,
|
||||
default: [],
|
||||
}
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const haptic = useHapticFeedback();
|
||||
const hapticScroll = useHapticScroll();
|
||||
const pagination = {
|
||||
clickable: true,
|
||||
};
|
||||
const modules = [];
|
||||
const isFullScreen = ref(false);
|
||||
const initialFullScreenIndex = ref(0);
|
||||
|
||||
function showFullScreen(index) {
|
||||
haptic.selectionChanged();
|
||||
isFullScreen.value = true;
|
||||
initialFullScreenIndex.value = index;
|
||||
document.body.style.overflow = 'hidden';
|
||||
history.pushState({fullscreen: true}, '');
|
||||
}
|
||||
|
||||
function closeFullScreen() {
|
||||
isFullScreen.value = false;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function onPopState() {
|
||||
if (isFullScreen.value) {
|
||||
closeFullScreen();
|
||||
} else {
|
||||
// пусть Vue Router сам обработает
|
||||
router.back();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('popstate', onPopState);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('popstate', onPopState);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.swiper-slide {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.swiper {
|
||||
height: 500px;
|
||||
border-radius: var(--radius-box, 0.5rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.swiper-slide img {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.swiper-slide img:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
</style>
|
||||
209
frontend/spa/src/components/Slider.vue
Normal file
209
frontend/spa/src/components/Slider.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div v-if="config.slides.length > 0" class="app-banner" :class="classList">
|
||||
<Swiper
|
||||
:effect="slideEffect"
|
||||
class="mainpage-slider select-none"
|
||||
:slides-per-view="1"
|
||||
:space-between="config.space_between"
|
||||
:pagination="pagination"
|
||||
:lazy="true"
|
||||
:modules="modules"
|
||||
:scrollbar="scrollbar"
|
||||
:free-mode="config.free_mode"
|
||||
:loop="config.loop"
|
||||
:autoplay="autoplay"
|
||||
@swiper="onSwiper"
|
||||
@slideChange="onSlideChange"
|
||||
@sliderMove="hapticScroll"
|
||||
>
|
||||
<SwiperSlide v-for="slide in config.slides" :key="slide.id">
|
||||
<RouterLink
|
||||
v-if="slide?.link?.type === 'category'"
|
||||
:to="{name: 'product.categories.show', params: {category_id: slide.link.value.category_id}}"
|
||||
@click="sliderClick(slide)"
|
||||
>
|
||||
<img :src="slide.image" :alt="slide.title" loading="lazy">
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
v-else-if="slide?.link?.type === 'product'"
|
||||
:to="{name: 'product.show', params: {id: slide.link.value.product_id}}"
|
||||
@click="sliderClick(slide)"
|
||||
>
|
||||
<img :src="slide.image" :alt="slide.title" loading="lazy">
|
||||
</RouterLink>
|
||||
|
||||
<img
|
||||
v-else-if="slide?.link?.type === 'url'"
|
||||
:src="slide.image"
|
||||
:alt="slide.title"
|
||||
loading="lazy"
|
||||
@click="openExternalLink(slide.link.value.url, slide)"
|
||||
>
|
||||
|
||||
<img v-else :src="slide.image" :alt="slide.title" loading="lazy"/>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div 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>У слайдера не загружены изображения.</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {Swiper, SwiperSlide} from 'swiper/vue';
|
||||
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {Autoplay, EffectCards, EffectCoverflow, EffectCube, EffectFlip, Scrollbar} from 'swiper/modules';
|
||||
import {computed, onMounted} from "vue";
|
||||
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
goalName: {
|
||||
type: String,
|
||||
default: null,
|
||||
}
|
||||
});
|
||||
|
||||
const hapticScroll = useHapticScroll();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const modules = [
|
||||
Autoplay,
|
||||
EffectCards,
|
||||
EffectFlip,
|
||||
EffectCube,
|
||||
Scrollbar,
|
||||
EffectCoverflow,
|
||||
];
|
||||
|
||||
const classList = computed(() => {
|
||||
if (props.config.effect === 'cards') {
|
||||
return ['px-8'];
|
||||
}
|
||||
|
||||
if (props.config.effect === 'flip') {
|
||||
return ['px-4', 'pb-4', 'pt-4'];
|
||||
}
|
||||
|
||||
if (props.config.effect === 'cube') {
|
||||
return ['px-4', 'pb-10'];
|
||||
}
|
||||
|
||||
return ['px-4'];
|
||||
});
|
||||
|
||||
const onSwiper = (swiper) => {
|
||||
|
||||
};
|
||||
const onSlideChange = () => {
|
||||
|
||||
};
|
||||
|
||||
const slideEffect = computed(() => {
|
||||
if (props.config.effect === 'slide') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return props.config.effect;
|
||||
});
|
||||
|
||||
const pagination = computed(() => {
|
||||
if (props.config.pagination) {
|
||||
return {
|
||||
clickable: true, dynamicBullets: false,
|
||||
};
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const scrollbar = computed(() => {
|
||||
if (props.config.scrollbar) {
|
||||
return {
|
||||
hide: true,
|
||||
};
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const autoplay = computed(() => {
|
||||
if (props.config.autoplay) {
|
||||
return {
|
||||
delay: 3000,
|
||||
reverseDirection: false,
|
||||
};
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
function sliderClick(slide) {
|
||||
if (props.goalName) {
|
||||
yaMetrika.reachGoal(props.goalName, {
|
||||
banner: slide.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openExternalLink(link, slide) {
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.SLIDER_HOME_CLICK, {
|
||||
banner: slide.title,
|
||||
});
|
||||
|
||||
window.Telegram.WebApp.openLink(link, {try_instant_view: false});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.debug('[Mainpage Slider] Init with config: ', props.config);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.app-banner {
|
||||
aspect-ratio: 740 / 400;
|
||||
}
|
||||
|
||||
.app-banner .swiper {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-banner .swiper-horizontal > .swiper-pagination-bullets {
|
||||
bottom: -20px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
|
||||
.app-banner .mainpage-slider .swiper-pagination-bullet {
|
||||
background-color: var(--color-neutral-content); /* неактивные точки */
|
||||
opacity: 0.6; /* чуть прозрачнее, чтобы не отвлекали */
|
||||
}
|
||||
|
||||
.app-banner .mainpage-slider .swiper-pagination-bullet-active {
|
||||
background-color: var(--color-primary); /* активная точка */
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
10
frontend/spa/src/components/SwipeToBack.vue
Normal file
10
frontend/spa/src/components/SwipeToBack.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSwipeBack} from "@/composables/useSwipeBack.js";
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
39
frontend/spa/src/composables/useHapticFeedback.js
Normal file
39
frontend/spa/src/composables/useHapticFeedback.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
|
||||
/**
|
||||
* Composable для безопасной работы с HapticFeedback
|
||||
* Проверяет настройку haptic_enabled перед использованием
|
||||
* @returns {object} Объект с методами haptic feedback, которые безопасно вызываются
|
||||
*/
|
||||
export function useHapticFeedback() {
|
||||
const settings = useSettingsStore();
|
||||
const haptic = window.Telegram?.WebApp?.HapticFeedback;
|
||||
|
||||
// Возвращаем обёртку с методами, которые проверяют настройку при каждом вызове
|
||||
return {
|
||||
impactOccurred: (style) => {
|
||||
// Проверяем настройку при каждом вызове (реактивно)
|
||||
const isHapticEnabled = settings.haptic_enabled !== false;
|
||||
if (!haptic || !isHapticEnabled || !haptic.impactOccurred) {
|
||||
return;
|
||||
}
|
||||
haptic.impactOccurred(style);
|
||||
},
|
||||
selectionChanged: () => {
|
||||
// Проверяем настройку при каждом вызове (реактивно)
|
||||
const isHapticEnabled = settings.haptic_enabled !== false;
|
||||
if (!haptic || !isHapticEnabled || !haptic.selectionChanged) {
|
||||
return;
|
||||
}
|
||||
haptic.selectionChanged();
|
||||
},
|
||||
notificationOccurred: (type) => {
|
||||
// Проверяем настройку при каждом вызове (реактивно)
|
||||
const isHapticEnabled = settings.haptic_enabled !== false;
|
||||
if (!haptic || !isHapticEnabled || !haptic.notificationOccurred) {
|
||||
return;
|
||||
}
|
||||
haptic.notificationOccurred(type);
|
||||
},
|
||||
};
|
||||
}
|
||||
34
frontend/spa/src/composables/useHapticScroll.js
Normal file
34
frontend/spa/src/composables/useHapticScroll.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import {ref} from 'vue';
|
||||
import {useHapticFeedback} from './useHapticFeedback.js';
|
||||
|
||||
/**
|
||||
* Composable для Haptic Feedback по свайпу
|
||||
* @param {number} threshold - минимальное смещение для триггера
|
||||
* @param type impactOccurred или selectionChanged
|
||||
* @param feedback только для impactOccurred: 'light' | 'medium' | 'heavy' | 'soft' | 'rigid'
|
||||
* @returns {(swiper: Swiper) => void}
|
||||
*/
|
||||
export function useHapticScroll(
|
||||
threshold = 20,
|
||||
type = 'selectionChanged',
|
||||
feedback = 'soft'
|
||||
) {
|
||||
const lastTranslate = ref(0);
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
return function (
|
||||
swiper
|
||||
) {
|
||||
const current = Math.abs(swiper.translate);
|
||||
const delta = Math.abs(current - lastTranslate.value);
|
||||
|
||||
if (delta > threshold && haptic) {
|
||||
if (type === 'impactOccurred' && haptic.impactOccurred) {
|
||||
haptic.impactOccurred(feedback);
|
||||
} else if (type === 'selectionChanged' && haptic.selectionChanged) {
|
||||
haptic.selectionChanged();
|
||||
}
|
||||
lastTranslate.value = current;
|
||||
}
|
||||
};
|
||||
}
|
||||
209
frontend/spa/src/composables/useSwipeBack.js
Normal file
209
frontend/spa/src/composables/useSwipeBack.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useHapticFeedback } from './useHapticFeedback.js';
|
||||
|
||||
/**
|
||||
* Композабл для обработки жеста Swipe Back
|
||||
* Реализует поведение, аналогичное нативному в Telegram MiniApp
|
||||
* Без визуального индикатора
|
||||
*/
|
||||
export function useSwipeBack() {
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const haptic = useHapticFeedback();
|
||||
|
||||
// Состояние жеста
|
||||
const isActive = ref(false);
|
||||
const deltaX = ref(0);
|
||||
const progress = ref(0);
|
||||
const hasTriggeredHaptic = ref(false);
|
||||
|
||||
// Конфигурация
|
||||
const EDGE_THRESHOLD = 20; // Расстояние от левого края для активации (px)
|
||||
const ACTIVATION_THRESHOLD = 80; // Пороговое расстояние для активации (px)
|
||||
const MAX_DELTA = 80; // Максимальное расстояние для расчета прогресса (px)
|
||||
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchStartTime = 0;
|
||||
let isTracking = false;
|
||||
|
||||
/**
|
||||
* Вычисляет прогресс жеста (0-1)
|
||||
*/
|
||||
const calculateProgress = (currentDeltaX) => {
|
||||
return Math.min(currentDeltaX / MAX_DELTA, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Проверяет, можно ли использовать жест swipe back
|
||||
*/
|
||||
const canUseSwipeBack = computed(() => {
|
||||
return route.name !== 'home';
|
||||
});
|
||||
|
||||
/**
|
||||
* Проверяет, начинается ли жест от левого края экрана
|
||||
*/
|
||||
const isStartingFromLeftEdge = (x) => {
|
||||
return x <= EDGE_THRESHOLD;
|
||||
};
|
||||
|
||||
/**
|
||||
* Обработчик начала касания
|
||||
*/
|
||||
const handleTouchStart = (e) => {
|
||||
// Проверяем, можно ли использовать жест (не на главной странице)
|
||||
if (!canUseSwipeBack.value) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
// Проверяем, не является ли цель касания интерактивным элементом
|
||||
const target = e.target;
|
||||
if (target.closest('input, textarea, select, button, a, [role="button"], .swiper, .swiper-wrapper')) {
|
||||
return;
|
||||
}
|
||||
|
||||
touchStartX = touch.clientX;
|
||||
touchStartY = touch.clientY;
|
||||
touchStartTime = Date.now();
|
||||
|
||||
// Проверяем, начинается ли жест от левого края
|
||||
if (isStartingFromLeftEdge(touchStartX)) {
|
||||
isTracking = true;
|
||||
isActive.value = true;
|
||||
deltaX.value = 0;
|
||||
progress.value = 0;
|
||||
hasTriggeredHaptic.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Обработчик движения пальца
|
||||
*/
|
||||
const handleTouchMove = (e) => {
|
||||
if (!isTracking) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const currentX = touch.clientX;
|
||||
const currentY = touch.clientY;
|
||||
const currentDeltaX = currentX - touchStartX;
|
||||
const currentDeltaY = Math.abs(currentY - touchStartY);
|
||||
|
||||
// Проверяем, что движение преимущественно горизонтальное
|
||||
if (Math.abs(currentDeltaX) < Math.abs(currentDeltaY) * 0.5 && Math.abs(currentDeltaX) > 5) {
|
||||
// Вертикальное движение - отменяем жест
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
|
||||
// Если движение влево (отрицательное), уменьшаем прогресс
|
||||
if (currentDeltaX < 0) {
|
||||
deltaX.value = Math.max(currentDeltaX, -EDGE_THRESHOLD);
|
||||
// Если вернулись слишком далеко влево, отменяем жест
|
||||
if (currentDeltaX < -10) {
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Прямое обновление deltaX для положительного движения
|
||||
deltaX.value = currentDeltaX;
|
||||
}
|
||||
|
||||
// Обновляем прогресс только для положительного движения
|
||||
progress.value = calculateProgress(Math.max(0, deltaX.value));
|
||||
|
||||
// Проверяем достижение порога активации
|
||||
if (deltaX.value >= ACTIVATION_THRESHOLD && !hasTriggeredHaptic.value) {
|
||||
// Срабатывает тактильная обратная связь один раз
|
||||
if (haptic) {
|
||||
haptic.impactOccurred('light');
|
||||
}
|
||||
hasTriggeredHaptic.value = true;
|
||||
}
|
||||
|
||||
// Предотвращаем прокрутку страницы во время жеста
|
||||
if (deltaX.value > 0) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Обработчик окончания касания
|
||||
*/
|
||||
const handleTouchEnd = () => {
|
||||
if (!isTracking) return;
|
||||
|
||||
// Если жест достиг порога активации - выполняем навигацию назад
|
||||
if (deltaX.value >= ACTIVATION_THRESHOLD) {
|
||||
// Небольшая задержка для плавности
|
||||
setTimeout(() => {
|
||||
router.back();
|
||||
resetGesture();
|
||||
}, 50);
|
||||
} else {
|
||||
// Откат жеста без действия
|
||||
resetGesture();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Обработчик отмены касания
|
||||
*/
|
||||
const handleTouchCancel = () => {
|
||||
resetGesture();
|
||||
};
|
||||
|
||||
/**
|
||||
* Сброс состояния жеста
|
||||
*/
|
||||
const resetGesture = () => {
|
||||
isTracking = false;
|
||||
isActive.value = false;
|
||||
deltaX.value = 0;
|
||||
progress.value = 0;
|
||||
hasTriggeredHaptic.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Инициализация обработчиков событий
|
||||
*/
|
||||
const init = () => {
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||
document.addEventListener('touchcancel', handleTouchCancel, { passive: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Очистка обработчиков событий
|
||||
*/
|
||||
const cleanup = () => {
|
||||
document.removeEventListener('touchstart', handleTouchStart);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
document.removeEventListener('touchcancel', handleTouchCancel);
|
||||
resetGesture();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
return {
|
||||
isTracking,
|
||||
isActive,
|
||||
deltaX,
|
||||
progress,
|
||||
ACTIVATION_THRESHOLD,
|
||||
MAX_DELTA,
|
||||
};
|
||||
}
|
||||
|
||||
3
frontend/spa/src/composables/useTgData.js
Normal file
3
frontend/spa/src/composables/useTgData.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export function useTgData() {
|
||||
return window.Telegram?.WebApp?.initDataUnsafe;
|
||||
}
|
||||
7
frontend/spa/src/constants/options.js
Normal file
7
frontend/spa/src/constants/options.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const SUPPORTED_OPTION_TYPES = [
|
||||
'checkbox',
|
||||
'radio',
|
||||
'select',
|
||||
'text',
|
||||
'textarea',
|
||||
];
|
||||
4
frontend/spa/src/constants/tPulseEvents.js
Normal file
4
frontend/spa/src/constants/tPulseEvents.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const TC_PULSE_EVENTS = {
|
||||
WEBAPP_OPEN: 'WEBAPP_OPEN',
|
||||
ORDER_CREATED: 'ORDER_CREATED',
|
||||
};
|
||||
16
frontend/spa/src/constants/yaMetrikaGoals.js
Normal file
16
frontend/spa/src/constants/yaMetrikaGoals.js
Normal file
@@ -0,0 +1,16 @@
|
||||
export const YA_METRIKA_GOAL = {
|
||||
ADD_TO_CART: 'add_to_cart',
|
||||
PRODUCT_OPEN_EXTERNAL: 'product_open_external',
|
||||
CREATE_ORDER: 'create_order',
|
||||
ORDER_CREATED_SUCCESS: 'order_created_success',
|
||||
VIEW_PRODUCT: 'view_product',
|
||||
VIEW_CART: 'view_cart',
|
||||
VIEW_CHECKOUT: 'view_checkout',
|
||||
VIEW_HOME: 'view_home',
|
||||
VIEW_FILTERS: 'view_filters',
|
||||
FILTERS_APPLY: 'filters_apply',
|
||||
FILTERS_RESET: 'filters_reset',
|
||||
VIEW_SEARCH: 'view_search',
|
||||
PERFORM_SEARCH: 'perform_search',
|
||||
SLIDER_HOME_CLICK: 'slider_home_click',
|
||||
};
|
||||
30
frontend/spa/src/errors.js
Normal file
30
frontend/spa/src/errors.js
Normal file
@@ -0,0 +1,30 @@
|
||||
class AppError extends Error {
|
||||
constructor(message, code = 'APP_ERROR') {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.code = code;
|
||||
|
||||
// важно для наследования Error
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MaintenanceError extends AppError {
|
||||
constructor(message = 'Application is under maintenance') {
|
||||
super(message, 'MAINTENANCE');
|
||||
}
|
||||
}
|
||||
|
||||
class TelegramInitDataError extends AppError {
|
||||
constructor(message = 'Application must be opened inside Telegram') {
|
||||
super(message, 'NO_INIT_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
AppError,
|
||||
MaintenanceError,
|
||||
TelegramInitDataError,
|
||||
};
|
||||
16
frontend/spa/src/formkit.config.js
Normal file
16
frontend/spa/src/formkit.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import {ru} from '@formkit/i18n';
|
||||
import {genesisIcons} from '@formkit/icons';
|
||||
import {rootClasses} from './formkit.theme';
|
||||
|
||||
const config = {
|
||||
locales: {ru},
|
||||
locale: 'ru',
|
||||
icons: {
|
||||
...genesisIcons,
|
||||
},
|
||||
config: {
|
||||
rootClasses,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
3398
frontend/spa/src/formkit.theme.mjs
Normal file
3398
frontend/spa/src/formkit.theme.mjs
Normal file
File diff suppressed because it is too large
Load Diff
153
frontend/spa/src/helpers.js
Normal file
153
frontend/spa/src/helpers.js
Normal file
@@ -0,0 +1,153 @@
|
||||
export function isNotEmpty(value) {
|
||||
if (value === null || value === undefined) return false;
|
||||
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
|
||||
if (typeof value === 'object') return Object.keys(value).length > 0;
|
||||
|
||||
if (typeof value === 'string') return value.trim() !== '';
|
||||
|
||||
return true; // для чисел, булевых и т.п.
|
||||
}
|
||||
|
||||
export function formatPrice(raw) {
|
||||
if (raw === null || raw === undefined) return '';
|
||||
|
||||
const str = String(raw).trim();
|
||||
const match = str.match(/^([+-]?)(\d+(?:\.\d+)?)/);
|
||||
if (!match) return '';
|
||||
|
||||
const sign = match[1] || '';
|
||||
const num = parseFloat(match[2]);
|
||||
|
||||
if (isNaN(num) || num === 0) return '';
|
||||
|
||||
const formatted = Math.round(num)
|
||||
.toString()
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
|
||||
return `${sign}${formatted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить CSS-переменную DaisyUI OKLH/OKLCH и вернуть HEX для Telegram
|
||||
* @param {string} cssVarName - например '--color-base-100'
|
||||
* @returns {string} #RRGGBB
|
||||
*/
|
||||
export function getCssVarOklchRgb(cssVarName) {
|
||||
// Получаем значение CSS-переменной
|
||||
const cssVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(cssVarName)
|
||||
.trim();
|
||||
|
||||
// Проверяем, что это OKLCH
|
||||
const match = cssVar.match(/^oklch\(\s*([\d.]+)%?\s+([\d.]+)\s+([\d.]+)\s*\)$/);
|
||||
if (!match) {
|
||||
console.warn(`CSS variable ${cssVarName} is not a valid OKLCH`);
|
||||
return { r:0, g:0, b:0 };
|
||||
}
|
||||
|
||||
// Парсим L, C, H
|
||||
const L = parseFloat(match[1]) / 100; // L в daisyUI в процентах
|
||||
const C = parseFloat(match[2]);
|
||||
const H = parseFloat(match[3]);
|
||||
|
||||
// --- OKLCH -> OKLab ---
|
||||
const hRad = (H * Math.PI) / 180;
|
||||
const a = C * Math.cos(hRad);
|
||||
const b = C * Math.sin(hRad);
|
||||
const l = L;
|
||||
|
||||
// --- OKLab -> Linear RGB ---
|
||||
const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s_ = l - 0.0894841775 * a - 1.2914855480 * b;
|
||||
|
||||
const lCubed = l_ ** 3;
|
||||
const mCubed = m_ ** 3;
|
||||
const sCubed = s_ ** 3;
|
||||
|
||||
let r = 4.0767416621 * lCubed - 3.3077115913 * mCubed + 0.2309699292 * sCubed;
|
||||
let g = -1.2684380046 * lCubed + 2.6097574011 * mCubed - 0.3413193965 * sCubed;
|
||||
let b_ = -0.0041960863 * lCubed - 0.7034186147 * mCubed + 1.7076147010 * sCubed;
|
||||
|
||||
// --- Линейный RGB -> sRGB ---
|
||||
const gammaCorrect = c => {
|
||||
c = Math.min(Math.max(c, 0), 1); // обрезаем 0..1
|
||||
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1/2.4) - 0.055;
|
||||
};
|
||||
|
||||
r = Math.round(gammaCorrect(r) * 255);
|
||||
g = Math.round(gammaCorrect(g) * 255);
|
||||
b_ = Math.round(gammaCorrect(b_) * 255);
|
||||
|
||||
// --- Преобразуем в HEX ---
|
||||
const toHex = n => n.toString(16).padStart(2, '0');
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b_)}`;
|
||||
}
|
||||
|
||||
export function deserializeStartParams(serialized) {
|
||||
if (!serialized) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Восстанавливаем стандартные base64 символы
|
||||
let encoded = serialized.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
// Добавляем padding, если нужно
|
||||
const padding = encoded.length % 4;
|
||||
if (padding !== 0) {
|
||||
encoded += '='.repeat(4 - padding);
|
||||
}
|
||||
|
||||
// Декодируем из base64
|
||||
let json;
|
||||
try {
|
||||
json = atob(encoded); // btoa / atob стандартные в браузере
|
||||
} catch (e) {
|
||||
throw new Error('Failed to decode base64 string');
|
||||
}
|
||||
|
||||
// Парсим JSON
|
||||
let parameters;
|
||||
try {
|
||||
parameters = JSON.parse(json);
|
||||
} catch (e) {
|
||||
throw new Error('Failed to decode JSON: ' + e.message);
|
||||
}
|
||||
|
||||
if (typeof parameters !== 'object' || parameters === null || Array.isArray(parameters) && !Array.isArray(parameters)) {
|
||||
throw new Error('Decoded value is not an object');
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
export function renderSmartNumber(count) {
|
||||
if (count < 10) {
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
if (count < 100) {
|
||||
// округляем до десятков
|
||||
const tens = Math.floor(count / 10) * 10;
|
||||
return `+${tens}`;
|
||||
}
|
||||
|
||||
if (count < 1000) {
|
||||
// округляем до сотен
|
||||
const hundreds = Math.floor(count / 100) * 100;
|
||||
return `+${hundreds}`;
|
||||
}
|
||||
|
||||
// для тысяч и выше
|
||||
if (count < 1_000_000) {
|
||||
const rounded = Math.floor(count / 1_000) * 1;
|
||||
return `+${rounded}к`;
|
||||
}
|
||||
|
||||
// миллионы
|
||||
const roundedMillions = Math.floor(count / 1_000_000);
|
||||
return `+${roundedMillions}м`;
|
||||
}
|
||||
142
frontend/spa/src/main.js
Normal file
142
frontend/spa/src/main.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import {createApp, ref} from 'vue';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
import {router} from './router';
|
||||
import {createPinia} from 'pinia';
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import ApplicationError from "@/ApplicationError.vue";
|
||||
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
||||
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
||||
import {checkIsUserPrivacyConsented} from "@/utils/ftch.js";
|
||||
|
||||
import {register} from 'swiper/element/bundle';
|
||||
import 'swiper/element/bundle';
|
||||
import 'swiper/css/bundle';
|
||||
import AppLoading from "@/AppLoading.vue";
|
||||
import {useBlocksStore} from "@/stores/BlocksStore.js";
|
||||
import {getCssVarOklchRgb} from "@/helpers.js";
|
||||
|
||||
import {defaultConfig, plugin} from '@formkit/vue';
|
||||
import config from './formkit.config.js';
|
||||
import {TC_PULSE_EVENTS} from "@/constants/tPulseEvents.js";
|
||||
import {usePulseStore} from "@/stores/Pulse.js";
|
||||
import {TelegramInitDataError} from '@/errors.js';
|
||||
import WrongPlatformError from "@/WrongPlatformError.vue";
|
||||
|
||||
register();
|
||||
|
||||
const pinia = createPinia();
|
||||
const app = createApp(App);
|
||||
app
|
||||
.use(pinia)
|
||||
.use(router)
|
||||
.use(plugin, defaultConfig(config))
|
||||
;
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const blocks = useBlocksStore();
|
||||
const pulse = usePulseStore();
|
||||
const tg = window.Telegram.WebApp;
|
||||
|
||||
const appLoading = createApp(AppLoading);
|
||||
appLoading.mount('#app');
|
||||
|
||||
function setTelegramUIColors() {
|
||||
const daisyUIBgColor = getCssVarOklchRgb('--color-base-200');
|
||||
tg.setHeaderColor(daisyUIBgColor);
|
||||
tg.setBackgroundColor(daisyUIBgColor);
|
||||
}
|
||||
|
||||
settings.load()
|
||||
.then(() => tg.lockOrientation())
|
||||
.then(() => {
|
||||
if (settings.app_enabled === false) {
|
||||
throw new Error('App disabled (maintenance mode)');
|
||||
}
|
||||
})
|
||||
.then(() => settings.ya_metrika_enabled && injectYaMetrika())
|
||||
.then(() => {
|
||||
if (! tg.initData) {
|
||||
throw new TelegramInitDataError('Invalid init data. Application not in Telegram View');
|
||||
}
|
||||
})
|
||||
.then(() => pulse.initFromStartParams())
|
||||
.then(() => pulse.catchTelegramCustomerFromInitData())
|
||||
.then(() => pulse.ingest(TC_PULSE_EVENTS.WEBAPP_OPEN))
|
||||
.then(() => {
|
||||
pulse.heartbeat();
|
||||
})
|
||||
.then(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const response = await checkIsUserPrivacyConsented();
|
||||
settings.is_privacy_consented = response?.data?.is_privacy_consented;
|
||||
if (settings.is_privacy_consented) {
|
||||
console.info('[Init] Privacy Policy consent given by user.');
|
||||
} else {
|
||||
console.info('[Init] Privacy Policy consent NOT given by user.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Init] Failed to check Telegram user consent.');
|
||||
settings.is_privacy_consented = false;
|
||||
}
|
||||
})();
|
||||
})
|
||||
.then(() => blocks.processBlocks(settings.mainpage_blocks))
|
||||
.then(() => {
|
||||
console.debug('[Init] Set theme attributes');
|
||||
document.documentElement.setAttribute('data-theme', settings.theme[tg.colorScheme]);
|
||||
if (settings.night_auto) {
|
||||
tg.onEvent('themeChanged', function () {
|
||||
document.documentElement.setAttribute('data-theme', settings.theme[this.colorScheme]);
|
||||
setTelegramUIColors();
|
||||
});
|
||||
}
|
||||
|
||||
const tgColorScheme = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--tg-color-scheme')
|
||||
.trim();
|
||||
if (tgColorScheme) {
|
||||
document.documentElement.classList.add(tgColorScheme);
|
||||
}
|
||||
|
||||
for (const key in settings.theme.variables) {
|
||||
document.documentElement.style.setProperty(key, settings.theme.variables[key]);
|
||||
}
|
||||
|
||||
try {
|
||||
setTelegramUIColors();
|
||||
} catch (e) {
|
||||
console.error('Could not set Telegram UI Colors', e);
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(() => new AppMetaInitializer(settings).init())
|
||||
.then(() => {
|
||||
appLoading.unmount();
|
||||
app.mount('#app');
|
||||
})
|
||||
.then(() => tg.ready())
|
||||
.then(() => {
|
||||
if (tg.platform !== 'tdesktop') {
|
||||
tg.disableVerticalSwipes();
|
||||
tg.requestFullscreen();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
const code = error.code ?? error?.response?._data.code;
|
||||
let ErrorComponent;
|
||||
|
||||
switch (code) {
|
||||
case 'NO_INIT_DATA':
|
||||
ErrorComponent = WrongPlatformError;
|
||||
break;
|
||||
|
||||
default:
|
||||
ErrorComponent = ApplicationError;
|
||||
}
|
||||
|
||||
const errorApp = createApp(ErrorComponent, { error });
|
||||
errorApp.mount('#app-error');
|
||||
});
|
||||
69
frontend/spa/src/router.js
Normal file
69
frontend/spa/src/router.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import {createRouter, createWebHashHistory} from 'vue-router';
|
||||
import Home from './views/Home.vue';
|
||||
import Product from './views/Product.vue';
|
||||
import CategoriesList from "./views/CategoriesList.vue";
|
||||
import Cart from "./views/Cart.vue";
|
||||
import Products from "@/views/Products.vue";
|
||||
import Checkout from "@/views/Checkout.vue";
|
||||
import OrderCreated from "@/views/OrderCreated.vue";
|
||||
import Search from "@/views/Search.vue";
|
||||
import Filters from "@/views/Filters.vue";
|
||||
import Account from "@/views/Account.vue";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: Home,
|
||||
},
|
||||
{path: '/filters', name: 'filters', component: Filters},
|
||||
{path: '/product/:id', name: 'product.show', component: Product},
|
||||
{
|
||||
path: '/products/:category_id',
|
||||
name: 'product.categories.show',
|
||||
component: Products,
|
||||
},
|
||||
{path: '/categories', name: 'categories', component: CategoriesList},
|
||||
{path: '/category/:id', name: 'category.show', component: CategoriesList},
|
||||
{path: '/cart', name: 'cart', component: Cart},
|
||||
{path: '/checkout', name: 'checkout', component: Checkout},
|
||||
{path: '/success', name: 'order_created', component: OrderCreated},
|
||||
{path: '/search', name: 'search', component: Search},
|
||||
{path: '/account', name: 'account', component: Account},
|
||||
];
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory('/image/catalog/tgshopspa/'),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
// Для страницы товара всегда скроллим наверх мгновенно
|
||||
if (to.name === 'product.show') {
|
||||
return {top: 0, behavior: 'instant'};
|
||||
}
|
||||
|
||||
// Для страницы категории скролл будет восстановлен в компоненте через onActivated
|
||||
// Здесь просто предотвращаем автоматический скролл наверх
|
||||
if (to.name === 'product.categories.show') {
|
||||
// Если возвращаемся назад на категорию - используем savedPosition
|
||||
if (savedPosition) {
|
||||
return savedPosition;
|
||||
}
|
||||
return false; // Не скроллить автоматически
|
||||
}
|
||||
|
||||
// Для остальных страниц используем savedPosition если есть, иначе наверх
|
||||
if (savedPosition) {
|
||||
return savedPosition;
|
||||
}
|
||||
|
||||
return {top: 0, behavior: 'smooth'};
|
||||
},
|
||||
});
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const ym = useYaMetrikaStore();
|
||||
ym.prevPath = from.path;
|
||||
next();
|
||||
});
|
||||
|
||||
32
frontend/spa/src/stores/BlocksStore.js
Normal file
32
frontend/spa/src/stores/BlocksStore.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {processBlock} from "@/utils/ftch.js";
|
||||
|
||||
export const useBlocksStore = defineStore('blocks', {
|
||||
state: () => ({
|
||||
blocks: [],
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async processBlocks(rawBlocks) {
|
||||
const results = await Promise.allSettled(
|
||||
rawBlocks.map(block => {
|
||||
console.debug('[Blocks Store]: Process block ', block);
|
||||
return processBlock(block)
|
||||
.then(response => response);
|
||||
})
|
||||
);
|
||||
|
||||
this.blocks = results
|
||||
.map(r => {
|
||||
if (r.status === 'fulfilled') {
|
||||
return r.value.data;
|
||||
} else {
|
||||
return {
|
||||
is_enabled: true,
|
||||
type: 'error',
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
169
frontend/spa/src/stores/CartStore.js
Normal file
169
frontend/spa/src/stores/CartStore.js
Normal file
@@ -0,0 +1,169 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {isNotEmpty} from "@/helpers.js";
|
||||
import {addToCart, cartEditItem, cartRemoveItem, getCart, setCoupon, setVoucher} from "@/utils/ftch.js";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
|
||||
export const useCartStore = defineStore('cart', {
|
||||
state: () => ({
|
||||
items: [],
|
||||
productsCount: 0,
|
||||
total: 0,
|
||||
isLoading: false,
|
||||
reason: null,
|
||||
error_warning: '',
|
||||
attention: '',
|
||||
success: '',
|
||||
coupon: '',
|
||||
voucher: '',
|
||||
error_coupon: '',
|
||||
error_voucher: '',
|
||||
}),
|
||||
|
||||
getters: {
|
||||
canCheckout: (state) => {
|
||||
if (state.isLoading || state.error_warning.length > 0) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
async getProducts() {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const {data} = await getCart();
|
||||
this.items = data.products;
|
||||
this.productsCount = data.total_products_count;
|
||||
this.totals = data.totals;
|
||||
this.error_warning = data.error_warning;
|
||||
this.attention = data.attention;
|
||||
this.success = data.success;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async addProduct(productId, productName, price, quantity = 1, options = []) {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const formData = new FormData();
|
||||
formData.append("product_id", productId);
|
||||
formData.append("quantity", quantity);
|
||||
|
||||
// TODO: Add support different types of options
|
||||
options.forEach((option) => {
|
||||
if (option.type === "checkbox" && Array.isArray(option.value)) {
|
||||
option.value.forEach(item => {
|
||||
formData.append(`option[${option.product_option_id}][]`, item.product_option_value_id);
|
||||
});
|
||||
} else if (option.type === "radio" && isNotEmpty(option.value)) {
|
||||
formData.append(`option[${option.product_option_id}]`, option.value.product_option_value_id);
|
||||
} else if (option.type === "select" && isNotEmpty(option.value)) {
|
||||
formData.append(`option[${option.product_option_id}]`, option.value.product_option_value_id);
|
||||
} else if ((option.type === "text" || option.type === 'textarea') && isNotEmpty(option.value)) {
|
||||
formData.append(`option[${option.product_option_id}]`, option.value);
|
||||
}
|
||||
})
|
||||
|
||||
const response = await addToCart(formData);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(JSON.stringify(response.error));
|
||||
}
|
||||
|
||||
await this.getProducts();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async removeItem(cartItem, rowId, index = 0) {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const formData = new FormData();
|
||||
formData.append('key', rowId);
|
||||
await cartRemoveItem(formData);
|
||||
useYaMetrikaStore().dataLayerPush({
|
||||
"ecommerce": {
|
||||
"currencyCode": useSettingsStore().currency_code,
|
||||
"remove": {
|
||||
"products": [
|
||||
{
|
||||
"id": cartItem.product_id,
|
||||
"name": cartItem.name,
|
||||
"quantity": cartItem.quantity,
|
||||
"position": index
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
await this.getProducts();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async setQuantity(cartId, quantity) {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const formData = new FormData();
|
||||
formData.append(`quantity[${cartId}]`, quantity);
|
||||
await cartEditItem(formData);
|
||||
await this.getProducts();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async applyCoupon() {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.error_warning = '';
|
||||
this.error_coupon = '';
|
||||
const response = await setCoupon(this.coupon);
|
||||
|
||||
if (response.error) {
|
||||
this.error_coupon = response.error;
|
||||
} else {
|
||||
await this.getProducts();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
this.error_warning = 'Возникла ошибка';
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async applyVoucher() {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.error_warning = '';
|
||||
this.error_voucher = '';
|
||||
const response = await setVoucher(this.voucher);
|
||||
|
||||
if (response.error) {
|
||||
this.error_voucher = response.error;
|
||||
} else {
|
||||
await this.getProducts();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
this.error_warning = 'Возникла ошибка';
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
47
frontend/spa/src/stores/CategoriesStore.js
Normal file
47
frontend/spa/src/stores/CategoriesStore.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import {defineStore} from "pinia";
|
||||
import ftch from "../utils/ftch.js";
|
||||
|
||||
export const useCategoriesStore = defineStore('categories', {
|
||||
state: () => ({
|
||||
topCategories: [],
|
||||
categories: [],
|
||||
isLoading: false,
|
||||
isCategoriesLoaded: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchCategories() {
|
||||
if (this.isCategoriesLoaded === false && this.categories.length === 0) {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const {data} = await ftch('categoriesList');
|
||||
this.categories = data;
|
||||
this.isCategoriesLoaded = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async findCategoryById(id, list = []) {
|
||||
if (! id) return null;
|
||||
|
||||
if (list && list.length === 0) {
|
||||
await this.fetchCategories();
|
||||
list = this.categories;
|
||||
}
|
||||
|
||||
for (const cat of list) {
|
||||
if (parseInt(cat.id) === parseInt(id)) return cat;
|
||||
if (cat.children?.length) {
|
||||
const found = await this.findCategoryById(id, cat.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
157
frontend/spa/src/stores/CheckoutStore.js
Normal file
157
frontend/spa/src/stores/CheckoutStore.js
Normal file
@@ -0,0 +1,157 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {isNotEmpty} from "@/helpers.js";
|
||||
import {storeOrder} from "@/utils/ftch.js";
|
||||
import {useCartStore} from "@/stores/CartStore.js";
|
||||
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import {usePulseStore} from "@/stores/Pulse.js";
|
||||
import {TC_PULSE_EVENTS} from "@/constants/tPulseEvents.js";
|
||||
import {nextTick} from "vue";
|
||||
|
||||
/**
|
||||
* Helper функция для получения haptic feedback (можно использовать в store)
|
||||
* @returns {object} Объект с методами haptic feedback
|
||||
*/
|
||||
function getHapticFeedback() {
|
||||
const settings = useSettingsStore();
|
||||
const haptic = window.Telegram?.WebApp?.HapticFeedback;
|
||||
|
||||
// Возвращаем обёртку с методами, которые проверяют настройку при каждом вызове
|
||||
return {
|
||||
impactOccurred: (style) => {
|
||||
const isHapticEnabled = settings.haptic_enabled !== false;
|
||||
if (!haptic || !isHapticEnabled || !haptic.impactOccurred) {
|
||||
return;
|
||||
}
|
||||
haptic.impactOccurred(style);
|
||||
},
|
||||
selectionChanged: () => {
|
||||
const isHapticEnabled = settings.haptic_enabled !== false;
|
||||
if (!haptic || !isHapticEnabled || !haptic.selectionChanged) {
|
||||
return;
|
||||
}
|
||||
haptic.selectionChanged();
|
||||
},
|
||||
notificationOccurred: (type) => {
|
||||
const isHapticEnabled = settings.haptic_enabled !== false;
|
||||
if (!haptic || !isHapticEnabled || !haptic.notificationOccurred) {
|
||||
return;
|
||||
}
|
||||
haptic.notificationOccurred(type);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const useCheckoutStore = defineStore('checkout', {
|
||||
state: () => ({
|
||||
form: {},
|
||||
order: null,
|
||||
isLoading: false,
|
||||
validationErrors: {},
|
||||
errorMessage: '',
|
||||
}),
|
||||
|
||||
getters: {
|
||||
hasError: (state) => {
|
||||
return (field) => isNotEmpty(state.validationErrors[field]);
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
async makeOrder() {
|
||||
try {
|
||||
this.errorMessage = '';
|
||||
this.isLoading = true;
|
||||
const data = window.Telegram.WebApp.initDataUnsafe;
|
||||
|
||||
console.log("Allows write to PM: ", data.user.allows_write_to_pm);
|
||||
|
||||
if (!data.user.allows_write_to_pm) {
|
||||
console.log("Sending request");
|
||||
const granted = await new Promise(resolve => {
|
||||
window.Telegram.WebApp.requestWriteAccess((granted) => {
|
||||
resolve(granted);
|
||||
});
|
||||
});
|
||||
|
||||
if (granted) {
|
||||
data.user.allows_write_to_pm = true;
|
||||
console.log('Пользователь разрешил отправку сообщений');
|
||||
} else {
|
||||
alert('Вы не дали разрешение — бот не сможет отправлять вам уведомления');
|
||||
}
|
||||
}
|
||||
|
||||
const response = await storeOrder({
|
||||
...this.form,
|
||||
tgData: data,
|
||||
});
|
||||
this.order = response.data;
|
||||
|
||||
if (!this.order.id) {
|
||||
console.debug(response.data);
|
||||
throw new Error('Ошибка создания заказа.');
|
||||
}
|
||||
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const pulse = usePulseStore();
|
||||
|
||||
await nextTick(() => {
|
||||
pulse.ingest(TC_PULSE_EVENTS.ORDER_CREATED, {
|
||||
order_id: this.order.id,
|
||||
revenue: this.order?.final_total_numeric,
|
||||
currency: this.order?.currency,
|
||||
});
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.ORDER_CREATED_SUCCESS, {
|
||||
price: this.order?.final_total_numeric,
|
||||
currency: this.order?.currency,
|
||||
});
|
||||
yaMetrika.dataLayerPush({
|
||||
"ecommerce": {
|
||||
"currencyCode": useSettingsStore().currency_code,
|
||||
"purchase": {
|
||||
"actionField": {
|
||||
"id": this.order.id,
|
||||
'revenue': this.order?.final_total_numeric,
|
||||
},
|
||||
"products": this.order.products ? this.order.products.map((product, index) => {
|
||||
return {
|
||||
id: product.product_id,
|
||||
name: product.name,
|
||||
price: product.total_numeric,
|
||||
position: index,
|
||||
quantity: product.quantity,
|
||||
};
|
||||
}) : [],
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const haptic = getHapticFeedback();
|
||||
await haptic.notificationOccurred('success');
|
||||
await useCartStore().getProducts();
|
||||
} catch (error) {
|
||||
if (error.response?.status === 422) {
|
||||
this.validationErrors = error.response._data.data;
|
||||
} else {
|
||||
console.error('Server error', error);
|
||||
}
|
||||
|
||||
const haptic = getHapticFeedback();
|
||||
haptic.notificationOccurred('error');
|
||||
|
||||
this.errorMessage = 'Возникла ошибка при создании заказа.';
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
clearError(field) {
|
||||
this.validationErrors[field] = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
16
frontend/spa/src/stores/FormsStore.js
Normal file
16
frontend/spa/src/stores/FormsStore.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import {defineStore} from "pinia";
|
||||
import ftch from "@/utils/ftch.js";
|
||||
|
||||
export const useFormsStore = defineStore('forms', {
|
||||
|
||||
state: () => ({}),
|
||||
|
||||
actions: {
|
||||
async getFormByAlias(alias) {
|
||||
return await ftch('getForm', null, {
|
||||
alias,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
15
frontend/spa/src/stores/KeyboardStore.js
Normal file
15
frontend/spa/src/stores/KeyboardStore.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {ref} from "vue";
|
||||
|
||||
export const useKeyboardStore = defineStore('keyboard', {
|
||||
state: () => ({
|
||||
isOpen: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
setOpen(value) {
|
||||
this.isOpen = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
41
frontend/spa/src/stores/ProductFiltersStore.js
Normal file
41
frontend/spa/src/stores/ProductFiltersStore.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {getFiltersForMainPage} from "@/utils/ftch.js";
|
||||
import {md5} from "js-md5";
|
||||
|
||||
export const useProductFiltersStore = defineStore('product_filters', {
|
||||
state: () => ({
|
||||
isLoading: false,
|
||||
draft: {},
|
||||
applied: {},
|
||||
default: {},
|
||||
fullPath: '',
|
||||
}),
|
||||
|
||||
getters: {
|
||||
paramsHashForRouter: (state) => md5(JSON.stringify({ filters: state.applied })),
|
||||
|
||||
isFiltersChanged: (state) =>
|
||||
md5(JSON.stringify({ filters: state.applied })) !== md5(JSON.stringify({ filters: state.default })),
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchFiltersForMainPage() {
|
||||
if (this.isLoading) return;
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const response = await getFiltersForMainPage();
|
||||
this.default = response.data;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.filters = {};
|
||||
}
|
||||
},
|
||||
});
|
||||
135
frontend/spa/src/stores/ProductsStore.js
Normal file
135
frontend/spa/src/stores/ProductsStore.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import {defineStore} from "pinia";
|
||||
import ftch from "@/utils/ftch.js";
|
||||
import {md5} from 'js-md5';
|
||||
import {toRaw} from "vue";
|
||||
|
||||
export const useProductsStore = defineStore('products', {
|
||||
state: () => ({
|
||||
products: {
|
||||
data: [],
|
||||
meta: {
|
||||
hasMore: true,
|
||||
},
|
||||
},
|
||||
filters: null,
|
||||
filtersFullUrl: '',
|
||||
search: '',
|
||||
page: 1,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
loadFinished: false,
|
||||
savedScrollY: 0,
|
||||
currentLoadedParamsHash: null,
|
||||
lastCategoryId: null,
|
||||
scrollPositions: {}, // Сохраняем позиции скролла для каждой категории
|
||||
}),
|
||||
|
||||
getters: {
|
||||
paramsHash: (state) => md5(JSON.stringify(toRaw(state.getParams()))),
|
||||
paramsHashForRouter: (state) => md5(JSON.stringify({
|
||||
search: state.search,
|
||||
filters: toRaw(state.filters),
|
||||
})),
|
||||
},
|
||||
|
||||
actions: {
|
||||
getParams() {
|
||||
return {
|
||||
page: this.page,
|
||||
search: this.search,
|
||||
filters: toRaw(this.filters),
|
||||
};
|
||||
},
|
||||
|
||||
async fetchProducts() {
|
||||
try {
|
||||
console.debug('Current params hash: ', this.currentLoadedParamsHash);
|
||||
if (this.products.data.length > 0 && this.paramsHash === this.currentLoadedParamsHash) {
|
||||
console.debug('Loading products from cache');
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(this.products);
|
||||
});
|
||||
}
|
||||
|
||||
console.debug('Requested param cache: ', this.paramsHash);
|
||||
console.debug('Invalidate cache. Fetch products from server.', this.getParams());
|
||||
const response = await ftch('products', null, this.getParams());
|
||||
this.currentLoadedParamsHash = this.paramsHash;
|
||||
console.debug('Products loaded from server.');
|
||||
console.debug('New params hash: ', this.currentLoadedParamsHash);
|
||||
|
||||
return {
|
||||
meta: response.meta,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to load products");
|
||||
console.error(error);
|
||||
} finally {
|
||||
}
|
||||
},
|
||||
|
||||
async loadProducts(filters = null) {
|
||||
if (this.isLoading) return;
|
||||
|
||||
try {
|
||||
console.debug('Load products with filters', filters);
|
||||
this.reset();
|
||||
this.isLoading = true;
|
||||
this.page = 1;
|
||||
this.loadFinished = false;
|
||||
this.search = '';
|
||||
this.filters = filters;
|
||||
this.products = await this.fetchProducts();
|
||||
} catch (e) {
|
||||
console.error('Ошибка загрузки', e);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.loadFinished = true;
|
||||
}
|
||||
},
|
||||
|
||||
async loadMore() {
|
||||
if (this.isLoading || this.isLoadingMore || this.products.meta.hasMore === false) return;
|
||||
|
||||
try {
|
||||
this.isLoadingMore = true;
|
||||
this.page++;
|
||||
console.debug('Load more products for page: ', this.page);
|
||||
const response = await this.fetchProducts();
|
||||
this.products.meta = response.meta;
|
||||
this.products.data.push(...response.data);
|
||||
} catch (e) {
|
||||
console.error('Ошибка загрузки', e);
|
||||
} finally {
|
||||
this.isLoadingMore = false;
|
||||
this.loadFinished = true;
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.isLoading = false;
|
||||
this.page = 1;
|
||||
this.loadFinished = false;
|
||||
this.search = '';
|
||||
this.products = {
|
||||
data: [],
|
||||
meta: {
|
||||
hasMore: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
saveScrollPosition(categoryId, position) {
|
||||
if (categoryId) {
|
||||
this.scrollPositions[categoryId] = position;
|
||||
this.savedScrollY = position;
|
||||
}
|
||||
},
|
||||
|
||||
getScrollPosition(categoryId) {
|
||||
return this.scrollPositions[categoryId] || 0;
|
||||
},
|
||||
},
|
||||
});
|
||||
60
frontend/spa/src/stores/Pulse.js
Normal file
60
frontend/spa/src/stores/Pulse.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {ingest, saveTelegramCustomer, heartbeat} from "@/utils/ftch.js";
|
||||
import {toRaw} from "vue";
|
||||
import {deserializeStartParams} from "@/helpers.js";
|
||||
|
||||
export const usePulseStore = defineStore('pulse', {
|
||||
state: () => ({
|
||||
tracking_id: null,
|
||||
campaign_id: null,
|
||||
customer_created_at: null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
initFromStartParams() {
|
||||
const webapp = window.Telegram.WebApp;
|
||||
const startParam = webapp.initDataUnsafe.start_param;
|
||||
const deserialized = deserializeStartParams(startParam);
|
||||
this.tracking_id = deserialized?.tracking_id;
|
||||
this.campaign_id = deserialized?.campaign_id;
|
||||
console.debug('[Pulse] Init with start parameters: ', deserialized);
|
||||
},
|
||||
|
||||
ingest(event, eventData = {}) {
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
ingest({
|
||||
event: event,
|
||||
idempotency_key: idempotencyKey,
|
||||
payload: {
|
||||
webapp: window.Telegram.WebApp,
|
||||
eventData: eventData,
|
||||
},
|
||||
})
|
||||
.then(() => console.debug('[Pulse] Event Ingested', event, eventData, idempotencyKey))
|
||||
.catch(err => console.error('Ingest failed:', err));
|
||||
},
|
||||
|
||||
catchTelegramCustomerFromInitData() {
|
||||
const userData = window.Telegram?.WebApp?.initDataUnsafe?.user;
|
||||
if (userData) {
|
||||
console.debug('[Pulse] Saving Telegram customer data');
|
||||
saveTelegramCustomer(userData)
|
||||
.then((response) => {
|
||||
this.tracking_id = response?.data?.tracking_id || this.tracking_id || null;
|
||||
this.customer_created_at = response?.data?.created_at || null;
|
||||
console.debug(
|
||||
'[Pulse] Telegram customer data saved successfully. Tracking ID: ',
|
||||
toRaw(this.tracking_id)
|
||||
);
|
||||
})
|
||||
.catch(() => console.warn('[Pulse] Failed to save Telegram customer data:', error));
|
||||
}
|
||||
},
|
||||
|
||||
heartbeat() {
|
||||
heartbeat()
|
||||
.then(() => console.debug('[Pulse] Heartbeat sent'))
|
||||
.catch(err => console.warn('[Pulse] Heartbeat failed:', err));
|
||||
}
|
||||
},
|
||||
});
|
||||
132
frontend/spa/src/stores/SearchStore.js
Normal file
132
frontend/spa/src/stores/SearchStore.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import {defineStore} from "pinia";
|
||||
import ftch from "@/utils/ftch.js";
|
||||
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {toRaw} from "vue";
|
||||
|
||||
export const useSearchStore = defineStore('search', {
|
||||
state: () => ({
|
||||
search: '',
|
||||
page: 1,
|
||||
products: {
|
||||
data: [],
|
||||
meta: {},
|
||||
},
|
||||
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
isSearchPerformed: false,
|
||||
hasMore: false,
|
||||
|
||||
// Placeholder товары для пустого состояния поиска
|
||||
placeholderProducts: {
|
||||
data: [],
|
||||
total: 0,
|
||||
},
|
||||
isLoadingPlaceholder: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
reset() {
|
||||
this.search = '';
|
||||
this.isSearchPerformed = false;
|
||||
this.isLoading = false;
|
||||
this.page = 1;
|
||||
this.products = {
|
||||
data: [],
|
||||
meta: {},
|
||||
};
|
||||
},
|
||||
|
||||
async fetchProducts(search, page = 1, perPage = 5) {
|
||||
return await ftch('products', null, {
|
||||
page,
|
||||
perPage: perPage,
|
||||
search,
|
||||
});
|
||||
},
|
||||
|
||||
async performSearch() {
|
||||
if (!this.search) {
|
||||
return this.reset();
|
||||
}
|
||||
|
||||
useYaMetrikaStore().reachGoal(YA_METRIKA_GOAL.PERFORM_SEARCH, {
|
||||
keyword: this.search,
|
||||
});
|
||||
|
||||
try {
|
||||
this.page = 1;
|
||||
this.isLoading = true;
|
||||
const response = await this.fetchProducts(this.search, this.page, 10);
|
||||
console.debug('[Search] Perform Search: ', response);
|
||||
this.products = response;
|
||||
this.hasMore = response?.meta?.hasMore || false;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.isSearchPerformed = true;
|
||||
}
|
||||
},
|
||||
|
||||
async loadMore() {
|
||||
try {
|
||||
if (this.isLoading === true || this.isLoadingMore === true || this.hasMore === false) return;
|
||||
this.isLoadingMore = true;
|
||||
this.page++;
|
||||
|
||||
const data = toRaw({
|
||||
page: this.page,
|
||||
perPage: 10,
|
||||
search: this.search,
|
||||
});
|
||||
console.debug('[Search] Loading more products: ', data);
|
||||
|
||||
const response = await ftch('products', null, data);
|
||||
|
||||
console.debug('[Search] Search results: ', response);
|
||||
|
||||
this.products.data.push(...response.data);
|
||||
this.products.meta = response.meta;
|
||||
this.hasMore = response.meta.hasMore;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.isLoadingMore = false;
|
||||
this.isSearchPerformed = true;
|
||||
}
|
||||
},
|
||||
|
||||
async loadSearchPlaceholder() {
|
||||
// Если данные уже есть в store, возвращаем их
|
||||
if (this.placeholderProducts.data.length > 0) {
|
||||
return {
|
||||
data: this.placeholderProducts.data,
|
||||
meta: {
|
||||
total: this.placeholderProducts.total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
this.isLoadingPlaceholder = true;
|
||||
// Иначе загружаем с сервера
|
||||
const response = await ftch('productsSearchPlaceholder');
|
||||
this.placeholderProducts.data = response.data.slice(0, 3);
|
||||
this.placeholderProducts.total = response?.meta?.total || 0;
|
||||
|
||||
return {
|
||||
data: this.placeholderProducts.data,
|
||||
meta: {
|
||||
total: this.placeholderProducts.total,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
this.isLoadingPlaceholder = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
});
|
||||
60
frontend/spa/src/stores/SettingsStore.js
Normal file
60
frontend/spa/src/stores/SettingsStore.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {fetchSettings} from "@/utils/ftch.js";
|
||||
|
||||
export const useSettingsStore = defineStore('settings', {
|
||||
state: () => ({
|
||||
app_enabled: true,
|
||||
app_debug: false,
|
||||
product_interaction_mode: 'browser',
|
||||
manager_username: null,
|
||||
app_name: 'ECommerce Telegram магазин',
|
||||
app_icon: '',
|
||||
night_auto: true,
|
||||
ya_metrika_enabled: false,
|
||||
feature_coupons: false,
|
||||
feature_vouchers: false,
|
||||
show_category_products_button: true,
|
||||
currency_code: null,
|
||||
haptic_enabled: true,
|
||||
theme: {
|
||||
light: 'light', dark: 'dark', variables: {
|
||||
'--product_list_title_max_lines': 2,
|
||||
}
|
||||
},
|
||||
texts: {
|
||||
text_no_more_products: 'Нет товаров',
|
||||
text_empty_cart: 'Корзина пуста',
|
||||
text_order_created_success: 'Заказ успешно оформлен.',
|
||||
text_manager_button: '💬 Связаться с менеджером',
|
||||
},
|
||||
mainpage_blocks: [],
|
||||
is_privacy_consented: true,
|
||||
privacy_policy_link: false,
|
||||
image_aspect_ratio: '1:1',
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async load() {
|
||||
console.log('Load settings');
|
||||
const settings = await fetchSettings();
|
||||
this.app_name = settings.app_name;
|
||||
this.app_icon = settings.app_icon;
|
||||
this.theme.light = settings.theme_light;
|
||||
this.theme.dark = settings.theme_dark;
|
||||
this.ya_metrika_enabled = settings.ya_metrika_enabled;
|
||||
this.app_enabled = settings.app_enabled;
|
||||
this.app_debug = settings.app_debug;
|
||||
this.product_interaction_mode = settings.product_interaction_mode ?? 'browser';
|
||||
this.manager_username = settings.manager_username;
|
||||
this.feature_coupons = settings.feature_coupons;
|
||||
this.feature_vouchers = settings.feature_vouchers;
|
||||
this.show_category_products_button = settings.show_category_products_button ?? true;
|
||||
this.currency_code = settings.currency_code;
|
||||
this.texts = settings.texts;
|
||||
this.mainpage_blocks = settings.mainpage_blocks;
|
||||
this.privacy_policy_link = settings.privacy_policy_link;
|
||||
this.image_aspect_ratio = settings.image_aspect_ratio;
|
||||
this.haptic_enabled = settings.haptic_enabled ?? true;
|
||||
}
|
||||
}
|
||||
});
|
||||
149
frontend/spa/src/stores/yaMetrikaStore.js
Normal file
149
frontend/spa/src/stores/yaMetrikaStore.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import {toRaw} from "vue";
|
||||
import {usePulseStore} from "@/stores/Pulse.js";
|
||||
|
||||
export const useYaMetrikaStore = defineStore('ya_metrika', {
|
||||
state: () => ({
|
||||
queue: [],
|
||||
prevPath: null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
pushHit(url, params = {}) {
|
||||
if (!useSettingsStore().ya_metrika_enabled) {
|
||||
console.debug('[ym] Yandex Metrika disabled in settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fullUrl = `/#${url}`;
|
||||
|
||||
params.referer = params.referer ?? this.prevPath;
|
||||
|
||||
const pulse = usePulseStore();
|
||||
params.campaign_id = params.campaign_id || pulse.campaign_id || null;
|
||||
params.tracking_id = params.tracking_id || pulse.tracking_id || null;
|
||||
|
||||
if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) {
|
||||
console.debug('[ym] Hit ', fullUrl);
|
||||
console.debug('[ym] ID ', window.YA_METRIKA_ID);
|
||||
console.debug('[ym] params ', params);
|
||||
window.ym(window.YA_METRIKA_ID, 'hit', fullUrl, params);
|
||||
} else {
|
||||
console.debug('[ym] Yandex Metrika is not initialized. Pushed to queue.');
|
||||
this.queue.push({
|
||||
event: 'hit',
|
||||
payload: {
|
||||
fullUrl,
|
||||
params,
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
reachGoal(target, params = {}) {
|
||||
if (!useSettingsStore().ya_metrika_enabled) {
|
||||
console.debug('[ym] Yandex Metrika disabled in settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (! target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pulse = usePulseStore();
|
||||
params.campaign_id = params.campaign_id || pulse.campaign_id || null;
|
||||
params.tracking_id = params.tracking_id || pulse.tracking_id || null;
|
||||
|
||||
if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) {
|
||||
console.debug('[ym] reachGoal ', target, ' params: ', params);
|
||||
window.ym(window.YA_METRIKA_ID, 'reachGoal', target, params);
|
||||
} else {
|
||||
console.debug('[ym] Yandex Metrika is not initialized. Pushed to queue.');
|
||||
this.queue.push({
|
||||
event: 'reachGoal',
|
||||
payload: {
|
||||
target,
|
||||
params
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
initUserParams() {
|
||||
if (!useSettingsStore().ya_metrika_enabled) {
|
||||
console.debug('[ym] Yandex Metrika disabled in settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) {
|
||||
const userParams = {
|
||||
tracking_id: usePulseStore().tracking_id,
|
||||
language: window.Telegram?.WebApp?.initDataUnsafe?.user?.language_code || 'unknown',
|
||||
platform: window.Telegram?.WebApp?.platform || 'unknown',
|
||||
};
|
||||
|
||||
window.ym(window.YA_METRIKA_ID, 'userParams', userParams);
|
||||
console.debug('[ym] User params initialized: ', userParams);
|
||||
} else {
|
||||
console.debug('[ym] Yandex Metrika is not initialized. Could not init user params.');
|
||||
}
|
||||
},
|
||||
|
||||
processQueue() {
|
||||
if (this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('[ym] Start processing queue. Size: ', this.queue.length);
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
const item = this.queue.shift();
|
||||
if (item.event === 'hit') {
|
||||
console.debug('[ym] Queue ', toRaw(item));
|
||||
window.ym(window.YA_METRIKA_ID, 'hit', item.payload.fullUrl, item.payload.params);
|
||||
} else if (item.event === 'reachGoal') {
|
||||
window.ym(window.YA_METRIKA_ID, 'reachGoal', item.payload.target, item.payload.params);
|
||||
} else if (item.event === 'dataLayer') {
|
||||
console.debug('[ym] queue dataLayer push: ', item.payload);
|
||||
window.dataLayer.push(item.payload);
|
||||
} else {
|
||||
console.error('[ym] Unsupported queue event: ', item.event);
|
||||
}
|
||||
}
|
||||
|
||||
console.debug('[ym] Queue processing complete. Size: ', this.queue.length);
|
||||
},
|
||||
|
||||
dataLayerPush(object) {
|
||||
if (!useSettingsStore().ya_metrika_enabled) {
|
||||
console.debug('[ym] Yandex Metrika disabled in settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pulse = usePulseStore();
|
||||
const campaignId = pulse.campaign_id || null;
|
||||
object.ecommerce = object.ecommerce || {};
|
||||
|
||||
if (campaignId) {
|
||||
object.ecommerce.promotions = object.ecommerce.promotions || [];
|
||||
object.ecommerce.promotions.push({ id: campaignId });
|
||||
}
|
||||
|
||||
// Всегда добавляем ключи на верхнем уровне
|
||||
object.campaign_id = campaignId;
|
||||
object.tracking_id = pulse.tracking_id || null;
|
||||
|
||||
if (Array.isArray(window.dataLayer)) {
|
||||
console.debug('[ym] dataLayer push: ', object);
|
||||
window.dataLayer.push(object);
|
||||
} else {
|
||||
console.debug('[ym] dataLayer inaccessible. Put to queue');
|
||||
this.queue.push({
|
||||
event: 'dataLayer',
|
||||
payload: object,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
88
frontend/spa/src/style.css
Normal file
88
frontend/spa/src/style.css
Normal file
@@ -0,0 +1,88 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@plugin "daisyui" {
|
||||
themes: all;
|
||||
}
|
||||
|
||||
/**
|
||||
--color-base-100 - DaisyUI background
|
||||
*/
|
||||
|
||||
html, body, #app {
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
:root {
|
||||
--swiper-pagination-bullet-horizontal-gap: 1px;
|
||||
--swiper-pagination-bullet-size: 6px;
|
||||
--swiper-pagination-color: #777;
|
||||
--swiper-pagination-bottom: 0px;
|
||||
--product_list_title_max_lines: 2;
|
||||
--tc-navbar-min-height: 3rem;
|
||||
--dock-height: 72px;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding-left: calc(
|
||||
var(--tg-content-safe-area-inset-left, 0px)
|
||||
+ var(--tg-safe-area-inset-left, 0px)
|
||||
);
|
||||
padding-right: calc(
|
||||
var(--tg-content-safe-area-inset-right, 0px)
|
||||
+ var(--tg-safe-area-inset-right, 0px)
|
||||
);
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1 1 auto;
|
||||
|
||||
padding-top: calc(
|
||||
var(--tg-content-safe-area-inset-top, 0px)
|
||||
+ var(--tg-safe-area-inset-top, 0px)
|
||||
);
|
||||
|
||||
padding-bottom: calc(
|
||||
var(--dock-height)
|
||||
+ var(--tg-content-safe-area-inset-bottom, 0px)
|
||||
+ var(--tg-safe-area-inset-bottom, 0px)
|
||||
);
|
||||
}
|
||||
|
||||
.safe-top {
|
||||
padding-top: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
|
||||
}
|
||||
|
||||
.app-header {
|
||||
z-index: 60;
|
||||
position: fixed;
|
||||
height: calc(var(--tg-content-safe-area-inset-top, 0px) + var(--tg-safe-area-inset-top, 0px));
|
||||
min-height: calc(var(--tg-content-safe-area-inset-top, 0px) + var(--tg-safe-area-inset-top, 0px));
|
||||
max-height: calc(var(--tg-content-safe-area-inset-top, 0px) + var(--tg-safe-area-inset-top, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
|
||||
.radius-box {
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.bottom-fix {
|
||||
bottom: calc(
|
||||
var(--tg-content-safe-area-inset-bottom, 0px)
|
||||
+ var(--tg-safe-area-inset-bottom, 0px)
|
||||
);
|
||||
}
|
||||
3
frontend/spa/src/translations.js
Normal file
3
frontend/spa/src/translations.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
RULE_PRODUCT_PRICE: 'Цена',
|
||||
};
|
||||
48
frontend/spa/src/utils/AppMetaInitializer.ts
Normal file
48
frontend/spa/src/utils/AppMetaInitializer.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
class AppMetaInitializer {
|
||||
private readonly settings: object;
|
||||
|
||||
constructor(settings: object) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public init() {
|
||||
console.log('Init app meta');
|
||||
document.title = this.settings.app_name;
|
||||
this.setMeta('application-name', this.settings.app_name);
|
||||
this.setMeta('apple-mobile-web-app-title', this.settings.app_name);
|
||||
this.setMeta('mobile-web-app-capable', 'yes');
|
||||
this.setMeta('apple-mobile-web-app-capable', 'yes');
|
||||
this.setMeta('apple-mobile-web-app-status-bar-style', 'default');
|
||||
this.setMeta('theme-color', '#000000');
|
||||
this.setMeta('msapplication-navbutton-color', '#000000');
|
||||
this.setMeta('apple-mobile-web-app-status-bar-style', 'black-translucent');
|
||||
|
||||
// Добавляем apple-touch-icon теги
|
||||
if (this.settings.apple_touch_icon) {
|
||||
this.addLink('apple-touch-icon', this.settings.apple_touch_icon);
|
||||
} else {
|
||||
// Добавляем дефолтный apple-touch-icon, если не указан
|
||||
this.addLink('apple-touch-icon', '/favicon.ico');
|
||||
}
|
||||
}
|
||||
|
||||
private setMeta(name: string, content: string) {
|
||||
let meta = document.querySelector(`meta[name="${name}"]`);
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.setAttribute('name', name);
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.setAttribute('content', content);
|
||||
}
|
||||
|
||||
private addLink(rel: string, href: string, sizes?: string) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = rel;
|
||||
link.href = href;
|
||||
if (sizes) link.sizes = sizes;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
export default AppMetaInitializer;
|
||||
146
frontend/spa/src/utils/ftch.js
Normal file
146
frontend/spa/src/utils/ftch.js
Normal file
@@ -0,0 +1,146 @@
|
||||
import {ofetch} from "ofetch";
|
||||
|
||||
const BASE_URL = '/';
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
function encodeBase64Unicode(str) {
|
||||
return btoa(new TextEncoder().encode(str).reduce((data, byte) => data + String.fromCharCode(byte), ''));
|
||||
}
|
||||
|
||||
export const apiFetch = ofetch.create({
|
||||
throwHttpErrors: true,
|
||||
onRequest({request, options}) {
|
||||
const data = window.Telegram?.WebApp?.initData;
|
||||
|
||||
if (data) {
|
||||
const encoded = encodeBase64Unicode(data);
|
||||
options.headers = {
|
||||
...(isDev && {'XDEBUG_TRIGGER': 'PHPSTORM'}),
|
||||
...options.headers,
|
||||
'X-Telegram-InitData': encoded,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function ftch(action, query = null, json = null) {
|
||||
const options = {
|
||||
method: json ? 'POST' : 'GET',
|
||||
};
|
||||
if (query) options.query = query;
|
||||
if (json) options.body = json;
|
||||
|
||||
const xDebugTrigger = isDev ? '&XDEBUG_TRIGGER=phpstorm' : '';
|
||||
|
||||
return await apiFetch(
|
||||
`${BASE_URL}index.php?route=extension/tgshop/handle&api_action=${action}${xDebugTrigger}`,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
async function ftchPost(action, json = {}) {
|
||||
return ftch(action, null, json);
|
||||
}
|
||||
|
||||
export async function storeOrder(data) {
|
||||
return ftch('storeOrder', null, data);
|
||||
}
|
||||
|
||||
export async function getCart() {
|
||||
return await ftch('getCart');
|
||||
}
|
||||
|
||||
export async function addToCart(data) {
|
||||
return await apiFetch(`${BASE_URL}index.php?route=checkout/cart/add`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cartRemoveItem(data) {
|
||||
return await apiFetch(`${BASE_URL}index.php?route=checkout/cart/remove`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cartEditItem(data) {
|
||||
return await apiFetch(`${BASE_URL}index.php?route=checkout/cart/edit`, {
|
||||
redirect: 'manual',
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSettings() {
|
||||
return await ftch('settings');
|
||||
}
|
||||
|
||||
export async function getFiltersForMainPage() {
|
||||
return await ftch('filtersForMainPage');
|
||||
}
|
||||
|
||||
export async function setCoupon(coupon) {
|
||||
const formData = new FormData();
|
||||
formData.append('coupon', coupon);
|
||||
|
||||
return await apiFetch(`${BASE_URL}index.php?route=extension/total/coupon/coupon`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function setVoucher(voucher) {
|
||||
const formData = new FormData();
|
||||
formData.append('voucher', voucher);
|
||||
|
||||
return await apiFetch(`${BASE_URL}index.php?route=extension/total/voucher/voucher`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function processBlock(block) {
|
||||
return await ftch('processBlock', null, block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить или обновить данные Telegram-пользователя
|
||||
* @param {Object} userData - Данные пользователя из Telegram.WebApp.initDataUnsafe.user
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function saveTelegramCustomer(userData) {
|
||||
return await ftch('saveTelegramCustomer', null, {
|
||||
user: userData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkIsUserPrivacyConsented() {
|
||||
return await ftch('checkIsUserPrivacyConsented');
|
||||
}
|
||||
|
||||
export async function userPrivacyConsent() {
|
||||
return await ftch('userPrivacyConsent');
|
||||
}
|
||||
|
||||
export async function ingest(data) {
|
||||
return await ftchPost('ingest', data);
|
||||
}
|
||||
|
||||
export async function heartbeat() {
|
||||
return await ftch('heartbeat');
|
||||
}
|
||||
|
||||
export async function fetchProductById(productId) {
|
||||
return await ftch('product_show', {
|
||||
id: productId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchProductImages(productId) {
|
||||
return await ftch('getProductImages', {
|
||||
id: productId,
|
||||
});
|
||||
}
|
||||
|
||||
export default ftch;
|
||||
30
frontend/spa/src/utils/yaMetrika.js
Normal file
30
frontend/spa/src/utils/yaMetrika.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
|
||||
function getMetrikaId() {
|
||||
// Пробуем найти все элементы <script> с mc.yandex.ru
|
||||
const scripts = Array.from(document.scripts);
|
||||
for (const s of scripts) {
|
||||
if (s.src.includes('mc.yandex.ru/metrika/tag.js')) {
|
||||
const match = s.src.match(/id=(\d+)/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function injectYaMetrika() {
|
||||
const script = document.createElement('script');
|
||||
script.src = '/index.php?route=extension/tgshop/handle/ya_metrika';
|
||||
// script.async = true;
|
||||
document.head.appendChild(script);
|
||||
console.debug('[ym] Yandex Metrika injected to the page.');
|
||||
|
||||
script.onload = () => {
|
||||
window.YA_METRIKA_ID = getMetrikaId();
|
||||
console.debug('[ym] Detected Yandex.Metrika ID:', window.YA_METRIKA_ID);
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
yaMetrika.initUserParams();
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
yaMetrika.processQueue();
|
||||
}
|
||||
}
|
||||
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>
|
||||
17
frontend/spa/tailwind.config.js
Normal file
17
frontend/spa/tailwind.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
"./formkit.theme.mjs",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
gridTemplateRows: {
|
||||
'[auto,auto,1fr]': 'auto auto 1fr',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("daisyui"),
|
||||
],
|
||||
};
|
||||
43
frontend/spa/tests/setup.js
Normal file
43
frontend/spa/tests/setup.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { expect, afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/vue';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// Очистка после каждого теста
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Моки для Telegram WebApp API
|
||||
global.Telegram = {
|
||||
WebApp: {
|
||||
initData: 'test_init_data',
|
||||
DeviceStorage: {
|
||||
getItem: (key, callback) => {
|
||||
const value = localStorage.getItem(key);
|
||||
callback(null, value);
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
localStorage.setItem(key, value);
|
||||
},
|
||||
deleteItem: (key) => {
|
||||
localStorage.removeItem(key);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Моки для window
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
}),
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user