HK VOYAGE MANIFEST // exploringhklife <style> /* BASE THEME: EDO PERIOD LEDGER (舒適江戶風) */ :root { --edo-paper: #f4f1ea; /* Washi Paper */ --edo-ink: #2b2b2b; /* Sumi Ink */ --edo-indigo: #1e3f5a; /* Aizome Blue */ --edo-indigo-light: #4a6a8a; --edo-vermilion: #bf3939; /* Inkan Red */ --edo-tea: #7d8a5b; /* Matcha */ } body { background-color: var(--edo-paper); /* Washi Paper Texture Pattern */ background-image: linear-gradient(90deg, rgba(0,0,0,0.03) 50%, transparent 50%), linear-gradient(rgba(0,0,0,0.03) 50%, transparent 50%); background-size: 4px 4px; color: var(--edo-ink); } /* Custom Scrollbar for "Scroll/Ledger" feel */ .custom-scroll::-webkit-scrollbar { width: 8px; } .custom-scroll::-webkit-scrollbar-track { background: rgba(30, 63, 90, 0.05); } .custom-scroll::-webkit-scrollbar-thumb { background-color: var(--edo-indigo); border-radius: 4px; border: 2px solid var(--edo-paper); } /* Animations */ @keyframes ink-reveal { 0% { opacity: 0; transform: translateY(10px); } 100% { opacity: 1; transform: translateY(0); } } .animate-ink { animation: ink-reveal 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) forwards; } /* Stamp Effect */ .hanko-stamp { border: 2px solid var(--edo-vermilion); color: var(--edo-vermilion); border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold; transform: rotate(-15deg); opacity: 0.8; } </style> </head> <body class="text-ink font-tc h-screen flex justify-center items-center p-2 md:p-6 overflow-hidden"> <!-- React Root --> <div id="root" class="w-full max-w-6xl h-full max-h-[900px] bg-[#fdfcf8] shadow-2xl rounded-sm overflow-hidden border-4 border-double border-[#1e3f5a] relative flex flex-col"> <!-- App Loading... --> <div class="absolute inset-0 flex items-center justify-center z-0"> <i class="fas fa-circle-notch fa-spin text-3xl text-[#1e3f5a] opacity-30"></i> </div> </div> <!-- REACT APPLICATION CODE --> <script type="text/babel"> const { useState, useEffect, useMemo } = React; // ========================================== // 1. API CONFIGURATION (Travelpayouts) // ========================================== // 參考: https://support.travelpayouts.com/hc/en-us/articles/203956163-Aviasales-Data-API const API_CONFIG = { enabled: false, // ⚠️ 設置為 true 以啟用真實 API // API Credentials token: 'd4c50e7326b7f4399239fe6f91fe7876', // Travelpayouts API Token marker: '686290', // Affiliate Marker (e.g., 12345) // Configuration currency: 'USD', origin: 'HKG', // 使用 'cheap' 端點獲取最優惠機票 baseUrl: "https://api.travelpayouts.com/v1/prices/cheap", // ⚠️ CORS 代理 (瀏覽器端調用必須) // 在生產環境中,您應該建立自己的後端代理。 // 這裡使用 cors-anywhere 作為演示 (可能需要先到該網站申請臨時權限) corsProxy: "https://cors-anywhere.herokuapp.com/", refreshInterval: 60000 }; // Fallback Data (Exploration of HK Life Themes) const FALLBACK_DATA = [ { id: 'CX-LON', airline: 'CX', airlineName: 'Cathay Pacific', destination: 'LHR', destName: 'London', price: 850, transfers: 0, departure: '2024-06-15', duration: '13h 15m' }, { id: 'JL-TYO', airline: 'JL', airlineName: 'Japan Airlines', destination: 'NRT', destName: 'Tokyo', price: 420, transfers: 0, departure: '2024-05-20', duration: '4h 30m' }, { id: 'BR-TPE', airline: 'BR', airlineName: 'EVA Air', destination: 'TPE', destName: 'Taipei', price: 210, transfers: 0, departure: '2024-05-12', duration: '1h 45m' }, { id: 'SQ-SIN', airline: 'SQ', airlineName: 'Singapore Air', destination: 'SIN', destName: 'Singapore', price: 380, transfers: 0, departure: '2024-07-01', duration: '4h 10m' }, { id: 'TG-BKK', airline: 'TG', airlineName: 'Thai Airways', destination: 'BKK', destName: 'Bangkok', price: 250, transfers: 0, departure: '2024-06-10', duration: '2h 50m' }, { id: 'KE-ICN', airline: 'KE', airlineName: 'Korean Air', destination: 'ICN', destName: 'Seoul', price: 320, transfers: 0, departure: '2024-08-15', duration: '3h 45m' }, { id: 'UA-SFO', airline: 'UA', airlineName: 'United', destination: 'SFO', destName: 'San Francisco', price: 980, transfers: 1, departure: '2024-09-01', duration: '13h 50m' }, { id: 'EK-DXB', airline: 'EK', airlineName: 'Emirates', destination: 'DXB', destName: 'Dubai', price: 650, transfers: 0, departure: '2024-07-20', duration: '8h 30m' }, { id: 'QF-SYD', airline: 'QF', airlineName: 'Qantas', destination: 'SYD', destName: 'Sydney', price: 720, transfers: 0, departure: '2024-10-05', duration: '9h 15m' }, { id: 'AC-YVR', airline: 'AC', airlineName: 'Air Canada', destination: 'YVR', destName: 'Vancouver', price: 890, transfers: 0, departure: '2024-06-25', duration: '12h 00m' }, ]; // Airline Codes for Mapping const AIRLINE_MAP = { 'CX': 'Cathay Pacific', 'JL': 'Japan Airlines', 'BR': 'EVA Air', 'SQ': 'Singapore Airlines', 'TG': 'Thai Airways', 'KE': 'Korean Air', 'UA': 'United', 'EK': 'Emirates', 'QF': 'Qantas', 'AC': 'Air Canada', 'NH': 'ANA', 'CI': 'China Airlines', 'HX': 'Hong Kong Airlines' }; // Translations const I18N = { 'TC': { title: '萬屋・機票大帳', subtitle: '全球航線 // 實時價格', search: '搜尋目的地、航空...', col_dest: '目的地 / 航空', col_time: '時間 / 轉機', col_price: '價格', trans_0: '直飛', trans_1: '1轉', trans_2: '2轉+', loading: '飛鴿傳書中...', update: '最後更新', action: '查看', error: '連接失敗,顯示存檔', footer: 'Exploring HK Life // 旅遊生活' }, 'EN': { title: 'THE FLIGHT LEDGER', subtitle: 'GLOBAL ROUTES // LIVE FARES', search: 'Search destination, airline...', col_dest: 'Route / Carrier', col_time: 'Schedule / Stops', col_price: 'Fare', trans_0: 'Direct', trans_1: '1 Stop', trans_2: '2 Stops+', loading: 'Fetching Data...', update: 'Last Updated', action: 'View', error: 'Connection failed, showing archives', footer: 'Exploring HK Life // Travel' } }; const App = () => { const [lang, setLang] = useState('TC'); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [search, setSearch] = useState(''); const [lastUpdate, setLastUpdate] = useState(''); const t = I18N[lang]; // API Call Logic const fetchData = async () => { setLoading(true); setError(null); // 如果 API 未啟用或沒有 Token,使用 Fallout 數據 + 模擬波動 if (!API_CONFIG.enabled || API_CONFIG.token === 'YOUR_API_TOKEN') { await new Promise(r => setTimeout(r, 1200)); // 模擬網絡延遲 // 模擬價格波動 const fluctuatingData = FALLBACK_DATA.map(item => ({ ...item, price: Math.round(item.price * (0.95 + Math.random() * 0.1)) })); setData(fluctuatingData); setLastUpdate(new Date().toLocaleTimeString()); setLoading(false); return; } try { // 建立 Travelpayouts API 請求 // 注意: "Cheap" API 返回結構為 { "success": true, "data": { "HKG": { "0": {...}, "1": {...} } } } // 我們通常需要指定目的地,但如果省略 destination,API 可能返回熱門航點 const targetUrl = `${API_CONFIG.corsProxy}${API_CONFIG.baseUrl}?origin=${API_CONFIG.origin}¤cy=${API_CONFIG.currency}&token=${API_CONFIG.token}&page=1&limit=20`; const response = await fetch(targetUrl); if (!response.ok) throw new Error("Network response was not ok"); const json = await response.json(); if (json.success && json.data) { const processedData = []; // 解析 API 複雜的嵌套結構 Object.keys(json.data).forEach(destCode => { const flights = json.data[destCode]; // 有時 flights 是一個物件(key是索引),有時是陣列,視 API 版本 const flightList = Array.isArray(flights) ? flights : Object.values(flights); flightList.forEach((flight, idx) => { processedData.push({ id: `${destCode}-${flight.airline}-${idx}`, destination: destCode, // IATA Code destName: destCode, // 在真實應用中應映射城市名 airline: flight.airline, airlineName: AIRLINE_MAP[flight.airline] || flight.airline, price: flight.price, transfers: flight.transfers || 0, departure: flight.departure_at.split('T')[0], // API 'cheap' 端點通常不返回 duration,需自行計算或顯示 'N/A' duration: flight.return_at ? 'Return' : 'One-way' }); }); }); setData(processedData); } else { throw new Error("API format error"); } } catch (err) { console.error("API Error:", err); setError(true); setData(FALLBACK_DATA); // 失敗時回退 } finally { setLastUpdate(new Date().toLocaleTimeString()); setLoading(false); } }; useEffect(() => { fetchData(); const interval = setInterval(fetchData, API_CONFIG.refreshInterval); return () => clearInterval(interval); }, []); // Filtering & Sorting const msgList = useMemo(() => { return data.filter(item => item.destination.toLowerCase().includes(search.toLowerCase()) || item.destName.toLowerCase().includes(search.toLowerCase()) || item.airlineName.toLowerCase().includes(search.toLowerCase()) ); }, [data, search]); const getTransferLabel = (stops) => { if (stops === 0) return <span class="text-[#7d8a5b] font-bold"><i class="fas fa-direct"></i> {t.trans_0}</span>; if (stops === 1) return <span class="text-[#1e3f5a]">{t.trans_1}</span>; return <span class="text-[#bf3939]">{t.trans_2}</span>; }; return ( <div className="flex flex-col h-full bg-[#fdfcf8] text-[#2b2b2b] font-serif"> {/* HEADER */} <header className="p-6 border-b border-[#1e3f5a]/20 shrink-0 bg-[#f4f1ea]/50"> <div className="flex justify-between items-start"> <div> <div className="text-xs font-bold tracking-[0.2em] text-[#bf3939] mb-1 uppercase"> {t.subtitle} </div> <h1 className="text-3xl md:text-4xl font-black tracking-tight text-[#1e3f5a]" style={{ fontFamily: '"Noto Serif TC", serif' }}> {t.title} </h1> </div> <div className="flex gap-2"> <button onClick={() => setLang(lang === 'TC' ? 'EN' : 'TC')} className="px-3 py-1 border border-[#1e3f5a] text-[#1e3f5a] hover:bg-[#1e3f5a] hover:text-white transition text-xs font-bold"> {lang} </button> </div> </div> {/* Controls */} <div className="mt-6 flex gap-4"> <div className="relative flex-1 border-b-2 border-[#1e3f5a]/30 focus-within:border-[#1e3f5a] transition-colors"> <i className="fas fa-search absolute left-0 top-3 text-[#1e3f5a]/40"></i> <input type="text" value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t.search} className="w-full bg-transparent py-2 pl-8 pr-4 focus:outline-none placeholder-[#1e3f5a]/30" /> </div> <button onClick={fetchData} className="flex items-center gap-2 px-4 py-2 bg-[#1e3f5a] text-white hover:bg-[#2b2b2b] transition shadow-md"> <i className={`fas fa-sync-alt ${loading ? 'fa-spin' : ''}`}></i> </button> </div> </header> {/* LIST */} <main className="flex-1 overflow-y-auto custom-scroll p-4"> {error && ( <div className="mb-4 p-3 bg-[#bf3939]/10 border-l-4 border-[#bf3939] text-[#bf3939] text-sm flex items-center gap-2"> <i className="fas fa-exclamation-triangle"></i> {t.error} </div> )} <div className="space-y-2"> {/* Header Row for Desktop */} <div className="hidden md:grid grid-cols-12 gap-4 px-4 py-2 text-xs font-bold text-[#1e3f5a]/50 uppercase tracking-wider"> <div className="col-span-5">{t.col_dest}</div> <div className="col-span-4 text-center">{t.col_time}</div> <div className="col-span-3 text-right">{t.col_price}</div> </div> {msgList.map((item, i) => ( <div key={item.id} className="animate-ink group bg-white border border-[#1e3f5a]/10 p-4 hover:border-[#1e3f5a] transition-all duration-300 shadow-sm hover:shadow-lg relative overflow-hidden" style={{animationDelay: `${i * 0.05}s`}}> {/* Deco Line */} <div className="absolute left-0 top-0 bottom-0 w-1 bg-[#bf3939] transform -translate-x-full group-hover:translate-x-0 transition-transform duration-300"></div> <div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-center"> {/* Route Info */} <div className="col-span-5"> <div className="flex items-center gap-3"> <div className="w-10 h-10 rounded-full bg-[#f4f1ea] flex items-center justify-center text-[#1e3f5a] font-bold text-sm border border-[#1e3f5a]/10"> {item.airline} </div> <div> <div className="text-lg font-bold text-[#1e3f5a] flex items-center gap-2"> HKG <i className="fas fa-long-arrow-alt-right text-xs opacity-50"></i> {item.destination} </div> <div className="text-xs text-[#1e3f5a]/60 font-mono"> {item.airlineName} </div> </div> </div> </div> {/* Time Info */} <div className="col-span-4 flex flex-row md:flex-col justify-between md:justify-center items-center md:text-center gap-1 text-sm border-t md:border-0 border-dashed border-gray-200 pt-2 md:pt-0 mt-2 md:mt-0"> <div className="font-mono text-[#2b2b2b]"> <i className="far fa-calendar-alt mr-1 opacity-50"></i> {item.departure} </div> <div className="text-xs"> {getTransferLabel(item.transfers)} • {item.duration} </div> </div> {/* Price Info */} <div className="col-span-3 flex flex-row md:flex-col justify-between md:justify-end items-center md:items-end gap-2 pt-2 md:pt-0 mt-2 md:mt-0"> <div className="text-2xl font-bold text-[#bf3939] font-mono"> ${item.price} </div> <a href={`https://www.aviasales.com/?marker=${API_CONFIG.marker}&origin=HKG&destination=${item.destination}`} target="_blank" rel="noopener noreferrer" className="px-4 py-1 bg-[#1e3f5a] text-white text-xs tracking-widest hover:bg-[#bf3939] transition-colors uppercase" > {t.action} </a> </div> </div> </div> ))} </div> </main> <footer className="p-4 border-t border-[#1e3f5a]/10 bg-[#f4f1ea] text-xs text-[#1e3f5a]/50 flex justify-between items-center"> <div>{t.footer}</div> <div className="flex items-center gap-2"> <span className="h-2 w-2 rounded-full bg-[#7d8a5b] animate-pulse"></span> {t.update}: {lastUpdate} </div> </footer> </div> ); }; const root = ReactDOM.createRoot(document.getElementById('root')); root.render(<App />); </script>