From 45ea2956e4af9aa90f2f78dcad463356c0dca713 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 3 Apr 2026 23:02:53 +0700 Subject: [PATCH] feat: improve UI/UX with dark mode, better visual hierarchy, and accessibility - Add card layout, loading spinner, error/empty states for better UX - Full dark mode support for date picker, tables, and inputs via CSS vars - Extract reusable RateTable component, remove duplicate table markup - Fix CSV injection vulnerability with escapeCsv sanitizer - Fix URL.createObjectURL memory leak - Replace JS hover handlers with CSS hover for better performance - Add aria-live region for screen reader announcements - Fix metadata from default "Create Next App" to proper title - Differentiate table stripe/hover colors in light mode --- .gitignore | 4 + src/app/components/ExchangeRateViewer.js | 390 ++++++++++++----------- src/app/globals.css | 77 ++++- src/app/layout.js | 4 +- 4 files changed, 291 insertions(+), 184 deletions(-) diff --git a/.gitignore b/.gitignore index 5ef6a52..c52f499 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +.claude/session-state + + + # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies diff --git a/src/app/components/ExchangeRateViewer.js b/src/app/components/ExchangeRateViewer.js index 36b160d..063b183 100644 --- a/src/app/components/ExchangeRateViewer.js +++ b/src/app/components/ExchangeRateViewer.js @@ -5,10 +5,98 @@ import DatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; import { parse, isDate, format } from "date-fns"; -export default function ExchangeRateViewer({ - defaultStartDate, - defaultEndDate, -}) { +function LoadingSpinner() { + return ( +
+
+
+

Fetching exchange rates...

