| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560 |
- import React, { useState, useMemo, useEffect, useRef } from 'react';
- import { Search, X, ArrowRight, Compass, Ship, Utensils, Anchor, Film } from 'lucide-react';
- import { useLanguage } from '../contexts/LanguageContext.tsx';
- import { useTheme } from '../contexts/ThemeContext.tsx';
- import { CONTENT } from '../constants.ts';
- import { useNavigate } from 'react-router-dom';
- interface SearchOverlayProps {
- isOpen: boolean;
- onClose: () => void;
- }
- interface SearchResult {
- title: string;
- subtitle?: string;
- category: 'home' | 'itinerary' | 'ships' | 'dining' | 'activity';
- link: string;
- 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();
- const t = CONTENT[language];
- const searchT = (CONTENT[language] as any).search || {
- placeholder: "Search...",
- no_results: "No results",
- results_for: "Results: ",
- categories: { home: "Home", ships: "Ships", itinerary: "Trips", dining: "Food", activity: "Fun" }
- };
- // Focus input when opened
- useEffect(() => {
- if (isOpen) {
- setTimeout(() => {
- inputRef.current?.focus();
- }, 100);
- // Removed body scroll lock to keep it feeling lightweight
- }
- }, [isOpen]);
- // Handle ESC key
- useEffect(() => {
- const handleEsc = (e: KeyboardEvent) => {
- if (e.key === 'Escape') onClose();
- };
- window.addEventListener('keydown', handleEsc);
- return () => window.removeEventListener('keydown', handleEsc);
- }, [onClose]);
- // Build Search Index
- const searchIndex: SearchResult[] = useMemo(() => {
- const index: SearchResult[] = [];
- // 1. Home - Hero (Text on Image)
- index.push({
- title: t.hero.title_normal + ' ' + t.hero.title_italic,
- subtitle: t.hero.subtitle,
- category: 'home',
- link: '/',
- matchType: 'title'
- });
- // 2. Dynamic Itineraries (Cards / Images)
- itineraries.forEach(item => {
- index.push({
- title: item.title,
- subtitle: `${item.route} - ${item.price}`,
- category: 'itinerary',
- link: '/?section=featured-itineraries',
- matchType: 'title'
- });
- });
- // 3. Dynamic Ships (Cards / Images)
- ships.forEach(item => {
- index.push({
- title: item.name,
- subtitle: item.description,
- category: 'ships',
- link: '/?section=our-fleet',
- matchType: 'title'
- });
- });
- // 4. Dynamic Dining (Banner Image)
- index.push({
- title: dining.title,
- subtitle: dining.description,
- category: 'dining',
- link: '/?section=dining-experience',
- matchType: 'title'
- });
- // 5. Dynamic Video/Activity (Thumbnail)
- index.push({
- title: videoSection.title + ' ' + videoSection.titleItalic,
- subtitle: videoSection.description,
- category: 'activity',
- link: '/?section=video-experience',
- matchType: 'title'
- });
- // 6. Ships Page Content (Text on Images: Hero & Cards)
- const shipPage = (t as any).shipsPage;
- if (shipPage) {
- // Hero
- index.push({
- title: shipPage.hero_sub,
- subtitle: shipPage.hero_desc,
- category: 'ships',
- link: '/ships',
- matchType: 'title'
- });
- // Lanyue Image Card
- index.push({
- title: shipPage.lanyue.title,
- subtitle: shipPage.lanyue.desc,
- category: 'ships',
- link: '/ships?section=lanyue',
- matchType: 'title'
- });
- // Aurora Image Card
- index.push({
- title: shipPage.aurora.title,
- subtitle: shipPage.aurora.desc,
- category: 'ships',
- link: '/ships?section=aurora',
- matchType: 'title'
- });
- // Intro Text
- index.push({
- title: shipPage.intro.title,
- subtitle: shipPage.intro.desc,
- category: 'ships',
- link: '/ships?section=series',
- matchType: 'content'
- });
- }
- // 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]);
- // 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();
-
- // 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(() => {
- 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);
- }
- };
- if (!isOpen) return null;
- return (
- // Outer Wrapper: Fully transparent, used for positioning and click-outside detection
- <div
- className="fixed inset-0 z-[100] flex justify-center items-start pt-[15vh]"
- onClick={onClose}
- >
- {/*
- Inner Container:
- - Compact floating bar (capsule/rounded rect style)
- - Background: Semi-transparent dark glass (bg-black/60) to ensure readability
- - Dimensions: Tightly wrapped around text input (w-auto, min-width)
- */}
- <div
- className="relative w-auto min-w-[300px] md:min-w-[500px] max-w-2xl mx-4 bg-black/60 backdrop-blur-md rounded border border-white/10 shadow-2xl animate-fade-in-up px-4 py-2 flex flex-col"
- onClick={(e) => e.stopPropagation()}
- >
- <div className="flex items-center w-full">
- <Search className="text-white/60 mr-3 shrink-0" size={18} />
- <input
- ref={inputRef}
- type="text"
- value={query}
- onChange={(e) => setQuery(e.target.value)}
- placeholder={searchT.placeholder}
- className="flex-1 bg-transparent border-none text-xl font-serif italic text-white placeholder-white/40 focus:outline-none focus:ring-0 transition-colors tracking-wide h-10 px-0"
- />
- <button
- onClick={onClose}
- className="ml-3 text-white/50 hover:text-white transition-colors shrink-0"
- >
- <X size={18} />
- </button>
- </div>
- {/* 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">
- {/* 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>
- {suggestions.map((suggestion, idx) => (
- <div
- key={idx}
- 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">
- <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">{suggestion.text}</h3>
- <p className="text-white/50 text-[10px] mt-0.5">{suggestion.count} results</p>
- </div>
- </div>
- </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>
- )}
- {/* 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>
- </div>
- );
- };
- export default SearchOverlay;
|