Files
tp/main_dc/yalarba/yalarba-nuxt/app/pages/search/index.vue
T
2026-06-12 00:29:34 +05:00

380 lines
10 KiB
Vue

<template>
<div class="search-page">
<section class="search-hero">
<div class="container">
<div class="search-bar">
<div class="search-bar__input">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
<path d="M20 20l-3.5-3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<input
v-model="searchQuery"
type="text"
placeholder="Поиск достопримечательностей, маршрутов, мест..."
@keyup.enter="doSearch"
/>
</div>
<button class="btn btn--primary btn--md" @click="doSearch">
Найти
</button>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="search-layout">
<aside class="search-filters">
<h5>Фильтры</h5>
<div class="filter-group">
<label class="form-label">Категория</label>
<select v-model="searchStore.filters.category_id" class="form-select" @change="doSearch">
<option :value="null">Все категории</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</option>
</select>
</div>
<div class="filter-group">
<label class="form-label">Город</label>
<input
v-model="searchStore.filters.city"
type="text"
class="form-input"
placeholder="Город"
@input="doSearch"
/>
</div>
<div class="filter-group">
<label class="form-label">Сортировка</label>
<select v-model="searchStore.filters.sort_by" class="form-select" @change="doSearch">
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<button class="btn btn--ghost btn--sm btn--full" @click="resetFilters">
Сбросить фильтры
</button>
</aside>
<div class="search-results">
<div class="search-results__header">
<p class="body-text--gray">
Найдено: <strong>{{ searchStore.total }}</strong>
</p>
</div>
<div v-if="searchStore.loading" class="search-results__grid">
<div v-for="n in 6" :key="n" class="card">
<div class="skeleton" style="height: 180px" />
<div style="padding: 16px">
<div class="skeleton" style="height: 20px; width: 80%; margin-bottom: 8px" />
<div class="skeleton" style="height: 16px; width: 60%" />
</div>
</div>
</div>
<div
v-else-if="searchStore.results.length"
class="search-results__grid"
>
<NuxtLink
v-for="obj in searchStore.results"
:key="obj.id"
:to="`/objects/${obj.id}`"
class="card object-card"
>
<div class="object-card__image">
<img
v-if="obj.images?.[0]"
:src="obj.images[0]"
:alt="obj.title"
/>
<div v-else class="object-card__placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/>
<circle cx="8.5" cy="8.5" r="1.5" stroke="currentColor" stroke-width="2"/>
<path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
</div>
<div class="object-card__info">
<h5 class="object-card__title">{{ obj.title }}</h5>
<p v-if="obj.short_description" class="small-text object-card__desc">
{{ obj.short_description }}
</p>
<div class="object-card__meta">
<span v-if="obj.rating" class="object-card__rating">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#FF8833">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
{{ obj.rating.toFixed(1) }}
</span>
<span v-if="obj.category_name" class="tag">{{ obj.category_name }}</span>
</div>
</div>
</NuxtLink>
</div>
<div v-else-if="hasSearched" class="search-results__empty">
<p class="body-text--gray">Ничего не найдено</p>
<p class="small-text">Попробуйте изменить параметры поиска</p>
</div>
<div v-else class="search-results__empty">
<p class="body-text--gray">Введите запрос для поиска</p>
</div>
<div
v-if="searchStore.totalPages > 1"
class="search-results__pagination"
>
<button
class="btn btn--ghost btn--sm"
:disabled="searchStore.page <= 1"
@click="changePage(searchStore.page - 1)"
>
Назад
</button>
<span class="small-text">
{{ searchStore.page }} / {{ searchStore.totalPages }}
</span>
<button
class="btn btn--ghost btn--sm"
:disabled="searchStore.page >= searchStore.totalPages"
@click="changePage(searchStore.page + 1)"
>
Вперёд
</button>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { CATEGORIES, SORT_OPTIONS } from '~/utils/constants'
definePageMeta({
title: 'Поиск',
})
const route = useRoute()
const searchStore = useSearchStore()
const searchQuery = ref((route.query.query as string) || '')
const hasSearched = ref(false)
const categories = CATEGORIES
const sortOptions = SORT_OPTIONS
onMounted(async () => {
if (route.query.query || route.query.category_id) {
searchQuery.value = (route.query.query as string) || searchStore.query
if (route.query.category_id) {
searchStore.setFilter('category_id', Number(route.query.category_id))
}
hasSearched.value = true
await searchStore.search({ query: searchQuery.value })
}
})
async function doSearch() {
hasSearched.value = true
await searchStore.search({ query: searchQuery.value })
}
function resetFilters() {
searchStore.resetFilters()
doSearch()
}
async function changePage(page: number) {
await searchStore.search({ query: searchQuery.value, page })
}
</script>
<style scoped>
.search-hero {
background: var(--color-primary);
padding: 32px 0;
}
.search-bar {
display: flex;
gap: 12px;
}
.search-bar__input {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
background: var(--color-white);
border-radius: var(--radius-sm);
padding: 0 16px;
}
.search-bar__input svg {
color: var(--color-text-gray);
flex-shrink: 0;
}
.search-bar__input input {
flex: 1;
height: 48px;
border: none;
outline: none;
font-family: var(--font-body);
font-size: var(--font-size-body);
color: var(--color-text-black);
background: transparent;
}
.search-bar__input input::placeholder {
color: var(--color-text-gray);
}
.search-layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 32px;
align-items: start;
}
.search-filters {
background: var(--color-white);
border: 1px solid var(--color-stroke);
border-radius: var(--radius-md);
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
position: sticky;
top: calc(var(--header-height) + 24px);
}
.filter-group {
display: flex;
flex-direction: column;
}
.search-results__header {
margin-bottom: 16px;
}
.search-results__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.object-card {
cursor: pointer;
}
.object-card__image {
height: 180px;
overflow: hidden;
}
.object-card__image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.object-card__placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-gray);
color: var(--color-text-gray);
}
.object-card__info {
padding: 12px;
}
.object-card__title {
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.object-card__desc {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 8px;
}
.object-card__meta {
display: flex;
align-items: center;
gap: 8px;
}
.object-card__rating {
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-body);
font-size: var(--font-size-small);
font-weight: var(--font-weight-semibold);
color: var(--color-text-black);
}
.search-results__empty {
text-align: center;
padding: 60px 0;
}
.search-results__pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 32px;
}
@media (max-width: 1024px) {
.search-results__grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 744px) {
.search-layout {
grid-template-columns: 1fr;
}
.search-filters {
position: static;
}
.search-results__grid {
grid-template-columns: 1fr;
}
.search-bar {
flex-direction: column;
}
.search-bar .btn {
width: 100%;
}
}
</style>