Files
interview-demo-code/frontend/spa/src/views/Product.vue
Nikita Kiselev 0e48b9d56d
Some checks failed
Telegram Mini App Shop Builder / Compute version metadata (push) Has been cancelled
Telegram Mini App Shop Builder / Run Frontend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run Backend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Has been cancelled
Telegram Mini App Shop Builder / Build module. (push) Has been cancelled
Telegram Mini App Shop Builder / release (push) Has been cancelled
Squashed commit message
2026-03-11 22:17:44 +03:00

415 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>