import React, { useState, useEffect, useMemo } from 'react'; import { Calculator, Settings2, Zap, CreditCard, LogIn, LogOut } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { supabase } from './lib/supabase'; import { User } from '@supabase/supabase-js'; import { GlobalConfig, CardConfig, VendorState, UserSettings } from './types'; import { calculateCosts, formatNumber } from './utils/math'; import { VendorCard } from './components/VendorCard'; import { ConfigTab } from './components/ConfigTab'; const initialGlobal: GlobalConfig = { cashVndUsd: 26800, // Realistic VND/USD rate in VN bcaBeli: 16775, // Bali Money Changer rate (IDR/USD) usdAvailable: 600 }; const initialCards: CardConfig[] = [ // Final rates including fees (using strings for precision preservation in UI) { id: 'tcb-master-credit', name: 'TCB MasterC. Credit', networkRate: '1.5881862', usdRate: '27333' }, { id: 'tcb-visa-debit', name: 'TCB Visa Debit', networkRate: '1.580256', usdRate: '27280' } ]; export default function App() { const [activeTab, setActiveTab] = useState<'calc' | 'config'>('calc'); const [user, setUser] = useState(null); const [isReady, setIsReady] = useState(false); // App State const [globalConfig, setGlobalConfig] = useState(initialGlobal); const [cards, setCards] = useState(initialCards); const [activeCardId, setActiveCardId] = useState('tcb-master-credit'); // UI State const [isSaving, setIsSaving] = useState(false); const [saveStatus, setSaveStatus] = useState<'success' | null>(null); // Vendor States — Option A defaults to USD, Option B defaults to IDR const [optionA, setOptionA] = useState({ price: '', currency: 'USD', surcharge: false }); const [optionB, setOptionB] = useState({ price: '', currency: 'IDR', surcharge: false }); // 1. Auth useEffect(() => { // Check initial session supabase.auth.getSession().then(({ data: { session } }) => { setUser(session?.user ?? null); setIsReady(true); }); const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => { setUser(session?.user ?? null); if (event === 'INITIAL_SESSION' || event === 'SIGNED_IN' || event === 'SIGNED_OUT') { setIsReady(true); } if (event === 'SIGNED_IN' && window.location.hash) { window.history.replaceState(null, '', window.location.pathname); } }); return () => subscription.unsubscribe(); }, []); const handleGoogleLogin = async () => { try { const { error } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: window.location.origin } }); if (error) throw error; } catch (error) { console.error('Google Login failed:', error); } }; const handleGuestLogin = async () => { try { const { error } = await supabase.auth.signInAnonymously(); if (error) throw error; } catch (error) { console.error('Guest Login failed:', error); alert("Guest login failed. Please ensure 'Allow Anonymous Sign-ins' is enabled in Supabase."); } }; // 2. Data Sync useEffect(() => { if (!user) return; const fetchData = async () => { try { const { data, error } = await supabase .from('user_configs') .select('settings') .eq('user_id', user.id) .single(); if (error && error.code !== 'PGRST116') throw error; if (data?.settings) { const settings = data.settings as UserSettings; // Data Migration / Safety Check: spread defaults first, then override with saved data const loadedGlobal: GlobalConfig = { ...initialGlobal, ...settings.globalConfig }; // Remove legacy visaRate field if it crept in from old saves // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (loadedGlobal as any).visaRate; // Data migration: ensure new fields exist in old saves and remove deprecated ones const loadedCards = (settings.cards || initialCards).map((c: any) => { // Force legacy cards to new defaults if (c.id === '1' || c.id === '2') { return c.id === '1' ? initialCards.find(ic => ic.id === 'tcb-master-credit')! : initialCards.find(ic => ic.id === 'tcb-visa-debit')!; } const { fxRate, bankSellRate, ...rest } = c; // remove deprecated fields const initialMatch = initialCards.find(ic => ic.id === c.id); return { ...(initialMatch || initialCards[0]), ...rest, networkRate: String(c.networkRate ?? (initialMatch?.networkRate || '1.58')), usdRate: String(c.usdRate ?? bankSellRate ?? (initialMatch?.usdRate || '26000')), }; }); // If the saved activeCardId is legacy, update it let newActiveId = settings.activeCardId || 'tcb-master-credit'; if (newActiveId === '1') newActiveId = 'tcb-master-credit'; if (newActiveId === '2') newActiveId = 'tcb-visa-debit'; setGlobalConfig(loadedGlobal); setCards(loadedCards); setActiveCardId(newActiveId); } } catch (err) { console.error('Fetch error:', err); } finally { setIsReady(true); } }; fetchData(); }, [user]); const handleSave = async () => { if (!user) return; setIsSaving(true); try { // Sanitize cards to ensure numbers are stored as numbers const sanitizedCards = cards.map(c => ({ ...c, networkRate: typeof c.networkRate === 'string' ? parseFloat(c.networkRate) || 0 : c.networkRate, usdRate: typeof c.usdRate === 'string' ? parseFloat(c.usdRate) || 0 : c.usdRate })); const { error } = await supabase .from('user_configs') .upsert({ user_id: user.id, settings: { globalConfig, cards: sanitizedCards, activeCardId }, updated_at: new Date().toISOString() }); if (error) throw error; setSaveStatus('success'); setTimeout(() => setSaveStatus(null), 3000); } catch (error) { console.error('Save failed:', error); } finally { setIsSaving(false); } }; // 3. Engine const activeCard = useMemo(() => cards.find(c => c.id === activeCardId) || cards[0], [cards, activeCardId] ); const resultA = useMemo(() => calculateCosts(optionA, globalConfig, activeCard), [optionA, globalConfig, activeCard] ); const resultB = useMemo(() => calculateCosts(optionB, globalConfig, activeCard), [optionB, globalConfig, activeCard] ); // 4. Best Option const bestOptionId = useMemo(() => { const options = [ { id: 'A-cash', val: resultA.cashVnd, possible: resultA.isCashPossible }, { id: 'A-online', val: resultA.onlineVnd, possible: true }, { id: 'A-pos', val: resultA.posVnd, possible: true }, { id: 'B-cash', val: resultB.cashVnd, possible: resultB.isCashPossible }, { id: 'B-online', val: resultB.onlineVnd, possible: true }, { id: 'B-pos', val: resultB.posVnd, possible: true }, ].filter(o => o.val > 0 && o.possible); if (options.length === 0) return null; return options.reduce((prev, curr) => (prev.val < curr.val ? prev : curr)).id; }, [resultA, resultB]); const bestText = useMemo(() => { if (!bestOptionId) return null; const [vendor, method] = bestOptionId.split('-'); const vendorName = vendor === 'A' ? 'Tùy chọn A' : 'Tùy chọn B'; const methodName = method === 'cash' ? 'Tiền mặt' : method === 'online' ? 'Online Transaction' : 'Quẹt POS'; return { vendorName, methodName }; }, [bestOptionId]); // --- Screens --- if (!isReady) { return (

Đang chuẩn bị...

); } if (!user) { return (

Travel Optimizer

Vui lòng đăng nhập để lưu trữ hồ sơ vốn và danh mục thẻ của bạn.

); } return (
{/* Navigation Bar */}
v2.1
{activeTab === 'calc' ? ( {/* HUD: Asset Summary */}
Thẻ đang dùng
Tiền mặt khả dụng
{formatNumber(globalConfig.usdAvailable)} USD
{/* Recommendation Alert */} {bestText && (

GỢI Ý TỐI ƯU

Dùng {bestText.vendorName} qua {bestText.methodName} tiết kiệm nhất.

)}
) : ( )}
); }