diff --git a/package-lock.json b/package-lock.json index 5b09d07..ed6f676 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@vercel/speed-insights": "^1.2.0", "axios": "^1.12.0", "date-fns": "^4.1.0", - "next": "16.1.5", + "next": "16.2.2", "react": "^19.0.0", "react-datepicker": "^8.4.0", "react-dom": "^19.0.0", @@ -906,9 +906,9 @@ } }, "node_modules/@next/env": { - "version": "16.1.5", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.5.tgz", - "integrity": "sha512-CRSCPJiSZoi4Pn69RYBDI9R7YK2g59vLexPQFXY0eyw+ILevIenCywzg+DqmlBik9zszEnw2HLFOUlLAcJbL7g==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz", + "integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -922,9 +922,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.5.tgz", - "integrity": "sha512-eK7Wdm3Hjy/SCL7TevlH0C9chrpeOYWx2iR7guJDaz4zEQKWcS1IMVfMb9UKBFMg1XgzcPTYPIp1Vcpukkjg6Q==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz", + "integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==", "cpu": [ "arm64" ], @@ -938,9 +938,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.5.tgz", - "integrity": "sha512-foQscSHD1dCuxBmGkbIr6ScAUF6pRoDZP6czajyvmXPAOFNnQUJu2Os1SGELODjKp/ULa4fulnBWoHV3XdPLfA==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz", + "integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==", "cpu": [ "x64" ], @@ -954,9 +954,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.5.tgz", - "integrity": "sha512-qNIb42o3C02ccIeSeKjacF3HXotGsxh/FMk/rSRmCzOVMtoWH88odn2uZqF8RLsSUWHcAqTgYmPD3pZ03L9ZAA==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz", + "integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==", "cpu": [ "arm64" ], @@ -970,9 +970,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.5.tgz", - "integrity": "sha512-U+kBxGUY1xMAzDTXmuVMfhaWUZQAwzRaHJ/I6ihtR5SbTVUEaDRiEU9YMjy1obBWpdOBuk1bcm+tsmifYSygfw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz", + "integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==", "cpu": [ "arm64" ], @@ -986,9 +986,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.5.tgz", - "integrity": "sha512-gq2UtoCpN7Ke/7tKaU7i/1L7eFLfhMbXjNghSv0MVGF1dmuoaPeEVDvkDuO/9LVa44h5gqpWeJ4mRRznjDv7LA==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz", + "integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==", "cpu": [ "x64" ], @@ -1002,9 +1002,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.5.tgz", - "integrity": "sha512-bQWSE729PbXT6mMklWLf8dotislPle2L70E9q6iwETYEOt092GDn0c+TTNj26AjmeceSsC4ndyGsK5nKqHYXjQ==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz", + "integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==", "cpu": [ "x64" ], @@ -1018,9 +1018,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.5.tgz", - "integrity": "sha512-LZli0anutkIllMtTAWZlDqdfvjWX/ch8AFK5WgkNTvaqwlouiD1oHM+WW8RXMiL0+vAkAJyAGEzPPjO+hnrSNQ==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz", + "integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==", "cpu": [ "arm64" ], @@ -1034,9 +1034,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.5.tgz", - "integrity": "sha512-7is37HJTNQGhjPpQbkKjKEboHYQnCgpVt/4rBrrln0D9nderNxZ8ZWs8w1fAtzUx7wEyYjQ+/13myFgFj6K2Ng==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz", + "integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==", "cpu": [ "x64" ], @@ -3808,9 +3808,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, @@ -5205,14 +5205,14 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.1.5", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.5.tgz", - "integrity": "sha512-f+wE+NSbiQgh3DSAlTaw2FwY5yGdVViAtp8TotNQj4kk4Q8Bh1sC/aL9aH+Rg1YAVn18OYXsRDT7U/079jgP7w==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz", + "integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==", "license": "MIT", "dependencies": { - "@next/env": "16.1.5", + "@next/env": "16.2.2", "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -5224,15 +5224,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.5", - "@next/swc-darwin-x64": "16.1.5", - "@next/swc-linux-arm64-gnu": "16.1.5", - "@next/swc-linux-arm64-musl": "16.1.5", - "@next/swc-linux-x64-gnu": "16.1.5", - "@next/swc-linux-x64-musl": "16.1.5", - "@next/swc-win32-arm64-msvc": "16.1.5", - "@next/swc-win32-x64-msvc": "16.1.5", - "sharp": "^0.34.4" + "@next/swc-darwin-arm64": "16.2.2", + "@next/swc-darwin-x64": "16.2.2", + "@next/swc-linux-arm64-gnu": "16.2.2", + "@next/swc-linux-arm64-musl": "16.2.2", + "@next/swc-linux-x64-gnu": "16.2.2", + "@next/swc-linux-x64-musl": "16.2.2", + "@next/swc-win32-arm64-msvc": "16.2.2", + "@next/swc-win32-x64-msvc": "16.2.2", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -5523,9 +5523,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { @@ -6442,9 +6442,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index a6a611d..b69076c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@vercel/speed-insights": "^1.2.0", "axios": "^1.12.0", "date-fns": "^4.1.0", - "next": "16.1.5", + "next": "16.2.2", "react": "^19.0.0", "react-datepicker": "^8.4.0", "react-dom": "^19.0.0", diff --git a/src/app/api/exchange-rates/route.js b/src/app/api/exchange-rates/route.js index 8576418..ba46c5f 100644 --- a/src/app/api/exchange-rates/route.js +++ b/src/app/api/exchange-rates/route.js @@ -11,27 +11,12 @@ const axiosInstance = axios.create({ const DISPLAY_DATE_FORMAT = "yyyy-MM-dd"; const PARALLEL_CHUNK_SIZE = 5; -const MAX_RETRIES = 2; -const INITIAL_BACKOFF_MS = 500; - -// Retry a function with exponential backoff -async function withRetry(fn, retries = MAX_RETRIES) { - for (let attempt = 0; attempt <= retries; attempt++) { - try { - return await fn(); - } catch (error) { - if (attempt === retries) throw error; - const delay = INITIAL_BACKOFF_MS * Math.pow(2, attempt); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } -} async function fetchBIDVRates(dateObj, currency) { const dateStr = format(dateObj, "dd/MM/yyyy"); try { const timeUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailSearchTimeServlet?date=${dateStr}`; - const timeRes = await withRetry(() => axiosInstance.get(timeUrl)); + const timeRes = await axiosInstance.get(timeUrl); const timeData = timeRes.data; if (timeData.status !== 1 || !timeData.data?.length) return null; @@ -41,7 +26,7 @@ async function fetchBIDVRates(dateObj, currency) { ); const rateUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailServlet?date=${dateStr}&time=${latest.namerecord}`; - const rateRes = await withRetry(() => axiosInstance.get(rateUrl)); + const rateRes = await axiosInstance.get(rateUrl); const rateData = rateRes.data; if (rateData.status !== 1 || !rateData.data) return null; @@ -70,7 +55,7 @@ async function fetchTCBRates(dateObj, currency) { const dateStr = format(dateObj, "yyyy-MM-dd"); const url = `https://techcombank.com/content/techcombank/web/vn/vi/cong-cu-tien-ich/ty-gia/_jcr_content.exchange-rates.${dateStr}.integration.json`; try { - const res = await withRetry(() => axiosInstance.get(url)); + const res = await axiosInstance.get(url); const data = res.data; if (!data.exchangeRate?.data) return null; @@ -180,7 +165,7 @@ export async function POST(request) { results.push(...chunkResults); } - return Response.json({ data: results, total: dates.length, chunks: Math.ceil(dates.length / PARALLEL_CHUNK_SIZE) }); + return Response.json({ data: results, total: dates.length }); } catch (error) { console.error("Server error:", error); return Response.json( diff --git a/src/app/components/ExchangeRateViewer.js b/src/app/components/ExchangeRateViewer.js index ba6737b..b8706c5 100644 --- a/src/app/components/ExchangeRateViewer.js +++ b/src/app/components/ExchangeRateViewer.js @@ -3,29 +3,12 @@ import React, { useState, useEffect, useCallback } from "react"; import DatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; -import { format, subDays, startOfMonth, isSameDay, differenceInCalendarDays } from "date-fns"; +import { format, subDays, startOfMonth } from "date-fns"; import * as XLSX from "xlsx"; import { LoadingSpinner, ErrorMessage, EmptyState } from "./exchange-rate-status"; import { RateTable, ComparisonTable } from "./exchange-rate-table"; import { RateTrendChart } from "./exchange-rate-chart"; -// Read state from URL search params (takes priority over localStorage) -function loadFromUrl() { - if (typeof window === "undefined") return null; - const params = new URLSearchParams(window.location.search); - const bank = params.get("bank"); - const currency = params.get("currency"); - const start = params.get("start"); - const end = params.get("end"); - if (!bank && !currency && !start && !end) return null; - return { - bank: bank || null, - currency: currency || null, - startDate: start ? new Date(start) : null, - endDate: end ? new Date(end) : null, - }; -} - function loadSavedSettings() { if (typeof window === "undefined") return null; try { @@ -66,82 +49,11 @@ const DATE_PRESETS = [ { label: "This month", start: () => startOfMonth(new Date()), end: () => new Date() }, ]; -function getActivePreset(startDate, endDate) { - if (!startDate || !endDate) return null; - for (const preset of DATE_PRESETS) { - if (isSameDay(startDate, preset.start()) && isSameDay(endDate, preset.end())) { - return preset.label; - } - } - return null; -} - -function applyTheme(value) { - const root = document.documentElement; - if (value === "system") { - root.removeAttribute("data-theme"); - } else { - root.setAttribute("data-theme", value); - } -} - -const THEME_CYCLE = ["system", "light", "dark"]; -const THEME_LABELS = { system: "System", light: "Light", dark: "Dark" }; - -function SunIcon() { - return ( - - ); -} - -function MoonIcon() { - return ( - - ); -} - -function SystemIcon() { - return ( - - ); -} - -// Inline SVG icons (16x16) to avoid adding an icon library dependency -function FetchIcon() { - return ( - - ); -} - -function ExcelIcon() { - return ( - - ); -} - -function CsvIcon() { - return ( - - ); -} - export default function ExchangeRateViewer() { - const [startDate, setStartDate] = useState(() => subDays(new Date(), 7)); - const [endDate, setEndDate] = useState(() => new Date()); - const [selectedBank, setSelectedBank] = useState("bidv"); - const [selectedCurrency, setSelectedCurrency] = useState("USD"); + const [startDate, setStartDate] = useState(() => loadSavedSettings()?.startDate || subDays(new Date(), 7)); + const [endDate, setEndDate] = useState(() => loadSavedSettings()?.endDate || new Date()); + const [selectedBank, setSelectedBank] = useState(() => loadSavedSettings()?.bank || "bidv"); + const [selectedCurrency, setSelectedCurrency] = useState(() => loadSavedSettings()?.currency || "USD"); const [compareMode, setCompareMode] = useState(false); const [showChart, setShowChart] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -149,36 +61,10 @@ export default function ExchangeRateViewer() { const [results, setResults] = useState([]); const [hasFetched, setHasFetched] = useState(false); const [progress, setProgress] = useState(null); - const [theme, setTheme] = useState("system"); - // Restore saved settings on client mount — URL params take priority over localStorage - const [hydrated, setHydrated] = useState(false); useEffect(() => { - const urlSettings = loadFromUrl(); - const saved = urlSettings || loadSavedSettings(); - if (saved) { - if (saved.bank) setSelectedBank(saved.bank); - if (saved.currency) setSelectedCurrency(saved.currency); - if (saved.startDate) setStartDate(saved.startDate); - if (saved.endDate) setEndDate(saved.endDate); - } - const savedTheme = localStorage.getItem("theme") || "system"; - setTheme(savedTheme); - applyTheme(savedTheme); - setHydrated(true); - }, []); - - // Sync state to both localStorage and URL - useEffect(() => { - if (!hydrated) return; saveSettings(selectedBank, selectedCurrency, startDate, endDate); - const params = new URLSearchParams(); - params.set("bank", selectedBank); - params.set("currency", selectedCurrency); - if (startDate) params.set("start", format(startDate, "yyyy-MM-dd")); - if (endDate) params.set("end", format(endDate, "yyyy-MM-dd")); - window.history.replaceState(null, "", `?${params.toString()}`); - }, [selectedBank, selectedCurrency, startDate, endDate, hydrated]); + }, [selectedBank, selectedCurrency, startDate, endDate]); const fetchBank = async (bank) => { const response = await fetch("/api/exchange-rates", { @@ -211,8 +97,7 @@ export default function ExchangeRateViewer() { setIsLoading(true); setResults([]); setHasFetched(false); - const totalDays = differenceInCalendarDays(endDate, startDate) + 1; - setProgress({ current: 0, total: totalDays }); + setProgress(null); try { if (compareMode) { const [bidvData, tcbData] = await Promise.all([fetchBank("bidv"), fetchBank("tcb")]); @@ -244,8 +129,8 @@ export default function ExchangeRateViewer() { }, [startDate, endDate, selectedBank, selectedCurrency, compareMode]); useEffect(() => { - if (hydrated) handleFetch(); - }, [hydrated]); // eslint-disable-line react-hooks/exhaustive-deps + handleFetch(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps const getExportRows = () => { if (compareMode) { @@ -285,38 +170,27 @@ export default function ExchangeRateViewer() { URL.revokeObjectURL(url); }; - const activePreset = getActivePreset(startDate, endDate); + const inputStyle = { + background: "var(--input-bg)", + color: "var(--foreground)", + borderColor: "var(--input-border)", + }; return (
-
-
-

Exchange Rate Export

-

- Fetch exchange rates from Vietnamese banks and export to CSV. -

-
- +
+

Exchange Rate Export

+

+ Fetch exchange rates from Vietnamese banks and export to CSV. +

{ if (e.key === "Enter" && !isLoading) handleFetch(); }} >
- {/* Row 1: Selectors and date pickers */}
@@ -325,7 +199,8 @@ export default function ExchangeRateViewer() { value={selectedBank} onChange={(e) => setSelectedBank(e.target.value)} disabled={compareMode} - className="border rounded-lg px-3 py-2 text-sm w-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/30 disabled:opacity-50 bg-[var(--input-bg)] text-[var(--foreground)] border-[var(--input-border)]" + className="border rounded-lg px-3 py-2 text-sm w-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/30 disabled:opacity-50" + style={inputStyle} > @@ -337,7 +212,8 @@ export default function ExchangeRateViewer() { id="currency" value={selectedCurrency} onChange={(e) => setSelectedCurrency(e.target.value)} - className="border rounded-lg px-3 py-2 text-sm w-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/30 bg-[var(--input-bg)] text-[var(--foreground)] border-[var(--input-border)]" + className="border rounded-lg px-3 py-2 text-sm w-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/30" + style={inputStyle} > {CURRENCY_OPTIONS.map((c) => ( @@ -371,26 +247,22 @@ export default function ExchangeRateViewer() {
- {/* Row 2: Toggles and date presets */} -
-