|
|
@@ -18,10 +18,17 @@ interface SearchResult {
|
|
|
matchType: 'title' | 'content';
|
|
|
}
|
|
|
|
|
|
+interface SearchSuggestion {
|
|
|
+ text: string;
|
|
|
+ count: number;
|
|
|
+ category: 'home' | 'itinerary' | 'ships' | 'dining' | 'activity' | 'all';
|
|
|
+}
|
|
|
+
|
|
|
const SearchOverlay: React.FC<SearchOverlayProps> = ({ isOpen, onClose }) => {
|
|
|
const { language } = useLanguage();
|
|
|
const { itineraries, ships, dining, videoSection } = useTheme();
|
|
|
const [query, setQuery] = useState('');
|
|
|
+ const [showSuggestions, setShowSuggestions] = useState(true);
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
@@ -142,29 +149,281 @@ const SearchOverlay: React.FC<SearchOverlayProps> = ({ isOpen, onClose }) => {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
+ // 7. CruiseSpace Page Content
|
|
|
+ const cruiseSpacePage = (t as any).shipsPage?.spaces;
|
|
|
+ if (cruiseSpacePage) {
|
|
|
+ // Facilities
|
|
|
+ index.push({
|
|
|
+ title: cruiseSpacePage.facilities.title,
|
|
|
+ subtitle: cruiseSpacePage.facilities.desc,
|
|
|
+ category: 'ships',
|
|
|
+ link: '/cruise-space?section=facilities',
|
|
|
+ matchType: 'title'
|
|
|
+ });
|
|
|
+ // Facilities Items
|
|
|
+ cruiseSpacePage.facilities.items?.forEach((item: any) => {
|
|
|
+ index.push({
|
|
|
+ title: item.title,
|
|
|
+ subtitle: item.desc,
|
|
|
+ category: 'ships',
|
|
|
+ link: '/cruise-space?section=facilities',
|
|
|
+ matchType: 'content'
|
|
|
+ });
|
|
|
+ });
|
|
|
+ // Rooms
|
|
|
+ index.push({
|
|
|
+ title: cruiseSpacePage.rooms.title,
|
|
|
+ subtitle: cruiseSpacePage.rooms.desc,
|
|
|
+ category: 'ships',
|
|
|
+ link: '/cruise-space?section=rooms',
|
|
|
+ matchType: 'title'
|
|
|
+ });
|
|
|
+ // Rooms Types
|
|
|
+ cruiseSpacePage.rooms.types?.forEach((room: any) => {
|
|
|
+ index.push({
|
|
|
+ title: room.name,
|
|
|
+ subtitle: room.desc,
|
|
|
+ category: 'ships',
|
|
|
+ link: '/cruise-space?section=rooms',
|
|
|
+ matchType: 'content'
|
|
|
+ });
|
|
|
+ });
|
|
|
+ // VIP Privileges
|
|
|
+ index.push({
|
|
|
+ title: cruiseSpacePage.vip.title,
|
|
|
+ subtitle: cruiseSpacePage.vip.desc,
|
|
|
+ category: 'ships',
|
|
|
+ link: '/cruise-space?section=vip',
|
|
|
+ matchType: 'title'
|
|
|
+ });
|
|
|
+ // VIP Benefits
|
|
|
+ cruiseSpacePage.vip.benefits?.forEach((benefit: string) => {
|
|
|
+ index.push({
|
|
|
+ title: benefit,
|
|
|
+ category: 'ships',
|
|
|
+ link: '/cruise-space?section=vip',
|
|
|
+ matchType: 'content'
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 8. Guide Page Content
|
|
|
+ const guidePage = t.guide;
|
|
|
+ if (guidePage) {
|
|
|
+ // Hero
|
|
|
+ index.push({
|
|
|
+ title: guidePage.hero.title,
|
|
|
+ subtitle: guidePage.hero.subtitle,
|
|
|
+ category: 'activity',
|
|
|
+ link: '/guide',
|
|
|
+ matchType: 'title'
|
|
|
+ });
|
|
|
+ // Travel Tips
|
|
|
+ index.push({
|
|
|
+ title: guidePage.tips.sectionSubtitle,
|
|
|
+ subtitle: guidePage.tips.sectionTitle,
|
|
|
+ category: 'activity',
|
|
|
+ link: '/guide?section=tips',
|
|
|
+ matchType: 'title'
|
|
|
+ });
|
|
|
+ // Tips Categories
|
|
|
+ guidePage.tips.categories?.forEach((category: any) => {
|
|
|
+ index.push({
|
|
|
+ title: category.title,
|
|
|
+ category: 'activity',
|
|
|
+ link: '/guide?section=tips',
|
|
|
+ matchType: 'content'
|
|
|
+ });
|
|
|
+ // Tips Items
|
|
|
+ category.items?.forEach((item: string) => {
|
|
|
+ index.push({
|
|
|
+ title: item,
|
|
|
+ category: 'activity',
|
|
|
+ link: '/guide?section=tips',
|
|
|
+ matchType: 'content'
|
|
|
+ });
|
|
|
+ });
|
|
|
+ });
|
|
|
+ // FAQ
|
|
|
+ index.push({
|
|
|
+ title: guidePage.faq.sectionSubtitle,
|
|
|
+ subtitle: guidePage.faq.sectionTitle,
|
|
|
+ category: 'activity',
|
|
|
+ link: '/guide?section=faq',
|
|
|
+ matchType: 'title'
|
|
|
+ });
|
|
|
+ // FAQ Items
|
|
|
+ guidePage.faq.items?.forEach((faq: any) => {
|
|
|
+ index.push({
|
|
|
+ title: faq.question,
|
|
|
+ subtitle: faq.answer,
|
|
|
+ category: 'activity',
|
|
|
+ link: '/guide?section=faq',
|
|
|
+ matchType: 'content'
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
return index;
|
|
|
}, [language, t, itineraries, ships, dining, videoSection]);
|
|
|
|
|
|
- // Filter Results
|
|
|
+ // Generate Search Suggestions
|
|
|
+ const suggestions = useMemo(() => {
|
|
|
+ if (!query.trim() || query.length < 2 || !showSuggestions) return [];
|
|
|
+ const lowerQuery = query.toLowerCase();
|
|
|
+
|
|
|
+ // Extract unique terms from search index
|
|
|
+ const allTerms = new Set<string>();
|
|
|
+ searchIndex.forEach(item => {
|
|
|
+ // Split title into words
|
|
|
+ item.title.toLowerCase().split(/\s+/).forEach(word => {
|
|
|
+ if (word.length > 2) allTerms.add(word);
|
|
|
+ });
|
|
|
+ // Split subtitle into words if exists
|
|
|
+ if (item.subtitle) {
|
|
|
+ item.subtitle.toLowerCase().split(/\s+/).forEach(word => {
|
|
|
+ if (word.length > 2) allTerms.add(word);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // Find terms that start with or contain the query
|
|
|
+ const matchingTerms = Array.from(allTerms)
|
|
|
+ .filter(term => term.startsWith(lowerQuery))
|
|
|
+ .sort();
|
|
|
+
|
|
|
+ // Limit to top 5 suggestions
|
|
|
+ return matchingTerms.slice(0, 5).map(term => ({
|
|
|
+ text: term,
|
|
|
+ count: searchIndex.filter(item =>
|
|
|
+ item.title.toLowerCase().includes(term) ||
|
|
|
+ (item.subtitle && item.subtitle.toLowerCase().includes(term))
|
|
|
+ ).length,
|
|
|
+ category: 'all'
|
|
|
+ }));
|
|
|
+ }, [query, searchIndex, showSuggestions]);
|
|
|
+
|
|
|
+ // Filter Results with Enhanced Matching and Grouping
|
|
|
const filteredResults = useMemo(() => {
|
|
|
if (!query.trim()) return [];
|
|
|
const lowerQuery = query.toLowerCase();
|
|
|
- return searchIndex.filter(item =>
|
|
|
- item.title.toLowerCase().includes(lowerQuery) ||
|
|
|
- (item.subtitle && item.subtitle.toLowerCase().includes(lowerQuery))
|
|
|
- );
|
|
|
+
|
|
|
+ // Calculate relevance score for each match
|
|
|
+ const resultsWithScore = searchIndex
|
|
|
+ .map(item => {
|
|
|
+ const titleLower = item.title.toLowerCase();
|
|
|
+ const subtitleLower = item.subtitle?.toLowerCase() || '';
|
|
|
+
|
|
|
+ // Basic match check
|
|
|
+ const titleMatches = titleLower.includes(lowerQuery);
|
|
|
+ const subtitleMatches = subtitleLower.includes(lowerQuery);
|
|
|
+
|
|
|
+ if (!titleMatches && !subtitleMatches) return null;
|
|
|
+
|
|
|
+ // Calculate relevance score
|
|
|
+ let score = 0;
|
|
|
+
|
|
|
+ // Title matches are more important
|
|
|
+ if (titleMatches) {
|
|
|
+ score += 10;
|
|
|
+ // Exact match in title gives extra points
|
|
|
+ if (titleLower === lowerQuery) score += 20;
|
|
|
+ // Start of title match gives more points
|
|
|
+ if (titleLower.startsWith(lowerQuery)) score += 5;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Subtitle matches are less important
|
|
|
+ if (subtitleMatches) {
|
|
|
+ score += 5;
|
|
|
+ if (subtitleLower === lowerQuery) score += 10;
|
|
|
+ if (subtitleLower.startsWith(lowerQuery)) score += 3;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Match type bonus
|
|
|
+ if (item.matchType === 'title') score += 3;
|
|
|
+
|
|
|
+ // Category priority (home > itinerary > ships > dining > activity)
|
|
|
+ const categoryPriority = {
|
|
|
+ home: 5,
|
|
|
+ itinerary: 4,
|
|
|
+ ships: 3,
|
|
|
+ dining: 2,
|
|
|
+ activity: 1
|
|
|
+ };
|
|
|
+ score += categoryPriority[item.category] || 0;
|
|
|
+
|
|
|
+ return { ...item, score };
|
|
|
+ })
|
|
|
+ .filter((item): item is typeof item & { score: number } => item !== null);
|
|
|
+
|
|
|
+ // Sort by relevance score (descending)
|
|
|
+ const sortedResults = resultsWithScore.sort((a, b) => b.score - a.score);
|
|
|
+
|
|
|
+ return sortedResults;
|
|
|
}, [query, searchIndex]);
|
|
|
|
|
|
+ // Group results by category for better display
|
|
|
+ const groupedResults = useMemo(() => {
|
|
|
+ if (!filteredResults.length) return [];
|
|
|
+
|
|
|
+ const categories = ['home', 'itinerary', 'ships', 'dining', 'activity'] as const;
|
|
|
+ const groups: { category: string; results: typeof filteredResults }[] = [];
|
|
|
+
|
|
|
+ categories.forEach(category => {
|
|
|
+ const categoryResults = filteredResults.filter(item => item.category === category);
|
|
|
+ if (categoryResults.length > 0) {
|
|
|
+ groups.push({ category, results: categoryResults });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return groups;
|
|
|
+ }, [filteredResults]);
|
|
|
+
|
|
|
const handleResultClick = (link: string) => {
|
|
|
onClose();
|
|
|
+
|
|
|
if (link.startsWith('/?section=')) {
|
|
|
const sectionId = link.split('=')[1];
|
|
|
navigate('/');
|
|
|
+ // Use a safer timeout and scroll logic
|
|
|
setTimeout(() => {
|
|
|
- document.getElementById(sectionId)?.scrollIntoView({ behavior: 'smooth' });
|
|
|
- }, 100);
|
|
|
- } else if (link.startsWith('/ships?section=')) {
|
|
|
- navigate(link);
|
|
|
+ const element = document.getElementById(sectionId);
|
|
|
+ if (element) {
|
|
|
+ // Add some offset to account for fixed header
|
|
|
+ const headerOffset = 80;
|
|
|
+ const elementPosition = element.getBoundingClientRect().top;
|
|
|
+ const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
|
|
|
+
|
|
|
+ window.scrollTo({
|
|
|
+ top: offsetPosition,
|
|
|
+ behavior: 'smooth'
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }, 150);
|
|
|
+ } else if (link.includes('?section=')) {
|
|
|
+ // Handle other section links with proper scroll logic
|
|
|
+ const [path, queryString] = link.split('?');
|
|
|
+ const params = new URLSearchParams(queryString);
|
|
|
+ const section = params.get('section');
|
|
|
+
|
|
|
+ navigate(path);
|
|
|
+
|
|
|
+ if (section) {
|
|
|
+ setTimeout(() => {
|
|
|
+ const elementId = link.startsWith('/cruise-space') ? `space-${section}` : section;
|
|
|
+ const element = document.getElementById(elementId);
|
|
|
+ if (element) {
|
|
|
+ const headerOffset = 80;
|
|
|
+ const elementPosition = element.getBoundingClientRect().top;
|
|
|
+ const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
|
|
|
+
|
|
|
+ window.scrollTo({
|
|
|
+ top: offsetPosition,
|
|
|
+ behavior: 'smooth'
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }, 200);
|
|
|
+ }
|
|
|
} else {
|
|
|
navigate(link);
|
|
|
}
|
|
|
@@ -209,41 +468,88 @@ const SearchOverlay: React.FC<SearchOverlayProps> = ({ isOpen, onClose }) => {
|
|
|
{/* Results Dropdown - Floating immediately below the bar */}
|
|
|
{(query.trim() !== '') && (
|
|
|
<div className="absolute top-full left-0 right-0 mt-2 bg-vista-darkblue/95 backdrop-blur-xl rounded border border-white/10 max-h-[60vh] overflow-y-auto no-scrollbar shadow-2xl">
|
|
|
- {filteredResults.length > 0 ? (
|
|
|
- <div className="p-2">
|
|
|
- <div className="px-4 py-2 text-vista-gold text-[10px] font-bold uppercase tracking-widest border-b border-white/5">
|
|
|
- {searchT.results_for} "{query}"
|
|
|
+ {/* Search Suggestions */}
|
|
|
+ {suggestions.length > 0 && showSuggestions && query.length < 20 && (
|
|
|
+ <div className="p-2 border-b border-white/5">
|
|
|
+ <div className="px-4 py-2 text-vista-gold text-[10px] font-bold uppercase tracking-widest">
|
|
|
+ Suggestions
|
|
|
</div>
|
|
|
- {filteredResults.map((result, idx) => (
|
|
|
+ {suggestions.map((suggestion, idx) => (
|
|
|
<div
|
|
|
key={idx}
|
|
|
- onClick={() => handleResultClick(result.link)}
|
|
|
+ onClick={() => {
|
|
|
+ setQuery(suggestion.text);
|
|
|
+ setShowSuggestions(false);
|
|
|
+ }}
|
|
|
className="group flex items-center justify-between p-3 hover:bg-white/10 cursor-pointer transition-colors rounded-sm"
|
|
|
>
|
|
|
<div className="flex items-center gap-3">
|
|
|
- <div className="text-white/40 group-hover:text-vista-gold transition-colors">
|
|
|
- {result.category === 'itinerary' && <Compass size={16} />}
|
|
|
- {result.category === 'ships' && <Ship size={16} />}
|
|
|
- {result.category === 'dining' && <Utensils size={16} />}
|
|
|
- {result.category === 'activity' && <Film size={16} />}
|
|
|
- {result.category === 'home' && <Anchor size={16} />}
|
|
|
- </div>
|
|
|
+ <Search className="text-white/40 group-hover:text-vista-gold transition-colors w-4 h-4" />
|
|
|
<div>
|
|
|
- <h3 className="text-base font-serif text-white group-hover:text-vista-gold transition-colors">{result.title}</h3>
|
|
|
- {result.subtitle && (
|
|
|
- <p className="text-white/50 text-[10px] mt-0.5 line-clamp-1">{result.subtitle}</p>
|
|
|
- )}
|
|
|
+ <h3 className="text-base font-serif text-white group-hover:text-vista-gold transition-colors">{suggestion.text}</h3>
|
|
|
+ <p className="text-white/50 text-[10px] mt-0.5">{suggestion.count} results</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
- <ArrowRight className="text-white/20 group-hover:text-vista-gold w-3 h-3" />
|
|
|
</div>
|
|
|
))}
|
|
|
+ <button
|
|
|
+ onClick={() => setShowSuggestions(false)}
|
|
|
+ className="w-full px-4 py-2 text-left text-vista-gold text-[10px] font-bold uppercase tracking-widest hover:bg-white/5 transition-colors"
|
|
|
+ >
|
|
|
+ Show Results for "{query}"
|
|
|
+ </button>
|
|
|
</div>
|
|
|
- ) : (
|
|
|
- <div className="p-6 text-center text-white/40 font-serif italic text-base">
|
|
|
- {searchT.no_results}
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Search Results */}
|
|
|
+ {(!suggestions.length || !showSuggestions) && (
|
|
|
+ <div>
|
|
|
+ {filteredResults.length > 0 ? (
|
|
|
+ <div className="p-2">
|
|
|
+ <div className="px-4 py-2 text-vista-gold text-[10px] font-bold uppercase tracking-widest border-b border-white/5 mb-2">
|
|
|
+ {searchT.results_for} "{query}"
|
|
|
+ </div>
|
|
|
+ {groupedResults.map((group, groupIdx) => (
|
|
|
+ <div key={groupIdx} className="mb-4 last:mb-0">
|
|
|
+ <div className="px-4 py-2 text-white/60 text-[10px] font-bold uppercase tracking-wider border-b border-white/5 flex items-center gap-2">
|
|
|
+ {group.category === 'itinerary' && <Compass size={14} />}
|
|
|
+ {group.category === 'ships' && <Ship size={14} />}
|
|
|
+ {group.category === 'dining' && <Utensils size={14} />}
|
|
|
+ {group.category === 'activity' && <Film size={14} />}
|
|
|
+ {group.category === 'home' && <Anchor size={14} />}
|
|
|
+ {searchT.categories?.[group.category] || group.category}
|
|
|
+ <span className="text-white/30">({group.results.length})</span>
|
|
|
+ </div>
|
|
|
+ <div className="mt-2">
|
|
|
+ {group.results.map((result, resultIdx) => (
|
|
|
+ <div
|
|
|
+ key={resultIdx}
|
|
|
+ onClick={() => handleResultClick(result.link)}
|
|
|
+ className="group flex items-center justify-between p-3 hover:bg-white/10 cursor-pointer transition-colors rounded-sm mx-2"
|
|
|
+ >
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <div className="w-1 h-1 rounded-full bg-vista-gold group-hover:scale-150 transition-transform"></div>
|
|
|
+ <div>
|
|
|
+ <h3 className="text-base font-serif text-white group-hover:text-vista-gold transition-colors">{result.title}</h3>
|
|
|
+ {result.subtitle && (
|
|
|
+ <p className="text-white/50 text-[10px] mt-0.5 line-clamp-1">{result.subtitle}</p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <ArrowRight className="text-white/20 group-hover:text-vista-gold w-3 h-3" />
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="p-6 text-center text-white/40 font-serif italic text-base">
|
|
|
+ {searchT.no_results}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
- )}
|
|
|
+ )}
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|