+
+
+ ); +} + +function ErrorMessage({ message }) { + return ( +
+ ⚠ + {message} +
+ ); +} + +function EmptyState() { + return ( +
+

No results yet

+

Select a bank and date range, then click Fetch Rates.

+
+ ); +} + +function RateTable({ results, selectedBank }) { + const bidvHeaders = ["Date", "Name (VI)", "Buy TM", "Buy CK", "Currency", "Name (EN)", "Sell"]; + const tcbHeaders = ["Date", "Label", "Ask Rate", "Bid CK", "Bid TM", "Source", "Target", "Ask TM"]; + + const headers = selectedBank === "bidv" ? bidvHeaders : tcbHeaders; + + const getRowCells = (row) => { + if (selectedBank === "bidv") { + return [row.date, row.nameVI, row.muaTm, row.muaCk, row.currency, row.nameEN, row.ban]; + } + return [row.date, row.label, row.askRate, row.bidRateCK, row.bidRateTM, row.sourceCurrency, row.targetCurrency, row.askRateTM]; + }; + + return ( +
+ + + + {headers.map((h) => ( + + ))} + + + + {results.map((row, idx) => ( + + {getRowCells(row).map((cell, i) => ( + + ))} + + ))} + +
+ {h} +
+ {cell} +
+
+ ); +} + +export default function ExchangeRateViewer({ defaultStartDate, defaultEndDate }) { const [startDate, setStartDate] = useState( defaultStartDate ? isDate(defaultStartDate) @@ -39,21 +127,17 @@ export default function ExchangeRateViewer({ setResults([]); setShowTable(false); try { - const start = startDate; - const end = endDate; - if (start > end) { + if (startDate > endDate) { setError("Start date cannot be after end date"); setIsLoading(false); return; } const response = await fetch("/api/exchange-rates", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - startDate: format(start, 'yyyy-MM-dd'), - endDate: format(end, 'yyyy-MM-dd'), + startDate: format(startDate, "yyyy-MM-dd"), + endDate: format(endDate, "yyyy-MM-dd"), bank: selectedBank, }), }); @@ -65,210 +149,154 @@ export default function ExchangeRateViewer({ setResults(data.data || []); setShowTable(true); } catch (err) { - setError( - `An error occurred while fetching exchange rates: ${err.message}` - ); + setError(`An error occurred while fetching exchange rates: ${err.message}`); console.error(err); } finally { setIsLoading(false); } }; + const escapeCsv = (value) => { + const str = String(value ?? ""); + const escaped = str.replace(/"/g, '""'); + if (/^[=+\-@\t\r]/.test(escaped)) return `"'${escaped}"`; + return `"${escaped}"`; + }; + const handleExport = () => { if (results.length === 0) return; let csvContent = ""; let headers = []; if (selectedBank === "bidv") { - headers = [ - "Date", - "NameVI", - "MuaTm", - "MuaCk", - "Currency", - "NameEN", - "Ban", - ]; + headers = ["Date", "NameVI", "MuaTm", "MuaCk", "Currency", "NameEN", "Ban"]; csvContent = headers.join(",") + "\n"; csvContent += results - .map( - (row) => - `"${row.date}","${row.nameVI}","${row.muaTm}","${row.muaCk}","${row.currency}","${row.nameEN}","${row.ban}"` - ) + .map((row) => [row.date, row.nameVI, row.muaTm, row.muaCk, row.currency, row.nameEN, row.ban].map(escapeCsv).join(",")) .join("\n"); } else { - headers = [ - "Date", - "Label", - "AskRate", - "BidRateCK", - "BidRateTM", - "SourceCurrency", - "TargetCurrency", - "AskRateTM", - ]; + headers = ["Date", "Label", "AskRate", "BidRateCK", "BidRateTM", "SourceCurrency", "TargetCurrency", "AskRateTM"]; csvContent = headers.join(",") + "\n"; csvContent += results - .map( - (row) => - `"${row.date}","${row.label}","${row.askRate}","${row.bidRateCK}","${row.bidRateTM}","${row.sourceCurrency}","${row.targetCurrency}","${row.askRateTM}"` - ) + .map((row) => [row.date, row.label, row.askRate, row.bidRateCK, row.bidRateTM, row.sourceCurrency, row.targetCurrency, row.askRateTM].map(escapeCsv).join(",")) .join("\n"); } const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = `exchange_rates_${selectedBank}_${ - new Date().toISOString().split("T")[0] - }.csv`; + link.download = `exchange_rates_${selectedBank}_${new Date().toISOString().split("T")[0]}.csv`; document.body.appendChild(link); link.click(); document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const inputStyle = { + background: "var(--input-bg)", + color: "var(--foreground)", + borderColor: "var(--input-border)", }; return ( -
-

Exchange Rate Export

-
- {/* Row 1: Start & End Date Pickers and Bank Select */} -
-
- - setSelectedBank(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" + style={inputStyle} + > + + + +
+
+ + setStartDate(date)} + maxDate={endDate || new Date()} + dateFormat="yyyy-MM-dd" + 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" + placeholderText="Select start date" + /> +
+
+ + setEndDate(date)} + minDate={startDate} + maxDate={new Date()} + dateFormat="yyyy-MM-dd" + 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" + placeholderText="Select end date" + /> +
+
+ + {/* Buttons row */} +
+ +
-
- - setStartDate(date)} - maxDate={endDate || new Date()} - dateFormat="yyyy-MM-dd" - className="border rounded p-2 w-full" - placeholderText="Select start date" - /> -
-
- - setEndDate(date)} - minDate={startDate} - maxDate={new Date()} - dateFormat="yyyy-MM-dd" - className="border rounded p-2 w-full" - placeholderText="Select end date" - /> -
-
- {/* Row 2: Fetch & Export Buttons aligned with End Date */} -
- -
- {error &&
{error}
} - {isLoading && ( -
Loading data, please wait...
- )} - {showTable && ( -
- {selectedBank === "bidv" ? ( - - - - - - - - - - - - - - {results.map((result, idx) => ( - - - - - - - - - - ))} - -
DateNameVIMuaTmMuaCkCurrencyNameENBan
{result.date}{result.nameVI}{result.muaTm}{result.muaCk}{result.currency}{result.nameEN}{result.ban}
- ) : ( - - - - - - - - - - - - - - - {results.map((result, idx) => ( - - - - - - - - - - - ))} - -
DateLabelAskRateBidRateCKBidRateTMSourceCurrencyTargetCurrencyAskRateTM
{result.date}{result.label}{result.askRate}{result.bidRateCK}{result.bidRateTM} - {result.sourceCurrency} - - {result.targetCurrency} - {result.askRateTM}
- )} + + {/* Status area - announced to screen readers */} +
+ {error && } + {isLoading && } + {!isLoading && showTable && results.length === 0 && } + {!isLoading && showTable && results.length > 0 && ( +
+
+

+ {results.length} {results.length === 1 ? "record" : "records"} found +

+
+
)} +
); } diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..f93f598 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,8 +1,17 @@ @import "tailwindcss"; :root { - --background: #ffffff; + --background: #f8fafc; --foreground: #171717; + --card-bg: #ffffff; + --card-border: #e2e8f0; + --input-bg: #ffffff; + --input-border: #cbd5e1; + --input-focus: #3b82f6; + --table-header-bg: #f1f5f9; + --table-row-hover: #e2e8f0; + --table-row-stripe: #f1f5f9; + --muted: #64748b; } @theme inline { @@ -16,6 +25,15 @@ :root { --background: #0a0a0a; --foreground: #ededed; + --card-bg: #18181b; + --card-border: #27272a; + --input-bg: #1e1e22; + --input-border: #3f3f46; + --input-focus: #60a5fa; + --table-header-bg: #1e1e22; + --table-row-hover: #27272a; + --table-row-stripe: #141416; + --muted: #a1a1aa; } } @@ -24,3 +42,60 @@ body { color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } + +/* Date picker dark mode support */ +.react-datepicker-wrapper { + width: 100%; +} + +.react-datepicker__input-container input { + background: var(--input-bg) !important; + color: var(--foreground) !important; + border-color: var(--input-border) !important; +} + +.react-datepicker { + background-color: var(--card-bg) !important; + border-color: var(--card-border) !important; + color: var(--foreground) !important; + font-family: inherit !important; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12) !important; +} + +.react-datepicker__header { + background-color: var(--table-header-bg) !important; + border-bottom-color: var(--card-border) !important; +} + +.react-datepicker__current-month, +.react-datepicker__day-name { + color: var(--foreground) !important; +} + +.react-datepicker__day { + color: var(--foreground) !important; + border-radius: 6px !important; +} + +.react-datepicker__day:hover { + background-color: var(--table-row-hover) !important; +} + +.react-datepicker__day--selected, +.react-datepicker__day--keyboard-selected { + background-color: #3b82f6 !important; + color: #fff !important; +} + +.react-datepicker__day--disabled { + color: var(--muted) !important; + opacity: 0.4; +} + +.react-datepicker__navigation-icon::before { + border-color: var(--muted) !important; +} + +.react-datepicker__triangle { + display: none !important; +} diff --git a/src/app/layout.js b/src/app/layout.js index bb0c64c..a4f308c 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -14,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Exchange Rate Export", + description: "Fetch exchange rates from BIDV or Techcombank and export to CSV", }; export default function RootLayout({ children }) {