feat(search): improve search UI with sticky bar and keyboard handling

- Add fixed search bar with glassmorphism effect (backdrop blur, semi-transparent)
- Implement clear search button in DaisyUI style
- Auto-hide keyboard on Enter key press and scroll events
- Remove search page title for cleaner UI
- Use DaisyUI theme-aware background colors instead of fixed white
- Add fixed padding offset for content below search bar
This commit is contained in:
2025-12-01 21:55:16 +03:00
parent cedc49f0d5
commit 64ead29583
6 changed files with 210 additions and 53 deletions

View File

@@ -68,8 +68,6 @@ import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import IconFunnel from "@/components/Icons/IconFunnel.vue"; import IconFunnel from "@/components/Icons/IconFunnel.vue";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js"; import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
import {useRouter} from "vue-router"; import {useRouter} from "vue-router";
import ProductTitle from "@/components/ProductItem/ProductTitle.vue";
import Price from "@/components/ProductItem/Price.vue";
import PriceTitle from "@/components/ProductItem/PriceTitle.vue"; import PriceTitle from "@/components/ProductItem/PriceTitle.vue";
const router = useRouter(); const router = useRouter();

View File

@@ -123,3 +123,31 @@ export function deserializeStartParams(serialized) {
return parameters; 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}м`;
}

View File

@@ -2,6 +2,7 @@ import {defineStore} from "pinia";
import ftch from "@/utils/ftch.js"; import ftch from "@/utils/ftch.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js"; import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js"; import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {toRaw} from "vue";
export const useSearchStore = defineStore('search', { export const useSearchStore = defineStore('search', {
state: () => ({ state: () => ({
@@ -13,7 +14,9 @@ export const useSearchStore = defineStore('search', {
}, },
isLoading: false, isLoading: false,
isLoadingMore: false,
isSearchPerformed: false, isSearchPerformed: false,
hasMore: false,
}), }),
actions: { actions: {
@@ -28,6 +31,14 @@ export const useSearchStore = defineStore('search', {
}; };
}, },
async fetchProducts(search, page = 1, perPage = 5) {
return await ftch('products', {
page,
perPage: perPage,
search,
});
},
async performSearch() { async performSearch() {
if (!this.search) { if (!this.search) {
return this.reset(); return this.reset();
@@ -39,11 +50,10 @@ export const useSearchStore = defineStore('search', {
try { try {
this.isLoading = true; this.isLoading = true;
this.products = await ftch('products', { const response = await this.fetchProducts(this.search, this.page, 10);
page: this.page, console.debug('[Search] Perform Search: ', response);
perPage: 10, this.products = response;
search: this.search, this.hasMore = response?.meta?.hasMore || false;
});
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} finally { } finally {
@@ -51,6 +61,34 @@ export const useSearchStore = defineStore('search', {
this.isSearchPerformed = true; this.isSearchPerformed = true;
} }
}, },
async loadMore() {
try {
if (this.isLoading === true || this.isLoadingMore === true || this.hasMore === false) return;
this.isLoadingMore = true;
this.page++;
console.debug('[Search] Loading more products for page: ', this.page);
const response = await ftch('products', null, toRaw({
page: this.page,
perPage: 10,
search: this.search,
}));
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;
}
},
}, },
}); });

View File

@@ -1,6 +1,8 @@
<template> <template>
<BaseViewWrapper title="Поиск"> <div>
<label class="input w-full mb-4"> <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"> <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g <g
stroke-linejoin="round" stroke-linejoin="round"
@@ -19,29 +21,35 @@
class="grow input-lg" class="grow input-lg"
placeholder="Поиск по магазину" placeholder="Поиск по магазину"
v-model="searchStore.search" v-model="searchStore.search"
@search="debouncedSearch" @search="handleSearch"
@keydown.enter="handleEnter"
@input="debouncedSearch" @input="debouncedSearch"
/> />
</label> </label>
<button
<div v-if="searchStore.isLoading === false && searchStore.products.data.length > 0"> v-if="searchStore.search"
<RouterLink @click="clearSearch"
v-for="product in searchStore.products.data" class="btn btn-circle btn-ghost"
:key="product.id" type="button"
class="flex mb-5" aria-label="Очистить поиск"
:to="{name: 'product.show', params: {id: product.id}}"
> >
<div v-if="product.images && product.images.length > 0" class="avatar"> <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">
<div class="w-24 rounded"> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
<img :src="product.images[0].url" :alt="product.images[0].alt"/> </svg>
</button>
</div> </div>
</div> </div>
<div class="ml-5 p-0"> <BaseViewWrapper>
<h2 class="card-title">{{ product.name }}</h2> <div class="pt-24">
<Price :price="product.price" :special="product.special"/> <div v-if="searchStore.isLoading === false && searchStore.products.data.length > 0">
</div> <ProductsList
</RouterLink> :products="searchStore.products.data"
:hasMore="searchStore.hasMore"
:isLoading="searchStore.isLoading"
:isLoadingMore="searchStore.isLoadingMore"
@loadMore="searchStore.loadMore"
/>
</div> </div>
<div v-if="searchStore.isLoading === true"> <div v-if="searchStore.isLoading === true">
@@ -63,24 +71,97 @@
<h2 class="text-xl font-semibold mb-2">Товары не найдены</h2> <h2 class="text-xl font-semibold mb-2">Товары не найдены</h2>
<p class="text-sm mb-4">Попробуйте изменить или уточнить запрос</p> <p class="text-sm mb-4">Попробуйте изменить или уточнить запрос</p>
</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 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> </BaseViewWrapper>
</div>
</template> </template>
<script setup> <script setup>
import {useSearchStore} from "@/stores/SearchStore.js"; import {useSearchStore} from "@/stores/SearchStore.js";
import {useDebounceFn} from "@vueuse/core"; import {useDebounceFn} from "@vueuse/core";
import {onMounted, ref} from "vue"; import {computed, onMounted, onUnmounted, ref} from "vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js"; import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {useRoute} from "vue-router"; import {useRoute} from "vue-router";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js"; import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
import BaseViewWrapper from "@/views/BaseViewWrapper.vue"; import BaseViewWrapper from "@/views/BaseViewWrapper.vue";
import Price from "@/components/ProductItem/Price.vue"; import ProductsList from "@/components/ProductsList.vue";
import {renderSmartNumber} from "../helpers.js";
const route = useRoute(); const route = useRoute();
const yaMetrika = useYaMetrikaStore(); const yaMetrika = useYaMetrikaStore();
const searchStore = useSearchStore(); const searchStore = useSearchStore();
const searchInput = ref(null); const searchInput = ref(null);
const debouncedSearch = useDebounceFn(() => searchStore.performSearch(), 500); 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();
};
const handleScroll = useDebounceFn(() => {
hideKeyboard();
}, 100);
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 () => { onMounted(async () => {
window.document.title = 'Поиск'; window.document.title = 'Поиск';
@@ -88,5 +169,15 @@ onMounted(async () => {
title: 'Поиск', title: 'Поиск',
}); });
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_SEARCH); yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_SEARCH);
const response = await searchStore.fetchProducts('', 1, 3);
productsTotal.value = response?.meta?.total || 0;
preloadedProducts.value = response.data;
window.addEventListener('scroll', handleScroll, { passive: true });
});
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll);
}); });
</script> </script>

View File

@@ -30,7 +30,7 @@ class ProductsHandler
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$page = (int) $request->json('page', 1); $page = (int) $request->json('page', 1);
$perPage = min((int) $request->json('perPage', 6), 15); $perPage = min((int) $request->json('perPage', 3), 15);
$maxPages = (int) $request->json('maxPages', 10); $maxPages = (int) $request->json('maxPages', 10);
$search = trim($request->get('search', '')); $search = trim($request->get('search', ''));
$filters = $request->json('filters'); $filters = $request->json('filters');

View File

@@ -200,6 +200,7 @@ class ProductsService
'name' => Utils::htmlEntityEncode($product['product_name']), 'name' => Utils::htmlEntityEncode($product['product_name']),
'price' => $price, 'price' => $price,
'special' => $special, 'special' => $special,
'image' => $image,
'images' => $allImages, 'images' => $allImages,
'special_numeric' => $specialPriceNumeric, 'special_numeric' => $specialPriceNumeric,
'price_numeric' => $priceNumeric, 'price_numeric' => $priceNumeric,
@@ -213,6 +214,7 @@ class ProductsService
'currentCategoryName' => $categoryName, 'currentCategoryName' => $categoryName,
'hasMore' => $hasMore, 'hasMore' => $hasMore,
'debug' => $debug, 'debug' => $debug,
'total' => $total,
] ]
]; ];
} }