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>