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:
@@ -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();
|
||||||
|
|||||||
@@ -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}м`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user