973 lines
34 KiB
Vue
973 lines
34 KiB
Vue
<template>
|
||
<div class="relative w-full max-w-xl mx-auto">
|
||
<!-- Main Search Container -->
|
||
<div class="relative">
|
||
<!-- Input Field -->
|
||
<div class="relative">
|
||
<div
|
||
class="relative bg-white dark:bg-dark-primary border border-gray-300 dark:border-dark-primary-700 rounded-lg transition-all duration-200"
|
||
>
|
||
<!-- Input Row -->
|
||
<div class="flex items-center">
|
||
<!-- Left Side: Icons + Filter Dropdown -->
|
||
<div class="flex items-center space-x-2 space-x-reverse ml-3">
|
||
<!-- Search Icon -->
|
||
<!-- <UIcon
|
||
name="i-heroicons-magnifying-glass"
|
||
class="w-5 h-5 text-gray-400 dark:text-gray-500 transition-colors duration-200"
|
||
:class="{
|
||
'text-blue-500 dark:text-blue-400': isFocused || searchQuery,
|
||
}"
|
||
/> -->
|
||
|
||
<!-- Filter Dropdown Trigger -->
|
||
<div class="relative">
|
||
<!-- Trigger Button با آیکون -->
|
||
<button
|
||
@click.stop="toggleFilterDropdown"
|
||
class="flex items-center gap-1 px-3 py-1.5 text-sm text-primary-700 hover:text-primary-900 hover:bg-primary-50 rounded-lg transition-all duration-200"
|
||
>
|
||
{{ selectedFilterLabel }}
|
||
<!-- آیکون Chevron با انیمیشن -->
|
||
<svg
|
||
class="w-4 h-4 transition-transform duration-200"
|
||
:class="{ 'rotate-180': filterDropdownOpen }"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M19 9l-7 7-7-7"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- Dropdown Menu -->
|
||
<Transition
|
||
enter-active-class="transition ease-out duration-150"
|
||
enter-from-class="opacity-0 -translate-y-1"
|
||
enter-to-class="opacity-100 translate-y-0"
|
||
leave-active-class="transition ease-in duration-100"
|
||
leave-from-class="opacity-100 translate-y-0"
|
||
leave-to-class="opacity-0 -translate-y-1"
|
||
>
|
||
<div
|
||
v-if="filterDropdownOpen"
|
||
class="absolute top-full left-0 mt-1.5 w-36 bg-white border border-primary-200 rounded-lg shadow-sm z-50"
|
||
>
|
||
<div class="py-1.5">
|
||
<button
|
||
v-for="filter in quickFilters"
|
||
:key="filter.value"
|
||
@click="selectFilterFromDropdown(filter)"
|
||
class="w-full px-4 py-2 text-sm text-primary-700 hover:bg-primary-50 hover:text-primary-900 text-right transition-colors duration-150"
|
||
:class="{
|
||
'text-blue-600': selectedFilter === filter.value,
|
||
}"
|
||
>
|
||
{{ filter.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
<!-- Selected Filter Badge -->
|
||
<!-- <div
|
||
v-if="selectedFilter !== 'all'"
|
||
class="flex items-center space-x-1 space-x-reverse px-2 py-1 bg-blue-50 dark:bg-blue-900/30 rounded-md animate-fade-in"
|
||
>
|
||
<UIcon
|
||
:name="selectedFilterIcon"
|
||
class="w-3 h-3 text-blue-500 dark:text-blue-400"
|
||
/>
|
||
<span class="text-xs text-blue-600 dark:text-blue-300">{{
|
||
selectedFilterLabel
|
||
}}</span>
|
||
<button
|
||
@click.stop="clearFilter"
|
||
class="text-blue-400 dark:text-blue-300 hover:text-blue-600 dark:hover:text-blue-100"
|
||
>
|
||
<UIcon name="i-heroicons-x-mark" class="w-3 h-3" />
|
||
</button>
|
||
</div> -->
|
||
</div>
|
||
|
||
<!-- Main Input -->
|
||
<input
|
||
ref="inputRef"
|
||
v-model="searchQuery"
|
||
:placeholder="isFocused && !searchQuery ? placeholder : ''"
|
||
type="text"
|
||
autocomplete="off"
|
||
spellcheck="false"
|
||
class="flex-1 bg-transparent text-dark-light dark:text-white-light placeholder:text-dark-light dark:placeholder:text-white-light focus:outline-none text-sm w-full"
|
||
@focus="handleFocus"
|
||
@blur="handleBlur"
|
||
@input="handleInput"
|
||
@keydown="handleKeyDown"
|
||
@keyup.enter="handleEnter"
|
||
/>
|
||
|
||
<!-- Right Side Actions -->
|
||
<div class="flex items-center space-x-2 space-x-reverse">
|
||
<!-- <div v-if="loading" class="flex items-center justify-center">
|
||
<div
|
||
class="w-4 h-4 border-2 border-gray-300 dark:border-dark-primary-600 border-t-blue-500 dark:border-t-blue-400 rounded-full animate-spin"
|
||
></div>
|
||
</div> -->
|
||
<button
|
||
v-if="searchQuery"
|
||
@click="clearSearch"
|
||
class="flex items-center justify-center w-6 h-6 rounded-full hover:bg-gray-100 dark:hover:bg-dark-primary-800 transition-colors duration-200"
|
||
aria-label="پاک کردن"
|
||
>
|
||
<UIcon
|
||
name="i-heroicons-x-mark"
|
||
class="w-3 h-3 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||
/>
|
||
</button>
|
||
<button
|
||
:disabled="loading"
|
||
@click="handleSearch"
|
||
class="cursor-pointer flex items-center justify-center h-10 p-2 bg-primary text-white rounded-l-lg hover:bg-primary-400 dark:hover:bg-dark-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 active:scale-95"
|
||
aria-label="جستجو"
|
||
>
|
||
جستجو
|
||
<!-- <UIcon name="i-heroicons-arrow-left" class="w-4 h-4" /> -->
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Character Counter -->
|
||
<!-- <div v-if="searchQuery" class="absolute left-3 -bottom-5">
|
||
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||
{{ searchQuery.length }}/{{ resolvedProps.value.maxChars || 100 }}
|
||
</span>
|
||
</div> -->
|
||
</div>
|
||
|
||
<!-- Results Dropdown -->
|
||
<Transition
|
||
enter-active-class="transition-all duration-200 ease-out"
|
||
enter-from-class="opacity-0 transform -translate-y-2"
|
||
enter-to-class="opacity-100 transform translate-y-0"
|
||
leave-active-class="transition-all duration-150 ease-in"
|
||
leave-from-class="opacity-100 transform translate-y-0"
|
||
leave-to-class="opacity-0 transform -translate-y-2"
|
||
>
|
||
<div
|
||
v-if="hasVisibleResults"
|
||
class="absolute z-50 w-full mt-2 bg-white dark:bg-dark-primary border border-gray-200 dark:border-dark-primary-700 rounded-lg shadow-lg backdrop-blur-sm bg-opacity-95 dark:bg-opacity-95 overflow-hidden"
|
||
>
|
||
<!-- Header -->
|
||
<div
|
||
class="px-4 py-3 bg-gray-100 dark:bg-dark-primary-800 border-b border-gray-100 dark:border-dark-primary-800"
|
||
>
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center space-x-2 space-x-reverse">
|
||
<UIcon
|
||
:name="
|
||
showAutocompleteList
|
||
? 'i-heroicons-sparkles'
|
||
: 'i-heroicons-clock'
|
||
"
|
||
class="w-4 h-4 ml-1"
|
||
:class="
|
||
showAutocompleteList ? 'text-purple-500' : 'text-blue-500'
|
||
"
|
||
/>
|
||
<span
|
||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||
>
|
||
{{ showAutocompleteList ? "پیشنهادات" : "تاریخچه" }}
|
||
<!-- <span
|
||
v-if="selectedFilter !== 'all'"
|
||
class="mr-2 inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-300"
|
||
>
|
||
<UIcon :name="selectedFilterIcon" class="w-3 h-3 ml-1" />
|
||
{{ selectedFilterLabel }}
|
||
</span> -->
|
||
</span>
|
||
</div>
|
||
<span class="text-xs text-gray-500 dark:text-gray-400"
|
||
>{{ totalResults }} مورد</span
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Results List -->
|
||
<div class="max-h-80 overflow-y-auto custom-scrollbar">
|
||
<!-- Autocomplete -->
|
||
<div v-if="showAutocompleteList">
|
||
<div
|
||
v-for="(item, index) in autocompleteResults"
|
||
:key="`autocomplete-${item.id || index}`"
|
||
class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-dark-primary-800 cursor-pointer border-b border-gray-100 dark:border-dark-primary-800 last:border-b-0 transition-colors duration-150"
|
||
:class="{
|
||
'bg-blue-50 dark:bg-blue-900/20':
|
||
selectedIndex === index && activeSection === 'autocomplete',
|
||
}"
|
||
@click="handleAutocompleteSelect(item)"
|
||
@mouseenter="
|
||
hoverIndex = index;
|
||
activeSection = 'autocomplete';
|
||
"
|
||
>
|
||
<div class="flex items-start justify-between">
|
||
<div class="flex-1 min-w-0">
|
||
<div
|
||
class="flex items-center space-x-2 space-x-reverse mb-1"
|
||
>
|
||
<UIcon
|
||
:name="item.icon || 'i-heroicons-magnifying-glass'"
|
||
class="w-4 h-4 text-gray-400 dark:text-gray-500 flex-shrink-0"
|
||
/>
|
||
<span
|
||
class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate"
|
||
>
|
||
{{ item.label || item.value || item }}
|
||
</span>
|
||
</div>
|
||
<p
|
||
v-if="item.description"
|
||
class="text-xs text-gray-500 dark:text-gray-400 line-clamp-2"
|
||
>
|
||
{{ item.description }}
|
||
</p>
|
||
</div>
|
||
<UIcon
|
||
name="i-heroicons-chevron-left"
|
||
class="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0"
|
||
:class="{
|
||
'text-blue-500 dark:text-blue-400':
|
||
selectedIndex === index &&
|
||
activeSection === 'autocomplete',
|
||
}"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- History -->
|
||
<div v-if="showHistoryList">
|
||
<div
|
||
v-for="(item, index) in historyResults"
|
||
:key="`history-${item.id || index}`"
|
||
class="group px-4 py-3 hover:bg-gray-50 dark:hover:bg-dark-primary-800 cursor-pointer border-b border-gray-100 dark:border-dark-primary-800 last:border-b-0 transition-colors duration-150"
|
||
:class="{
|
||
'bg-blue-50 dark:bg-blue-900/20':
|
||
selectedIndex === autocompleteResults.length + index &&
|
||
activeSection === 'history',
|
||
}"
|
||
@click="handleHistorySelect(item)"
|
||
@mouseenter="
|
||
hoverIndex = autocompleteResults.length + index;
|
||
activeSection = 'history';
|
||
"
|
||
>
|
||
<div class="flex items-center justify-between">
|
||
<div
|
||
class="flex items-center space-x-3 space-x-reverse flex-1 min-w-0"
|
||
>
|
||
<div class="relative ml-2">
|
||
<div
|
||
class="w-8 h-8 bg-gray-100 dark:bg-dark-primary-800 rounded-lg flex items-center justify-center"
|
||
>
|
||
<UIcon
|
||
name="i-heroicons-clock"
|
||
class="w-4 h-4 text-gray-400 dark:text-gray-500"
|
||
/>
|
||
</div>
|
||
<div
|
||
v-if="item.count && item.count > 1"
|
||
class="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 dark:bg-blue-400 rounded-full flex items-center justify-center"
|
||
>
|
||
<span class="text-[9px] text-white font-medium">{{
|
||
item.count
|
||
}}</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<p
|
||
class="text-sm text-gray-800 dark:text-gray-200 truncate"
|
||
>
|
||
{{ item.label || item.value || item }}
|
||
</p>
|
||
<p
|
||
v-if="item.timestamp"
|
||
class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"
|
||
>
|
||
{{ formatTimeAgo(item.timestamp) }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
@click.stop="removeHistoryItem(item, index)"
|
||
class="opacity-0 group-hover:opacity-100 p-1 text-gray-400 dark:text-gray-500 hover:text-red-500 dark:hover:text-red-400 transition-all duration-200"
|
||
>
|
||
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Empty History -->
|
||
<div
|
||
v-if="historyResults.length === 0"
|
||
class="px-4 py-8 text-center"
|
||
>
|
||
<UIcon
|
||
name="i-heroicons-clock"
|
||
class="w-8 h-8 text-gray-300 dark:text-gray-600 mx-auto mb-3"
|
||
/>
|
||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||
تاریخچه خالی است
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Empty State -->
|
||
<div v-if="showEmptyState" class="px-4 py-8 text-center">
|
||
<UIcon
|
||
name="i-heroicons-magnifying-glass"
|
||
class="w-8 h-8 text-gray-300 dark:text-gray-600 mx-auto mb-3"
|
||
/>
|
||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||
نتیجهای یافت نشد
|
||
</p>
|
||
<p class="text-xs text-gray-500 dark:text-gray-500">
|
||
عبارت جستجو را تغییر دهید
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<!-- Clear All History -->
|
||
<div
|
||
v-if="historyResults.length > 0"
|
||
class="px-4 py-3 bg-gray-100 dark:bg-dark-primary-800 border-t border-gray-100 dark:border-dark-primary-800"
|
||
>
|
||
<button
|
||
@click="clearAllHistory"
|
||
class="w-full cursor-pointer flex items-center justify-center space-x-2 space-x-reverse text-sm text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors duration-200"
|
||
>
|
||
<UIcon name="i-heroicons-trash" class="w-4 h-4 ml-1" />
|
||
<span>پاک کردن تاریخچه</span>
|
||
</button>
|
||
</div>
|
||
<!-- Footer -->
|
||
<!-- <div
|
||
v-if="
|
||
hasVisibleResults && (showAutocompleteList || showHistoryList)
|
||
"
|
||
class="px-4 py-2 border-t border-gray-100 dark:border-dark-primary-800 bg-gray-50 dark:bg-dark-primary-800/50"
|
||
>
|
||
<div
|
||
class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
|
||
>
|
||
<div class="flex items-center space-x-4 space-x-reverse">
|
||
<span class="flex items-center space-x-1 space-x-reverse">
|
||
<UIcon name="i-heroicons-arrow-up" class="w-3 h-3" />
|
||
<UIcon name="i-heroicons-arrow-down" class="w-3 h-3" />
|
||
<span>ناوبری</span>
|
||
</span>
|
||
<span class="flex items-center space-x-1 space-x-reverse">
|
||
<UIcon name="i-heroicons-enter" class="w-3 h-3" />
|
||
<span>انتخاب</span>
|
||
</span>
|
||
</div>
|
||
<span>↵ Enter برای جستجو</span>
|
||
</div>
|
||
</div> -->
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
|
||
<!-- Recent Searches (خارج از دراپداون) -->
|
||
<!-- <div
|
||
v-if="
|
||
showRecentSearches &&
|
||
historyResults.length > 0 &&
|
||
!searchQuery &&
|
||
!isFocused
|
||
"
|
||
class="mt-4 animate-fade-in"
|
||
>
|
||
<div
|
||
class="flex items-center space-x-2 space-x-reverse overflow-x-auto py-2"
|
||
>
|
||
<span class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"
|
||
>اخیراً:</span
|
||
>
|
||
<button
|
||
v-for="(item, index) in recentSearches"
|
||
:key="index"
|
||
@click="selectRecentSearch(item)"
|
||
class="flex-shrink-0 px-3 py-1 text-xs bg-gray-100 dark:bg-dark-primary-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-dark-primary-700 rounded-full transition-colors duration-200 truncate max-w-xs"
|
||
>
|
||
{{ item }}
|
||
</button>
|
||
</div>
|
||
</div> -->
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||
|
||
const props = defineProps({
|
||
autoComplationSchema: {
|
||
type: Object,
|
||
default: () => ({}),
|
||
},
|
||
|
||
placeholder: { type: String, default: "جستجو در هزاران محتوا" },
|
||
autocompleteUrl: { type: String, required: true },
|
||
historyUrl: { type: String, required: true },
|
||
showSearchButton: { type: Boolean, default: true },
|
||
debounceTime: { type: Number, default: 500 },
|
||
maxHistoryItems: { type: Number, default: 10 },
|
||
minCharsForAutocomplete: { type: Number, default: 2 },
|
||
maxChars: { type: Number, default: 100 },
|
||
filters: { type: Array, default: () => [] },
|
||
});
|
||
|
||
const resolvedProps = computed(() => ({
|
||
placeholder: props.autoComplationSchema.placeholder ?? props.placeholder,
|
||
|
||
autocompleteUrl:
|
||
props.autoComplationSchema.autocompleteUrl ?? props.autocompleteUrl,
|
||
|
||
historyUrl: props.autoComplationSchema.historyUrl ?? props.historyUrl,
|
||
|
||
showSearchButton:
|
||
props.autoComplationSchema.showSearchButton ?? props.showSearchButton,
|
||
|
||
debounceTime: props.autoComplationSchema.debounceTime ?? props.debounceTime,
|
||
|
||
maxHistoryItems:
|
||
props.autoComplationSchema.maxHistoryItems ?? props.maxHistoryItems,
|
||
|
||
minCharsForAutocomplete:
|
||
props.autoComplationSchema.minCharsForAutocomplete ??
|
||
props.minCharsForAutocomplete,
|
||
|
||
maxChars: props.autoComplationSchema.maxChars ?? props.maxChars,
|
||
|
||
filters: props.autoComplationSchema.filters ?? props.filters,
|
||
}));
|
||
|
||
// const emit = defineEmits([
|
||
// "search",
|
||
// "autocomplete-select",
|
||
// "history-select",
|
||
// "history-clear",
|
||
// "history-remove",
|
||
// "focus",
|
||
// "blur",
|
||
// "clear",
|
||
// "filter-selected",
|
||
// "complete-search",
|
||
// ]);
|
||
const emit = defineEmits(["auto-complation-handler"]);
|
||
|
||
// States
|
||
const searchQuery = ref("");
|
||
const autocompleteResults = ref([]);
|
||
const historyResults = ref([]);
|
||
const recentSearches = ref([]);
|
||
const showResults = ref(false);
|
||
const selectedIndex = ref(-1);
|
||
const hoverIndex = ref(-1);
|
||
const activeSection = ref(null);
|
||
const loading = ref(false);
|
||
const isFocused = ref(false);
|
||
const inputRef = ref(null);
|
||
const selectedFilter = ref("all");
|
||
const filterDropdownOpen = ref(false);
|
||
const showRecentSearches = ref(true);
|
||
|
||
const quickFilters = computed(() => [
|
||
// { label: "همه", value: "all", icon: "i-heroicons-globe-alt" },
|
||
...(resolvedProps.value.filters || []),
|
||
]);
|
||
|
||
// LocalStorage Key
|
||
const LOCAL_STORAGE_KEY = "searchHistory";
|
||
|
||
// لود از localStorage
|
||
const loadHistoryFromLocal = () => {
|
||
try {
|
||
const stored = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||
if (stored) {
|
||
const parsed = JSON.parse(stored);
|
||
historyResults.value = parsed.slice(
|
||
0,
|
||
resolvedProps.value.maxHistoryItems,
|
||
);
|
||
recentSearches.value = historyResults.value
|
||
.slice(0, 5)
|
||
.map((i) => i.label);
|
||
}
|
||
} catch (err) {
|
||
console.error("خطا در لود تاریخچه از localStorage:", err);
|
||
}
|
||
};
|
||
|
||
// ذخیره در localStorage
|
||
const saveHistoryToLocal = () => {
|
||
try {
|
||
localStorage.setItem(
|
||
LOCAL_STORAGE_KEY,
|
||
JSON.stringify(historyResults.value),
|
||
);
|
||
} catch (err) {
|
||
console.error("خطا در ذخیره تاریخچه:", err);
|
||
}
|
||
};
|
||
|
||
// Computed
|
||
const hasVisibleResults = computed(
|
||
() =>
|
||
showResults.value &&
|
||
(showAutocompleteList.value ||
|
||
showHistoryList.value ||
|
||
showEmptyState.value),
|
||
);
|
||
const showAutocompleteList = computed(
|
||
() =>
|
||
showResults.value &&
|
||
autocompleteResults.value.length > 0 &&
|
||
searchQuery.value.length >= resolvedProps.value.minCharsForAutocomplete,
|
||
);
|
||
const showHistoryList = computed(
|
||
() => showResults.value && searchQuery.value.length === 0,
|
||
);
|
||
const showEmptyState = computed(
|
||
() =>
|
||
showResults.value &&
|
||
!showAutocompleteList.value &&
|
||
!showHistoryList.value &&
|
||
searchQuery.value.length >= resolvedProps.value.minCharsForAutocomplete,
|
||
);
|
||
const totalResults = computed(
|
||
() =>
|
||
(showAutocompleteList.value ? autocompleteResults.value.length : 0) +
|
||
(showHistoryList.value ? historyResults.value.length : 0),
|
||
);
|
||
|
||
const selectedFilterIcon = computed(
|
||
() =>
|
||
quickFilters.value.find((f) => f.value === selectedFilter.value)?.icon ||
|
||
"i-heroicons-funnel",
|
||
);
|
||
const selectedFilterLabel = computed(
|
||
() =>
|
||
quickFilters.value.find((f) => f.value === selectedFilter.value)?.label ||
|
||
"فیلتر",
|
||
);
|
||
|
||
// فرمت زمان
|
||
const formatTimeAgo = (timestamp) => {
|
||
if (!timestamp) return "اخیراً";
|
||
const now = new Date();
|
||
const past = new Date(timestamp);
|
||
const diffMs = now - past;
|
||
const diffMins = Math.floor(diffMs / 60000);
|
||
const diffHours = Math.floor(diffMs / 3600000);
|
||
const diffDays = Math.floor(diffMs / 86400000);
|
||
if (diffMins < 1) return "همین الان";
|
||
if (diffMins < 60) return `${diffMins} دقیقه پیش`;
|
||
if (diffHours < 24) return `${diffHours} ساعت پیش`;
|
||
if (diffDays < 7) return `${diffDays} روز پیش`;
|
||
return past.toLocaleDateString("fa-IR");
|
||
};
|
||
|
||
const { $http: httpService } = useNuxtApp();
|
||
|
||
// Autocomplete
|
||
const fetchAutocomplete = async () => {
|
||
if (
|
||
!searchQuery.value ||
|
||
searchQuery.value.length < resolvedProps.value.minCharsForAutocomplete
|
||
) {
|
||
autocompleteResults.value = [];
|
||
return;
|
||
}
|
||
loading.value = true;
|
||
let url = `${resolvedProps.value.autocompleteUrl}/q=${searchQuery.value}`;
|
||
if (selectedFilter.value !== "all") url += `&filter=${selectedFilter.value}`;
|
||
try {
|
||
console.log("url", url);
|
||
|
||
const response = await httpService.getRequest(url);
|
||
const hits = response?.hits?.hits || [];
|
||
autocompleteResults.value = hits
|
||
.map((hit) => ({
|
||
id: hit._id,
|
||
label: hit._source?.title || hit._source?.name || "بدون عنوان",
|
||
value: hit._source?.title || hit._source?.name,
|
||
description: hit._source?.description || hit._source?.subtitle || "",
|
||
icon:
|
||
hit._source?.type === "product"
|
||
? "i-heroicons-shopping-bag"
|
||
: hit._source?.type === "article"
|
||
? "i-heroicons-newspaper"
|
||
: "i-heroicons-document-text",
|
||
}))
|
||
.slice(0, 6);
|
||
} catch (err) {
|
||
console.error("خطا در پیشنهادات:", err);
|
||
autocompleteResults.value = [];
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
// History (اولویت با localStorage)
|
||
const fetchHistory = async () => {
|
||
loadHistoryFromLocal();
|
||
// اگر بخوای از سرور هم بگیری، این بخش رو فعال کن
|
||
// if (historyResults.value.length === 0) { ... }
|
||
};
|
||
|
||
// اضافه کردن به تاریخچه + ذخیره
|
||
const addToHistory = (item) => {
|
||
const term =
|
||
typeof item === "string" ? item : item.value || item.label || item;
|
||
if (!term) return;
|
||
const existingIndex = historyResults.value.findIndex((h) => h.value === term);
|
||
if (existingIndex > -1) {
|
||
const existing = historyResults.value[existingIndex];
|
||
existing.count = (existing.count || 0) + 1;
|
||
existing.timestamp = new Date().toISOString();
|
||
historyResults.value.splice(existingIndex, 1);
|
||
historyResults.value.unshift(existing);
|
||
} else {
|
||
historyResults.value.unshift({
|
||
id: Date.now(),
|
||
label: term,
|
||
value: term,
|
||
timestamp: new Date().toISOString(),
|
||
count: 1,
|
||
});
|
||
}
|
||
if (historyResults.value.length > resolvedProps.value.maxHistoryItems) {
|
||
historyResults.value = historyResults.value.slice(
|
||
0,
|
||
resolvedProps.value.maxHistoryItems,
|
||
);
|
||
}
|
||
recentSearches.value = historyResults.value.slice(0, 5).map((i) => i.label);
|
||
saveHistoryToLocal();
|
||
};
|
||
|
||
const removeHistoryItem = (item, index) => {
|
||
historyResults.value.splice(index, 1);
|
||
recentSearches.value = recentSearches.value.filter((t) => t !== item.label);
|
||
saveHistoryToLocal();
|
||
// emit("history-remove", item);
|
||
emit("auto-complation-handler", { action: "history-remove", item });
|
||
};
|
||
|
||
const clearAllHistory = () => {
|
||
historyResults.value = [];
|
||
recentSearches.value = [];
|
||
localStorage.removeItem(LOCAL_STORAGE_KEY);
|
||
// emit("history-clear");
|
||
emit("auto-complation-handler", { action: "history-clear", item: null });
|
||
};
|
||
|
||
// UI Functions
|
||
const toggleFilterDropdown = () =>
|
||
(filterDropdownOpen.value = !filterDropdownOpen.value);
|
||
|
||
const selectFilterFromDropdown = (filter) => {
|
||
selectedFilter.value = filter.value;
|
||
|
||
// emit("filter-selected", filter);
|
||
emit("auto-complation-handler", { action: "filter-selected", item: filter });
|
||
filterDropdownOpen.value = false;
|
||
if (searchQuery.value) fetchAutocomplete();
|
||
};
|
||
|
||
const clearFilter = () => {
|
||
selectedFilter.value = "all";
|
||
// emit("filter-selected", {
|
||
// label: "همه",
|
||
// value: "all",
|
||
// icon: "i-heroicons-globe-alt",
|
||
// });
|
||
emit("auto-complation-handler", {
|
||
action: "filter-selected",
|
||
item: {
|
||
label: "همه",
|
||
value: "all",
|
||
icon: "i-heroicons-globe-alt",
|
||
},
|
||
});
|
||
if (searchQuery.value) fetchAutocomplete();
|
||
};
|
||
|
||
const clearSearch = () => {
|
||
searchQuery.value = "";
|
||
autocompleteResults.value = [];
|
||
// emit("clear");
|
||
emit("auto-complation-handler", { action: "clear", item: null });
|
||
};
|
||
const handleSearch = () => {
|
||
// اگر تایمر debounce فعال بود، لغوش کن
|
||
if (searchTimer.value) {
|
||
clearTimeout(searchTimer.value);
|
||
searchTimer.value = null;
|
||
}
|
||
|
||
const query = searchQuery.value.trim();
|
||
|
||
// حتی اگر query خالی باشه، ایونت رو بفرست (جستجوی خالی = نمایش همه)
|
||
addToHistory(query || ""); // اختیاری: میتونی تاریخچه خالی اضافه نکنی
|
||
|
||
showResults.value = false;
|
||
|
||
// ارسال ایونت complete-search حتی اگر query خالی باشه
|
||
emit("auto-complation-handler", {
|
||
action: "complete-search",
|
||
item: query || null, // یا "" یا null — بسته به چیزی که والد انتظار داره
|
||
});
|
||
};
|
||
// const handleSearch = () => {
|
||
// // اگر تایمر فعال بود، لغوش کن (چون کاربر دستی جستجو کرد)
|
||
// if (searchTimer.value) {
|
||
// clearTimeout(searchTimer.value);
|
||
// searchTimer.value = null;
|
||
// }
|
||
|
||
// if (!searchQuery.value.trim()) return;
|
||
|
||
// addToHistory(searchQuery.value.trim());
|
||
// showResults.value = false;
|
||
// // emit("search", searchQuery.value.trim());
|
||
// // emit("complete-search", searchQuery.value.trim());
|
||
|
||
// // emit("auto-complation-handler", { action: "search", item: searchQuery.value.trim() });
|
||
// emit("auto-complation-handler", { action: "complete-search", item: searchQuery.value.trim() });
|
||
// };
|
||
|
||
const handleAutocompleteSelect = (item) => {
|
||
searchQuery.value = item.value || item.label;
|
||
addToHistory(item);
|
||
showResults.value = false;
|
||
inputRef.value?.blur();
|
||
// emit("autocomplete-select", item);
|
||
// emit("search", searchQuery.value);
|
||
|
||
emit("auto-complation-handler", { action: "autocomplete-select", item });
|
||
// emit("auto-complation-handler", { action: "search", item: searchQuery.value });
|
||
emit("auto-complation-handler", {
|
||
action: "complete-search",
|
||
item: searchQuery.value.trim(),
|
||
});
|
||
};
|
||
|
||
const handleHistorySelect = (item) => {
|
||
// console.log("item ==> ", item);
|
||
searchQuery.value = item.value || item.label;
|
||
showResults.value = false;
|
||
inputRef.value?.focus();
|
||
// emit("history-select", item);
|
||
emit("auto-complation-handler", { action: "history-select", item });
|
||
emit("auto-complation-handler", {
|
||
action: "complete-search",
|
||
item: searchQuery.value.trim(),
|
||
});
|
||
};
|
||
|
||
const selectRecentSearch = (term) => {
|
||
searchQuery.value = term;
|
||
handleSearch();
|
||
};
|
||
|
||
// 3. اصلاح handleFocus برای باز کردن دراپداون
|
||
const handleFocus = () => {
|
||
isFocused.value = true;
|
||
showResults.value = true; // همیشه باز باشه
|
||
selectedIndex.value = -1;
|
||
activeSection.value = null;
|
||
// emit("focus");
|
||
emit("auto-complation-handler", { action: "focus", item: null });
|
||
|
||
if (historyResults.value.length === 0) {
|
||
fetchHistory();
|
||
}
|
||
};
|
||
|
||
const handleBlur = () => {
|
||
setTimeout(() => {
|
||
isFocused.value = false;
|
||
showResults.value = false;
|
||
selectedIndex.value = -1;
|
||
hoverIndex.value = -1;
|
||
activeSection.value = null;
|
||
filterDropdownOpen.value = false;
|
||
// emit("blur");
|
||
emit("auto-complation-handler", { action: "blur", item: null });
|
||
}, 300);
|
||
};
|
||
|
||
const searchTimer = ref(null);
|
||
|
||
// زمان debounce به ثانیه (۳ ثانیه = 3000 میلیثانیه)
|
||
const DEBOUNCE_TIME = computed(() => resolvedProps.value.debounceTime);
|
||
|
||
const handleInput = () => {
|
||
// همیشه پیشنهادات رو سریع بگیر (autocomplete)
|
||
fetchAutocomplete();
|
||
|
||
// ریست تایمر قبلی
|
||
if (searchTimer.value) {
|
||
clearTimeout(searchTimer.value);
|
||
}
|
||
// تنظیم تایمر جدید برای جستجوی نهایی
|
||
searchTimer.value = setTimeout(() => {
|
||
if (
|
||
searchQuery.value.trim() &&
|
||
searchQuery.value.length >= resolvedProps.value.minCharsForAutocomplete
|
||
) {
|
||
// جستجوی نهایی بعد از ۳ ثانیه توقف تایپ
|
||
addToHistory(searchQuery.value.trim());
|
||
// emit("search", searchQuery.value.trim());
|
||
// emit("complete-search", searchQuery.value.trim());
|
||
// emit("auto-complation-handler", { action: "search", item: searchQuery.value.trim() });
|
||
emit("auto-complation-handler", {
|
||
action: "complete-search",
|
||
item: searchQuery.value.trim(),
|
||
});
|
||
}
|
||
}, DEBOUNCE_TIME.value);
|
||
};
|
||
// 4. اصلاح handleKeyDown (نسخه بهبود یافته)
|
||
const handleKeyDown = (event) => {
|
||
if (!hasVisibleResults.value) return;
|
||
|
||
const autocompleteCount = showAutocompleteList.value
|
||
? autocompleteResults.value.length
|
||
: 0;
|
||
const historyCount = showHistoryList.value ? historyResults.value.length : 0;
|
||
const totalItems = autocompleteCount + historyCount;
|
||
|
||
if (totalItems === 0) return;
|
||
|
||
switch (event.key) {
|
||
case "ArrowDown":
|
||
event.preventDefault();
|
||
if (selectedIndex.value < totalItems - 1) {
|
||
selectedIndex.value++;
|
||
} else {
|
||
selectedIndex.value = 0; // چرخشی
|
||
}
|
||
updateActiveSection(autocompleteCount);
|
||
break;
|
||
|
||
case "ArrowUp":
|
||
event.preventDefault();
|
||
if (selectedIndex.value <= 0) {
|
||
selectedIndex.value = totalItems - 1;
|
||
} else {
|
||
selectedIndex.value--;
|
||
}
|
||
updateActiveSection(autocompleteCount);
|
||
break;
|
||
|
||
case "Enter":
|
||
event.preventDefault();
|
||
if (selectedIndex.value >= 0) {
|
||
if (activeSection.value === "autocomplete") {
|
||
handleAutocompleteSelect(
|
||
autocompleteResults.value[selectedIndex.value],
|
||
);
|
||
} else if (activeSection.value === "history") {
|
||
const historyIndex = selectedIndex.value - autocompleteCount;
|
||
handleHistorySelect(historyResults.value[historyIndex]);
|
||
}
|
||
} else {
|
||
handleSearch();
|
||
}
|
||
break;
|
||
|
||
case "Escape":
|
||
showResults.value = false;
|
||
selectedIndex.value = -1;
|
||
activeSection.value = null;
|
||
inputRef.value?.blur();
|
||
break;
|
||
}
|
||
};
|
||
|
||
const updateActiveSection = (autocompleteCount) => {
|
||
activeSection.value =
|
||
selectedIndex.value < autocompleteCount ? "autocomplete" : "history";
|
||
}; // قابل گسترش برای ناوبری کیبورد
|
||
const handleEnter = () => {
|
||
handleSearch(); // جستجوی فوری با Enter
|
||
};
|
||
|
||
// Mount
|
||
const closeDropdown = () => (filterDropdownOpen.value = false);
|
||
|
||
onMounted(() => {
|
||
document.addEventListener("click", closeDropdown);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener("click", closeDropdown);
|
||
});
|
||
watch(autocompleteResults, (val) => {
|
||
if (val.length > 0 && isFocused.value) {
|
||
showResults.value = true;
|
||
}
|
||
});
|
||
watch(searchQuery, (newVal) => {
|
||
// همیشه دراپداون باز باشه وقتی فوکوس داریم یا چیزی تایپ شده
|
||
if (isFocused.value) {
|
||
showResults.value = true;
|
||
}
|
||
// ریست ناوبری کیبورد
|
||
selectedIndex.value = -1;
|
||
activeSection.value = null;
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.animate-fade-in {
|
||
animation: fadeIn 0.3s ease-out;
|
||
}
|
||
@keyframes fadeIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-10px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
.custom-scrollbar::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
.custom-scrollbar::-webkit-scrollbar-track {
|
||
background: rgba(243, 244, 246, 0.5);
|
||
border-radius: 3px;
|
||
}
|
||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||
background: rgba(156, 163, 175, 0.5);
|
||
border-radius: 3px;
|
||
}
|
||
.dark .custom-scrollbar::-webkit-scrollbar-track {
|
||
background: rgba(31, 41, 55, 0.5);
|
||
}
|
||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||
background: rgba(75, 85, 99, 0.5);
|
||
}
|
||
</style>
|