216 lines
7.8 KiB
Vue
216 lines
7.8 KiB
Vue
<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">
|
||
<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>
|
||
</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 = ref([]);
|
||
const productsTotal = ref(0);
|
||
|
||
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);
|
||
|
||
const response = await searchStore.fetchProducts('', 1, 3);
|
||
productsTotal.value = response?.meta?.total || 0;
|
||
preloadedProducts.value = response.data.splice(0, 3);
|
||
});
|
||
</script>
|