refactor: clean up source files and split viewer into modules

Source-level cleanup with no behavior changes:
- Drop unused React default imports (React 19 auto JSX runtime)
- Hoist CHART_COLORS and add MAX_DOTS_DAYS / MAX_LEGEND_LINES named constants
- Use date-fns addDays/subDays in route helpers (no more setDate mutation)
- Drop dead current:0 field from progress state
- Use format() for export filename (local tz, not UTC)
- Replace key={idx} with composite domain keys in table rows
- Fix border-3 (non-existent in Tailwind v4) -> border-[3px] in spinner
- Fix html lang from "vi" to "en" to match UI language
- Sync description text to "Excel or CSV" across metadata and header

Restructure:
- Rename ExchangeRateViewer.js -> exchange-rate-viewer.js (kebab-case)
- Extract icons, storage helpers, and constants into sibling modules
This commit is contained in:
tiennm99 committed 2026-05-23 17:23:13 +07:00
1 parent 6211658e7f
commit 2417946d9e
10 files changed
+159 -158

No files matched your search

+3 -3
View File
@@ -1,4 +1,4 @@
import { format } from "date-fns";
import { format, addDays, subDays } from "date-fns";
import axios from "axios";
import https from "https";
import crypto from "crypto";
@@ -105,7 +105,7 @@ function getDateRange(start, end) {
const last = new Date(end);
while (current <= last) {
dates.push(new Date(current));
current.setDate(current.getDate() + 1);
current = addDays(current, 1);
}
return dates;
}
@@ -114,7 +114,7 @@ function getDateRange(start, end) {
async function getPreviousDayRate(date, bank, currency, rateCache, maxAttempts = 30) {
let currentDate = new Date(date);
for (let attempt = 0; attempt < maxAttempts; attempt++) {
currentDate.setDate(currentDate.getDate() - 1);
currentDate = subDays(currentDate, 1);
const dateStr = format(currentDate, "yyyy-MM-dd");
if (rateCache.has(dateStr)) {
+7 -6
View File
@@ -1,8 +1,11 @@
"use client";
import React from "react";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
const CHART_COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"];
const MAX_DOTS_DAYS = 31;
const MAX_LEGEND_LINES = 8;
// Parse rate string to number (handles comma-separated values like "25,880")
function parseRate(val) {
if (!val) return null;
@@ -54,8 +57,6 @@ export function RateTrendChart({ results, selectedBank, compareMode }) {
}
}
const colors = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"];
return (
<div className="rounded-lg p-4 mb-4 bg-[var(--card-bg)] border border-[var(--card-border)]">
<ResponsiveContainer width="100%" height={300}>
@@ -80,15 +81,15 @@ export function RateTrendChart({ results, selectedBank, compareMode }) {
fontSize: 12,
}}
/>
{lineKeys.size <= 8 && <Legend wrapperStyle={{ fontSize: 12 }} />}
{lineKeys.size <= MAX_LEGEND_LINES && <Legend wrapperStyle={{ fontSize: 12 }} />}
{Array.from(lineKeys).map((key, i) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={colors[i % colors.length]}
stroke={CHART_COLORS[i % CHART_COLORS.length]}
strokeWidth={2}
dot={chartData.length <= 31}
dot={chartData.length <= MAX_DOTS_DAYS}
connectNulls
/>
))}
@@ -0,0 +1,26 @@
import { subDays, startOfMonth, isSameDay } from "date-fns";
export const CURRENCY_OPTIONS = [
"ALL", "USD", "EUR", "GBP", "JPY", "AUD",
"CAD", "CHF", "SGD", "THB", "HKD", "CNY", "KRW",
];
export const DATE_PRESETS = [
{ label: "Today", start: () => new Date(), end: () => new Date() },
{ label: "Last 7 days", start: () => subDays(new Date(), 7), end: () => new Date() },
{ label: "Last 30 days", start: () => subDays(new Date(), 30), end: () => new Date() },
{ label: "This month", start: () => startOfMonth(new Date()), end: () => new Date() },
];
export 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;
}
export const THEME_CYCLE = ["system", "light", "dark"];
export const THEME_LABELS = { system: "System", light: "Light", dark: "Dark" };
+47
View File
@@ -0,0 +1,47 @@
export function SunIcon() {
return (
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
);
}
export function MoonIcon() {
return (
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
);
}
export function SystemIcon() {
return (
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25A2.25 2.25 0 0 1 5.25 3h13.5A2.25 2.25 0 0 1 21 5.25Z" />
</svg>
);
}
export function FetchIcon() {
return (
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
</svg>
);
}
export function ExcelIcon() {
return (
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M10.875 12c-.621 0-1.125.504-1.125 1.125M12 12c.621 0 1.125.504 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 0v1.5c0 .621-.504 1.125-1.125 1.125m0 0c-.621 0-1.125.504-1.125 1.125v1.5" />
</svg>
);
}
export function CsvIcon() {
return (
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
);
}
+1 -3
View File
@@ -1,12 +1,10 @@
"use client";
import React from "react";
export function LoadingSpinner({ progress }) {
return (
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center gap-3 w-64">
<div className="w-8 h-8 border-3 border-blue-200 border-t-blue-600 rounded-full animate-spin" />
<div className="w-8 h-8 border-[3px] border-blue-200 border-t-blue-600 rounded-full animate-spin" />
<p className="text-[var(--muted)] text-sm">
{progress?.total ? `Fetching rates for ${progress.total} day${progress.total > 1 ? "s" : ""}...` : "Fetching exchange rates..."}
</p>
@@ -0,0 +1,52 @@
export 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,
};
}
export function loadSavedSettings() {
if (typeof window === "undefined") return null;
try {
const saved = localStorage.getItem("exchangeRateSettings");
if (!saved) return null;
const parsed = JSON.parse(saved);
return {
bank: parsed.bank || "bidv",
currency: parsed.currency || "USD",
startDate: parsed.startDate ? new Date(parsed.startDate) : null,
endDate: parsed.endDate ? new Date(parsed.endDate) : null,
};
} catch {
return null;
}
}
export function saveSettings(bank, currency, startDate, endDate) {
try {
localStorage.setItem("exchangeRateSettings", JSON.stringify({
bank,
currency,
startDate: startDate?.toISOString(),
endDate: endDate?.toISOString(),
}));
} catch { /* ignore quota errors */ }
}
export function applyTheme(value) {
const root = document.documentElement;
if (value === "system") {
root.removeAttribute("data-theme");
} else {
root.setAttribute("data-theme", value);
}
}
+12 -8
View File
@@ -1,7 +1,5 @@
"use client";
import React from "react";
export function ComparisonTable({ results }) {
const headers = [
{ label: "Date", numeric: false },
@@ -41,7 +39,7 @@ export function ComparisonTable({ results }) {
];
return (
<tr
key={idx}
key={`${row.date}-${row.currency}`}
className={`transition-colors hover:!bg-[var(--table-row-hover)] ${idx % 2 !== 0 ? "bg-[var(--table-row-stripe)]" : ""}`}
>
{cells.map((cell, i) => (
@@ -60,8 +58,8 @@ export function ComparisonTable({ results }) {
</div>
{/* Mobile cards */}
<div className="sm:hidden flex flex-col gap-2">
{results.map((row, idx) => (
<div key={idx} className="rounded-lg border border-[var(--card-border)] p-3 bg-[var(--card-bg)] text-sm">
{results.map((row) => (
<div key={`${row.date}-${row.currency}`} className="rounded-lg border border-[var(--card-border)] p-3 bg-[var(--card-bg)] text-sm">
<div className="flex justify-between mb-1.5">
<span className="font-medium">{row.date}</span>
<span className="text-[var(--muted)]">{row.currency}</span>
@@ -158,9 +156,12 @@ export function RateTable({ results, selectedBank }) {
<tbody>
{results.map((row, idx) => {
const cells = getRowCells(row);
const rowKey = selectedBank === "bidv"
? `${row.date}-${row.currency}-${row.nameVI}`
: `${row.date}-${row.sourceCurrency}-${row.label}`;
return (
<tr
key={idx}
key={rowKey}
className={`transition-colors hover:!bg-[var(--table-row-hover)] ${idx % 2 !== 0 ? "bg-[var(--table-row-stripe)]" : ""}`}
>
{cells.map((cell, i) => (
@@ -179,10 +180,13 @@ export function RateTable({ results, selectedBank }) {
</div>
{/* Mobile cards */}
<div className="sm:hidden flex flex-col gap-2">
{results.map((row, idx) => {
{results.map((row) => {
const card = getMobileCard(row);
const rowKey = selectedBank === "bidv"
? `${row.date}-${row.currency}-${row.nameVI}`
: `${row.date}-${row.sourceCurrency}-${row.label}`;
return (
<div key={idx} className="rounded-lg border border-[var(--card-border)] p-3 bg-[var(--card-bg)] text-sm">
<div key={rowKey} className="rounded-lg border border-[var(--card-border)] p-3 bg-[var(--card-bg)] text-sm">
<div className="flex justify-between mb-1.5">
<span className="font-medium">{card.title}</span>
<span className="text-[var(--muted)] text-xs">{card.subtitle}</span>
@@ -1,141 +1,16 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { 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, differenceInCalendarDays } 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 {
const saved = localStorage.getItem("exchangeRateSettings");
if (!saved) return null;
const parsed = JSON.parse(saved);
return {
bank: parsed.bank || "bidv",
currency: parsed.currency || "USD",
startDate: parsed.startDate ? new Date(parsed.startDate) : null,
endDate: parsed.endDate ? new Date(parsed.endDate) : null,
};
} catch {
return null;
}
}
function saveSettings(bank, currency, startDate, endDate) {
try {
localStorage.setItem("exchangeRateSettings", JSON.stringify({
bank,
currency,
startDate: startDate?.toISOString(),
endDate: endDate?.toISOString(),
}));
} catch { /* ignore quota errors */ }
}
const CURRENCY_OPTIONS = [
"ALL", "USD", "EUR", "GBP", "JPY", "AUD",
"CAD", "CHF", "SGD", "THB", "HKD", "CNY", "KRW",
];
const DATE_PRESETS = [
{ label: "Today", start: () => new Date(), end: () => new Date() },
{ label: "Last 7 days", start: () => subDays(new Date(), 7), end: () => new Date() },
{ label: "Last 30 days", start: () => subDays(new Date(), 30), end: () => new Date() },
{ 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 (
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
);
}
function MoonIcon() {
return (
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
);
}
function SystemIcon() {
return (
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25A2.25 2.25 0 0 1 5.25 3h13.5A2.25 2.25 0 0 1 21 5.25Z" />
</svg>
);
}
// Inline SVG icons (16x16) to avoid adding an icon library dependency
function FetchIcon() {
return (
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
</svg>
);
}
function ExcelIcon() {
return (
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M10.875 12c-.621 0-1.125.504-1.125 1.125M12 12c.621 0 1.125.504 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 0v1.5c0 .621-.504 1.125-1.125 1.125m0 0c-.621 0-1.125.504-1.125 1.125v1.5" />
</svg>
);
}
function CsvIcon() {
return (
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
);
}
import { SunIcon, MoonIcon, SystemIcon, FetchIcon, ExcelIcon, CsvIcon } from "./exchange-rate-icons";
import { loadFromUrl, loadSavedSettings, saveSettings, applyTheme } from "./exchange-rate-storage";
import { CURRENCY_OPTIONS, DATE_PRESETS, getActivePreset, THEME_CYCLE, THEME_LABELS } from "./exchange-rate-constants";
export default function ExchangeRateViewer() {
const [startDate, setStartDate] = useState(() => subDays(new Date(), 7));
@@ -151,7 +26,6 @@ export default function ExchangeRateViewer() {
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();
@@ -168,7 +42,6 @@ export default function ExchangeRateViewer() {
setHydrated(true);
}, []);
// Sync state to both localStorage and URL
useEffect(() => {
if (!hydrated) return;
saveSettings(selectedBank, selectedCurrency, startDate, endDate);
@@ -212,7 +85,7 @@ export default function ExchangeRateViewer() {
setResults([]);
setHasFetched(false);
const totalDays = differenceInCalendarDays(endDate, startDate) + 1;
setProgress({ current: 0, total: totalDays });
setProgress({ total: totalDays });
try {
if (compareMode) {
const [bidvData, tcbData] = await Promise.all([fetchBank("bidv"), fetchBank("tcb")]);
@@ -260,7 +133,7 @@ export default function ExchangeRateViewer() {
return [headers, ...results.map((r) => [r.date, r.label, r.askRate, r.bidRateCK, r.bidRateTM, r.sourceCurrency, r.targetCurrency, r.askRateTM])];
};
const baseFilename = `exchange_rates_${compareMode ? "compare" : selectedBank}_${selectedCurrency}_${new Date().toISOString().split("T")[0]}`;
const baseFilename = `exchange_rates_${compareMode ? "compare" : selectedBank}_${selectedCurrency}_${format(new Date(), "yyyy-MM-dd")}`;
const handleExportExcel = () => {
if (results.length === 0) return;
@@ -293,7 +166,7 @@ export default function ExchangeRateViewer() {
<div>
<h1 className="text-2xl font-bold tracking-tight">Exchange Rate Export</h1>
<p className="text-sm mt-1 text-[var(--muted)]">
Fetch exchange rates from Vietnamese banks and export to CSV.
Fetch exchange rates from Vietnamese banks and export to Excel or CSV.
</p>
</div>
<button
+2 -2
View File
@@ -10,12 +10,12 @@ const geistSans = Geist({
export const metadata = {
title: "Exchange Rate Export",
description: "Fetch exchange rates from BIDV or Techcombank and export to CSV",
description: "Fetch exchange rates from Vietnamese banks and export to Excel or CSV.",
};
export default function RootLayout({ children }) {
return (
<html lang="vi">
<html lang="en">
<body
className={`${geistSans.variable} antialiased`}
>
+1 -1
View File
@@ -1,4 +1,4 @@
import ExchangeRateViewer from "./components/ExchangeRateViewer";
import ExchangeRateViewer from "./components/exchange-rate-viewer";
export default function Home() {
return (