Files
interview-demo-code/frontend/spa/src/views/Search.vue
Nikita Kiselev f329bfa9d9
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 23:00:23 +03:00

227 lines
8.3 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 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>