SearchOverlay.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. import React, { useState, useMemo, useEffect, useRef } from 'react';
  2. import { Search, X, ArrowRight, Compass, Ship, Utensils, Anchor, Film } from 'lucide-react';
  3. import { useLanguage } from '../contexts/LanguageContext.tsx';
  4. import { useTheme } from '../contexts/ThemeContext.tsx';
  5. import { CONTENT } from '../constants.ts';
  6. import { useNavigate } from 'react-router-dom';
  7. interface SearchOverlayProps {
  8. isOpen: boolean;
  9. onClose: () => void;
  10. }
  11. interface SearchResult {
  12. title: string;
  13. subtitle?: string;
  14. category: 'home' | 'itinerary' | 'ships' | 'dining' | 'activity';
  15. link: string;
  16. matchType: 'title' | 'content';
  17. }
  18. interface SearchSuggestion {
  19. text: string;
  20. count: number;
  21. category: 'home' | 'itinerary' | 'ships' | 'dining' | 'activity' | 'all';
  22. }
  23. const SearchOverlay: React.FC<SearchOverlayProps> = ({ isOpen, onClose }) => {
  24. const { language } = useLanguage();
  25. const { itineraries, ships, dining, videoSection } = useTheme();
  26. const [query, setQuery] = useState('');
  27. const [showSuggestions, setShowSuggestions] = useState(true);
  28. const inputRef = useRef<HTMLInputElement>(null);
  29. const navigate = useNavigate();
  30. const t = CONTENT[language];
  31. const searchT = (CONTENT[language] as any).search || {
  32. placeholder: "Search...",
  33. no_results: "No results",
  34. results_for: "Results: ",
  35. categories: { home: "Home", ships: "Ships", itinerary: "Trips", dining: "Food", activity: "Fun" }
  36. };
  37. // Focus input when opened
  38. useEffect(() => {
  39. if (isOpen) {
  40. setTimeout(() => {
  41. inputRef.current?.focus();
  42. }, 100);
  43. // Removed body scroll lock to keep it feeling lightweight
  44. }
  45. }, [isOpen]);
  46. // Handle ESC key
  47. useEffect(() => {
  48. const handleEsc = (e: KeyboardEvent) => {
  49. if (e.key === 'Escape') onClose();
  50. };
  51. window.addEventListener('keydown', handleEsc);
  52. return () => window.removeEventListener('keydown', handleEsc);
  53. }, [onClose]);
  54. // Build Search Index
  55. const searchIndex: SearchResult[] = useMemo(() => {
  56. const index: SearchResult[] = [];
  57. // 1. Home - Hero (Text on Image)
  58. index.push({
  59. title: t.hero.title_normal + ' ' + t.hero.title_italic,
  60. subtitle: t.hero.subtitle,
  61. category: 'home',
  62. link: '/',
  63. matchType: 'title'
  64. });
  65. // 2. Dynamic Itineraries (Cards / Images)
  66. itineraries.forEach(item => {
  67. index.push({
  68. title: item.title,
  69. subtitle: `${item.route} - ${item.price}`,
  70. category: 'itinerary',
  71. link: '/?section=featured-itineraries',
  72. matchType: 'title'
  73. });
  74. });
  75. // 3. Dynamic Ships (Cards / Images)
  76. ships.forEach(item => {
  77. index.push({
  78. title: item.name,
  79. subtitle: item.description,
  80. category: 'ships',
  81. link: '/?section=our-fleet',
  82. matchType: 'title'
  83. });
  84. });
  85. // 4. Dynamic Dining (Banner Image)
  86. index.push({
  87. title: dining.title,
  88. subtitle: dining.description,
  89. category: 'dining',
  90. link: '/?section=dining-experience',
  91. matchType: 'title'
  92. });
  93. // 5. Dynamic Video/Activity (Thumbnail)
  94. index.push({
  95. title: videoSection.title + ' ' + videoSection.titleItalic,
  96. subtitle: videoSection.description,
  97. category: 'activity',
  98. link: '/?section=video-experience',
  99. matchType: 'title'
  100. });
  101. // 6. Ships Page Content (Text on Images: Hero & Cards)
  102. const shipPage = (t as any).shipsPage;
  103. if (shipPage) {
  104. // Hero
  105. index.push({
  106. title: shipPage.hero_sub,
  107. subtitle: shipPage.hero_desc,
  108. category: 'ships',
  109. link: '/ships',
  110. matchType: 'title'
  111. });
  112. // Lanyue Image Card
  113. index.push({
  114. title: shipPage.lanyue.title,
  115. subtitle: shipPage.lanyue.desc,
  116. category: 'ships',
  117. link: '/ships?section=lanyue',
  118. matchType: 'title'
  119. });
  120. // Aurora Image Card
  121. index.push({
  122. title: shipPage.aurora.title,
  123. subtitle: shipPage.aurora.desc,
  124. category: 'ships',
  125. link: '/ships?section=aurora',
  126. matchType: 'title'
  127. });
  128. // Intro Text
  129. index.push({
  130. title: shipPage.intro.title,
  131. subtitle: shipPage.intro.desc,
  132. category: 'ships',
  133. link: '/ships?section=series',
  134. matchType: 'content'
  135. });
  136. }
  137. // 7. CruiseSpace Page Content
  138. const cruiseSpacePage = (t as any).shipsPage?.spaces;
  139. if (cruiseSpacePage) {
  140. // Facilities
  141. index.push({
  142. title: cruiseSpacePage.facilities.title,
  143. subtitle: cruiseSpacePage.facilities.desc,
  144. category: 'ships',
  145. link: '/cruise-space?section=facilities',
  146. matchType: 'title'
  147. });
  148. // Facilities Items
  149. cruiseSpacePage.facilities.items?.forEach((item: any) => {
  150. index.push({
  151. title: item.title,
  152. subtitle: item.desc,
  153. category: 'ships',
  154. link: '/cruise-space?section=facilities',
  155. matchType: 'content'
  156. });
  157. });
  158. // Rooms
  159. index.push({
  160. title: cruiseSpacePage.rooms.title,
  161. subtitle: cruiseSpacePage.rooms.desc,
  162. category: 'ships',
  163. link: '/cruise-space?section=rooms',
  164. matchType: 'title'
  165. });
  166. // Rooms Types
  167. cruiseSpacePage.rooms.types?.forEach((room: any) => {
  168. index.push({
  169. title: room.name,
  170. subtitle: room.desc,
  171. category: 'ships',
  172. link: '/cruise-space?section=rooms',
  173. matchType: 'content'
  174. });
  175. });
  176. // VIP Privileges
  177. index.push({
  178. title: cruiseSpacePage.vip.title,
  179. subtitle: cruiseSpacePage.vip.desc,
  180. category: 'ships',
  181. link: '/cruise-space?section=vip',
  182. matchType: 'title'
  183. });
  184. // VIP Benefits
  185. cruiseSpacePage.vip.benefits?.forEach((benefit: string) => {
  186. index.push({
  187. title: benefit,
  188. category: 'ships',
  189. link: '/cruise-space?section=vip',
  190. matchType: 'content'
  191. });
  192. });
  193. }
  194. // 8. Guide Page Content
  195. const guidePage = t.guide;
  196. if (guidePage) {
  197. // Hero
  198. index.push({
  199. title: guidePage.hero.title,
  200. subtitle: guidePage.hero.subtitle,
  201. category: 'activity',
  202. link: '/guide',
  203. matchType: 'title'
  204. });
  205. // Travel Tips
  206. index.push({
  207. title: guidePage.tips.sectionSubtitle,
  208. subtitle: guidePage.tips.sectionTitle,
  209. category: 'activity',
  210. link: '/guide?section=tips',
  211. matchType: 'title'
  212. });
  213. // Tips Categories
  214. guidePage.tips.categories?.forEach((category: any) => {
  215. index.push({
  216. title: category.title,
  217. category: 'activity',
  218. link: '/guide?section=tips',
  219. matchType: 'content'
  220. });
  221. // Tips Items
  222. category.items?.forEach((item: string) => {
  223. index.push({
  224. title: item,
  225. category: 'activity',
  226. link: '/guide?section=tips',
  227. matchType: 'content'
  228. });
  229. });
  230. });
  231. // FAQ
  232. index.push({
  233. title: guidePage.faq.sectionSubtitle,
  234. subtitle: guidePage.faq.sectionTitle,
  235. category: 'activity',
  236. link: '/guide?section=faq',
  237. matchType: 'title'
  238. });
  239. // FAQ Items
  240. guidePage.faq.items?.forEach((faq: any) => {
  241. index.push({
  242. title: faq.question,
  243. subtitle: faq.answer,
  244. category: 'activity',
  245. link: '/guide?section=faq',
  246. matchType: 'content'
  247. });
  248. });
  249. }
  250. return index;
  251. }, [language, t, itineraries, ships, dining, videoSection]);
  252. // Generate Search Suggestions
  253. const suggestions = useMemo(() => {
  254. if (!query.trim() || query.length < 2 || !showSuggestions) return [];
  255. const lowerQuery = query.toLowerCase();
  256. // Extract unique terms from search index
  257. const allTerms = new Set<string>();
  258. searchIndex.forEach(item => {
  259. // Split title into words
  260. item.title.toLowerCase().split(/\s+/).forEach(word => {
  261. if (word.length > 2) allTerms.add(word);
  262. });
  263. // Split subtitle into words if exists
  264. if (item.subtitle) {
  265. item.subtitle.toLowerCase().split(/\s+/).forEach(word => {
  266. if (word.length > 2) allTerms.add(word);
  267. });
  268. }
  269. });
  270. // Find terms that start with or contain the query
  271. const matchingTerms = Array.from(allTerms)
  272. .filter(term => term.startsWith(lowerQuery))
  273. .sort();
  274. // Limit to top 5 suggestions
  275. return matchingTerms.slice(0, 5).map(term => ({
  276. text: term,
  277. count: searchIndex.filter(item =>
  278. item.title.toLowerCase().includes(term) ||
  279. (item.subtitle && item.subtitle.toLowerCase().includes(term))
  280. ).length,
  281. category: 'all'
  282. }));
  283. }, [query, searchIndex, showSuggestions]);
  284. // Filter Results with Enhanced Matching and Grouping
  285. const filteredResults = useMemo(() => {
  286. if (!query.trim()) return [];
  287. const lowerQuery = query.toLowerCase();
  288. // Calculate relevance score for each match
  289. const resultsWithScore = searchIndex
  290. .map(item => {
  291. const titleLower = item.title.toLowerCase();
  292. const subtitleLower = item.subtitle?.toLowerCase() || '';
  293. // Basic match check
  294. const titleMatches = titleLower.includes(lowerQuery);
  295. const subtitleMatches = subtitleLower.includes(lowerQuery);
  296. if (!titleMatches && !subtitleMatches) return null;
  297. // Calculate relevance score
  298. let score = 0;
  299. // Title matches are more important
  300. if (titleMatches) {
  301. score += 10;
  302. // Exact match in title gives extra points
  303. if (titleLower === lowerQuery) score += 20;
  304. // Start of title match gives more points
  305. if (titleLower.startsWith(lowerQuery)) score += 5;
  306. }
  307. // Subtitle matches are less important
  308. if (subtitleMatches) {
  309. score += 5;
  310. if (subtitleLower === lowerQuery) score += 10;
  311. if (subtitleLower.startsWith(lowerQuery)) score += 3;
  312. }
  313. // Match type bonus
  314. if (item.matchType === 'title') score += 3;
  315. // Category priority (home > itinerary > ships > dining > activity)
  316. const categoryPriority = {
  317. home: 5,
  318. itinerary: 4,
  319. ships: 3,
  320. dining: 2,
  321. activity: 1
  322. };
  323. score += categoryPriority[item.category] || 0;
  324. return { ...item, score };
  325. })
  326. .filter((item): item is typeof item & { score: number } => item !== null);
  327. // Sort by relevance score (descending)
  328. const sortedResults = resultsWithScore.sort((a, b) => b.score - a.score);
  329. return sortedResults;
  330. }, [query, searchIndex]);
  331. // Group results by category for better display
  332. const groupedResults = useMemo(() => {
  333. if (!filteredResults.length) return [];
  334. const categories = ['home', 'itinerary', 'ships', 'dining', 'activity'] as const;
  335. const groups: { category: string; results: typeof filteredResults }[] = [];
  336. categories.forEach(category => {
  337. const categoryResults = filteredResults.filter(item => item.category === category);
  338. if (categoryResults.length > 0) {
  339. groups.push({ category, results: categoryResults });
  340. }
  341. });
  342. return groups;
  343. }, [filteredResults]);
  344. const handleResultClick = (link: string) => {
  345. onClose();
  346. if (link.startsWith('/?section=')) {
  347. const sectionId = link.split('=')[1];
  348. navigate('/');
  349. // Use a safer timeout and scroll logic
  350. setTimeout(() => {
  351. const element = document.getElementById(sectionId);
  352. if (element) {
  353. // Add some offset to account for fixed header
  354. const headerOffset = 80;
  355. const elementPosition = element.getBoundingClientRect().top;
  356. const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
  357. window.scrollTo({
  358. top: offsetPosition,
  359. behavior: 'smooth'
  360. });
  361. }
  362. }, 150);
  363. } else if (link.includes('?section=')) {
  364. // Handle other section links with proper scroll logic
  365. const [path, queryString] = link.split('?');
  366. const params = new URLSearchParams(queryString);
  367. const section = params.get('section');
  368. navigate(path);
  369. if (section) {
  370. setTimeout(() => {
  371. const elementId = link.startsWith('/cruise-space') ? `space-${section}` : section;
  372. const element = document.getElementById(elementId);
  373. if (element) {
  374. const headerOffset = 80;
  375. const elementPosition = element.getBoundingClientRect().top;
  376. const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
  377. window.scrollTo({
  378. top: offsetPosition,
  379. behavior: 'smooth'
  380. });
  381. }
  382. }, 200);
  383. }
  384. } else {
  385. navigate(link);
  386. }
  387. };
  388. if (!isOpen) return null;
  389. return (
  390. // Outer Wrapper: Fully transparent, used for positioning and click-outside detection
  391. <div
  392. className="fixed inset-0 z-[100] flex justify-center items-start pt-[15vh]"
  393. onClick={onClose}
  394. >
  395. {/*
  396. Inner Container:
  397. - Compact floating bar (capsule/rounded rect style)
  398. - Background: Semi-transparent dark glass (bg-black/60) to ensure readability
  399. - Dimensions: Tightly wrapped around text input (w-auto, min-width)
  400. */}
  401. <div
  402. 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"
  403. onClick={(e) => e.stopPropagation()}
  404. >
  405. <div className="flex items-center w-full">
  406. <Search className="text-white/60 mr-3 shrink-0" size={18} />
  407. <input
  408. ref={inputRef}
  409. type="text"
  410. value={query}
  411. onChange={(e) => setQuery(e.target.value)}
  412. placeholder={searchT.placeholder}
  413. 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"
  414. />
  415. <button
  416. onClick={onClose}
  417. className="ml-3 text-white/50 hover:text-white transition-colors shrink-0"
  418. >
  419. <X size={18} />
  420. </button>
  421. </div>
  422. {/* Results Dropdown - Floating immediately below the bar */}
  423. {(query.trim() !== '') && (
  424. <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">
  425. {/* Search Suggestions */}
  426. {suggestions.length > 0 && showSuggestions && query.length < 20 && (
  427. <div className="p-2 border-b border-white/5">
  428. <div className="px-4 py-2 text-vista-gold text-[10px] font-bold uppercase tracking-widest">
  429. Suggestions
  430. </div>
  431. {suggestions.map((suggestion, idx) => (
  432. <div
  433. key={idx}
  434. onClick={() => {
  435. setQuery(suggestion.text);
  436. setShowSuggestions(false);
  437. }}
  438. className="group flex items-center justify-between p-3 hover:bg-white/10 cursor-pointer transition-colors rounded-sm"
  439. >
  440. <div className="flex items-center gap-3">
  441. <Search className="text-white/40 group-hover:text-vista-gold transition-colors w-4 h-4" />
  442. <div>
  443. <h3 className="text-base font-serif text-white group-hover:text-vista-gold transition-colors">{suggestion.text}</h3>
  444. <p className="text-white/50 text-[10px] mt-0.5">{suggestion.count} results</p>
  445. </div>
  446. </div>
  447. </div>
  448. ))}
  449. <button
  450. onClick={() => setShowSuggestions(false)}
  451. 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"
  452. >
  453. Show Results for "{query}"
  454. </button>
  455. </div>
  456. )}
  457. {/* Search Results */}
  458. {(!suggestions.length || !showSuggestions) && (
  459. <div>
  460. {filteredResults.length > 0 ? (
  461. <div className="p-2">
  462. <div className="px-4 py-2 text-vista-gold text-[10px] font-bold uppercase tracking-widest border-b border-white/5 mb-2">
  463. {searchT.results_for} "{query}"
  464. </div>
  465. {groupedResults.map((group, groupIdx) => (
  466. <div key={groupIdx} className="mb-4 last:mb-0">
  467. <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">
  468. {group.category === 'itinerary' && <Compass size={14} />}
  469. {group.category === 'ships' && <Ship size={14} />}
  470. {group.category === 'dining' && <Utensils size={14} />}
  471. {group.category === 'activity' && <Film size={14} />}
  472. {group.category === 'home' && <Anchor size={14} />}
  473. {searchT.categories?.[group.category] || group.category}
  474. <span className="text-white/30">({group.results.length})</span>
  475. </div>
  476. <div className="mt-2">
  477. {group.results.map((result, resultIdx) => (
  478. <div
  479. key={resultIdx}
  480. onClick={() => handleResultClick(result.link)}
  481. className="group flex items-center justify-between p-3 hover:bg-white/10 cursor-pointer transition-colors rounded-sm mx-2"
  482. >
  483. <div className="flex items-center gap-3">
  484. <div className="w-1 h-1 rounded-full bg-vista-gold group-hover:scale-150 transition-transform"></div>
  485. <div>
  486. <h3 className="text-base font-serif text-white group-hover:text-vista-gold transition-colors">{result.title}</h3>
  487. {result.subtitle && (
  488. <p className="text-white/50 text-[10px] mt-0.5 line-clamp-1">{result.subtitle}</p>
  489. )}
  490. </div>
  491. </div>
  492. <ArrowRight className="text-white/20 group-hover:text-vista-gold w-3 h-3" />
  493. </div>
  494. ))}
  495. </div>
  496. </div>
  497. ))}
  498. </div>
  499. ) : (
  500. <div className="p-6 text-center text-white/40 font-serif italic text-base">
  501. {searchT.no_results}
  502. </div>
  503. )}
  504. </div>
  505. )}
  506. </div>
  507. )}
  508. </div>
  509. </div>
  510. );
  511. };
  512. export default SearchOverlay;