Merge branch 'feature/react'

History-preserving absorb: the branch's content lands in this merge
commit and is restored to the previous main tree in the next commit.
The branch ref is deleted after this merge; its history stays reachable
through the second parent.
This commit is contained in:
tiennm99 committed 2026-08-07 13:44:56 +07:00
commit d14d951cbe
43 files changed
+5793 -5995

No files matched your search

+22 -35
View File
@@ -1,41 +1,28 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
pnpm-debug.log*
lerna-debug.log*
# env files (can opt-in for committing if needed)
.env*
node_modules
dist
dist-ssr
*.local
# vercel
.vercel
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# typescript
*.tsbuildinfo
next-env.d.ts
# wrangler files
.wrangler
.dev.vars*
-1
View File
@@ -1,4 +1,3 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
+7 -42
View File
@@ -1,47 +1,12 @@
# exchange-rate-export
Fetch data from BIDV or Techcombank and export to Excel or CSV.
# React + Vite
Features:
- Choose BIDV or Techcombank.
- Select time range.
- View and export to Excel or CSV.
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Use data from :
- https://bidv.com.vn/vn/ty-gia-ngoai-te
- https://techcombank.com/cong-cu-tien-ich/ty-gia
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
Requires Node.js 24+ and pnpm 11+.
First, run the development server:
```bash
pnpm install
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `src/app/page.js`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+266
View File
@@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exchange Rate Export</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 40px;
background: #f7f7f7;
}
.container {
background: #fff;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
max-width: 700px;
margin: auto;
}
h1 {
text-align: center;
}
label {
margin-right: 8px;
}
input[type="date"] {
margin-right: 16px;
padding: 4px 8px;
}
button {
padding: 8px 16px;
margin-right: 8px;
border: none;
border-radius: 4px;
background: #007bff;
color: #fff;
cursor: pointer;
font-size: 1rem;
}
button:disabled {
background: #aaa;
cursor: not-allowed;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 24px;
}
th, td {
border: 1px solid #ddd;
padding: 8px;
text-align: center;
}
th {
background: #007bff;
color: #fff;
}
.loading {
text-align: center;
margin-top: 20px;
color: #007bff;
}
.error {
color: red;
text-align: center;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<h1>Exchange Rate Export (USD)</h1>
<div style="margin-bottom: 20px;">
<label for="start-date">Start Date:</label>
<input type="date" id="start-date">
<label for="end-date">End Date:</label>
<input type="date" id="end-date">
<button id="fetch-btn">Fetch</button>
<button id="export-btn" disabled>Export CSV</button>
</div>
<div id="status"></div>
<table id="result-table" style="display:none;">
<thead>
<tr>
<th>Date</th>
<th>nameVI</th>
<th>muaTm</th>
<th>muaCk</th>
<th>currency</th>
<th>nameEN</th>
<th>ban</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<script>
const fetchBtn = document.getElementById('fetch-btn');
const exportBtn = document.getElementById('export-btn');
const startDateInput = document.getElementById('start-date');
const endDateInput = document.getElementById('end-date');
const statusDiv = document.getElementById('status');
const table = document.getElementById('result-table');
const tbody = table.querySelector('tbody');
let results = [];
function formatDate(date) {
// Format as dd/MM/yyyy
const d = new Date(date);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}/${month}/${year}`;
}
function getDateRange(start, end) {
const dates = [];
let current = new Date(start);
const last = new Date(end);
while (current <= last) {
dates.push(new Date(current));
current.setDate(current.getDate() + 1);
}
return dates;
}
async function fetchNamerecord(dateStr) {
const url = 'https://bidv.com.vn/ServicesBIDV/ExchangeDetailSearchTimeServlet';
const payload = `date=${encodeURIComponent(dateStr)}`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: payload
});
if (!res.ok) throw new Error('API 1 error');
const data = await res.json();
if (data.status !== 1 || !data.data || !data.data.length) return null;
// Get the latest (max time) record
let latest = data.data[0];
for (const rec of data.data) {
if (rec.time > latest.time) latest = rec;
}
return latest.namerecord;
} catch (e) {
return null;
}
}
async function fetchUSD(dateStr, namerecord) {
const url = 'https://bidv.com.vn/ServicesBIDV/ExchangeDetailServlet';
const payload = `date=${encodeURIComponent(dateStr)}&time=${namerecord}`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: payload
});
if (!res.ok) throw new Error('API 2 error');
const data = await res.json();
if (data.status !== 1 || !data.data) return null;
// Find USD (currency === 'USD')
const usd = data.data.find(item => item.currency === 'USD');
if (!usd) return null;
return {
nameVI: usd.nameVI,
muaTm: usd.muaTm,
muaCk: usd.muaCk,
currency: usd.currency,
nameEN: usd.nameEN,
ban: usd.ban
};
} catch (e) {
return null;
}
}
function renderTable(data) {
tbody.innerHTML = '';
for (const row of data) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${row.date}</td>
<td>${row.nameVI}</td>
<td>${row.muaTm}</td>
<td>${row.muaCk}</td>
<td>${row.currency}</td>
<td>${row.nameEN}</td>
<td>${row.ban}</td>
`;
tbody.appendChild(tr);
}
table.style.display = data.length ? '' : 'none';
}
function exportCSV(data) {
const header = ['Date', 'nameVI', 'muaTm', 'muaCk', 'currency', 'nameEN', 'ban'];
const rows = [header];
for (const row of data) {
rows.push([
row.date,
row.nameVI,
row.muaTm,
row.muaCk,
row.currency,
row.nameEN,
row.ban
]);
}
const csvContent = rows.map(r => r.map(cell => '"' + String(cell).replace(/"/g, '""') + '"').join(',')).join('\r\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'usd_exchange_rates.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
fetchBtn.onclick = async () => {
const start = startDateInput.value;
const end = endDateInput.value;
if (!start || !end) {
statusDiv.innerHTML = '<div class="error">Please select both start and end dates.</div>';
return;
}
if (start > end) {
statusDiv.innerHTML = '<div class="error">Start date must be before or equal to end date.</div>';
return;
}
statusDiv.innerHTML = '<div class="loading">Fetching data, please wait...</div>';
fetchBtn.disabled = true;
exportBtn.disabled = true;
results = [];
renderTable([]);
const dates = getDateRange(start, end);
for (let i = 0; i < dates.length; i++) {
const dateObj = dates[i];
const dateStr = formatDate(dateObj);
statusDiv.innerHTML = `<div class=\"loading\">Fetching: ${dateStr} (${i+1}/${dates.length})</div>`;
const namerecord = await fetchNamerecord(dateStr);
if (!namerecord) continue;
const usd = await fetchUSD(dateStr, namerecord);
if (usd) {
results.push({ date: dateStr, ...usd });
renderTable(results);
}
}
statusDiv.innerHTML = results.length ? '' : '<div class="error">No data found for the selected range.</div>';
fetchBtn.disabled = false;
exportBtn.disabled = results.length === 0;
};
exportBtn.onclick = () => {
if (results.length) exportCSV(results);
};
</script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
-14
View File
@@ -1,14 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
const eslintConfig = defineConfig([
...nextVitals,
{
rules: {
"react-hooks/set-state-in-effect": "off",
},
},
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
]);
export default eslintConfig;
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
-7
View File
@@ -1,7 +0,0 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
-4
View File
@@ -1,4 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
+4877
View File
File diff suppressed because it is too large. Load diff
+29 -32
View File
@@ -1,33 +1,30 @@
{
"name": "exchange-rate-export",
"version": "0.1.0",
"packageManager": "pnpm@11.1.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint src"
},
"dependencies": {
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
"axios": "^1.18.1",
"date-fns": "^4.4.0",
"next": "16.2.11",
"react": "^19.2.7",
"react-datepicker": "^9.1.0",
"react-dom": "^19.2.7",
"recharts": "^3.9.2",
"write-excel-file": "^4.1.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"eslint": "^9.39.5",
"eslint-config-next": "16.2.11",
"tailwindcss": "^4"
},
"engines": {
"node": ">=24"
}
}
"name": "exchange-rate-export",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "npm run build && vite preview",
"deploy": "npm run build && wrangler deploy"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.7.5",
"@eslint/js": "^9.29.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0",
"vite": "^7.0.0",
"wrangler": "^4.22.0"
}
}
-4679
View File
File diff suppressed because it is too large. Load diff
-16
View File
@@ -1,16 +0,0 @@
overrides:
'@babel/core': 7.29.6
form-data: 4.0.6
js-yaml: 4.3.0
postcss: 8.5.18
# next 16.2.11 declares sharp ^0.34.5, which excludes the patched 0.35.0, so
# no upstream bump can reach the fix -- force it here.
sharp: 0.35.0
# Two independent major branches reach this package: minimatch@3 pulls 1.x,
# minimatch@10 pulls 5.x. Each branch needs its own patched version.
'brace-expansion@1': 1.1.16
'brace-expansion@5': 5.0.7
allowBuilds:
sharp: true
unrs-resolver: true
-5
View File
@@ -1,5 +0,0 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

+45
View File
@@ -0,0 +1,45 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
.logo.cloudflare:hover {
filter: drop-shadow(0 0 2em #f6821faa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+58
View File
@@ -0,0 +1,58 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import cloudflareLogo from './assets/Cloudflare_Logo.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
const [name, setName] = useState('unknown')
return (
<>
<div>
<a href='https://vite.dev' target='_blank'>
<img src={viteLogo} className='logo' alt='Vite logo' />
</a>
<a href='https://react.dev' target='_blank'>
<img src={reactLogo} className='logo react' alt='React logo' />
</a>
<a href='https://workers.cloudflare.com/' target='_blank'>
<img src={cloudflareLogo} className='logo cloudflare' alt='Cloudflare logo' />
</a>
</div>
<h1>Vite + React + Cloudflare</h1>
<div className='card'>
<button
onClick={() => setCount((count) => count + 1)}
aria-label='increment'
>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<div className='card'>
<button
onClick={() => {
fetch('/api/')
.then((res) => res.json())
.then((data) => setName(data.name))
}}
aria-label='get name'
>
Name from API is: {name}
</button>
<p>
Edit <code>worker/index.js</code> to change the name
</p>
</div>
<p className='read-the-docs'>
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App
-191
View File
@@ -1,191 +0,0 @@
import { format, addDays, subDays } from "date-fns";
import axios from "axios";
import https from "https";
import crypto from "crypto";
const axiosInstance = axios.create({
httpsAgent: new https.Agent({
secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
}),
});
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 timeData = timeRes.data;
if (timeData.status !== 1 || !timeData.data?.length) return null;
const latest = timeData.data.reduce(
(best, current) => (current.time > best.time ? current : best),
timeData.data[0]
);
const rateUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailServlet?date=${dateStr}&time=${latest.namerecord}`;
const rateRes = await withRetry(() => axiosInstance.get(rateUrl));
const rateData = rateRes.data;
if (rateData.status !== 1 || !rateData.data) return null;
const displayDate = format(dateObj, DISPLAY_DATE_FORMAT);
const items = currency === "ALL"
? rateData.data
: rateData.data.filter((item) => item.currency === currency);
if (items.length === 0) return null;
return items.map((item) => ({
date: displayDate,
nameVI: item.nameVI || "",
muaTm: item.muaTm || "",
muaCk: item.muaCk || "",
currency: item.currency || "",
nameEN: item.nameEN || "",
ban: item.ban || "",
}));
} catch (error) {
console.error("Error fetching BIDV rates:", error.message);
return null;
}
}
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 data = res.data;
if (!data.exchangeRate?.data) return null;
const items = currency === "ALL"
? data.exchangeRate.data
: data.exchangeRate.data.filter((item) => item.sourceCurrency === currency);
if (items.length === 0) return null;
return items.map((item) => ({
date: dateStr,
label: item.label || "",
askRate: item.askRate || "",
bidRateCK: item.bidRateCK || "",
bidRateTM: item.bidRateTM || "",
sourceCurrency: item.sourceCurrency || "",
targetCurrency: item.targetCurrency || "",
askRateTM: item.askRateTM || "",
}));
} catch (error) {
console.error("Error fetching TCB rates:", error.message);
return null;
}
}
function fetchRateForDate(dateObj, bank, currency) {
return bank === "bidv" ? fetchBIDVRates(dateObj, currency) : fetchTCBRates(dateObj, currency);
}
function getDateRange(start, end) {
const dates = [];
let current = new Date(start);
const last = new Date(end);
while (current <= last) {
dates.push(new Date(current));
current = addDays(current, 1);
}
return dates;
}
// Search backwards for the most recent available rate
async function getPreviousDayRate(date, bank, currency, rateCache, maxAttempts = 30) {
let currentDate = new Date(date);
for (let attempt = 0; attempt < maxAttempts; attempt++) {
currentDate = subDays(currentDate, 1);
const dateStr = format(currentDate, "yyyy-MM-dd");
if (rateCache.has(dateStr)) {
return rateCache.get(dateStr);
}
const rates = await fetchRateForDate(currentDate, bank, currency);
if (rates) {
rateCache.set(dateStr, rates);
return rates;
}
}
return null;
}
// Fetch a chunk of dates in parallel, filling gaps with previous day data
async function fetchChunk(dates, bank, currency, rateCache) {
const settled = await Promise.allSettled(
dates.map((date) => fetchRateForDate(date, bank, currency))
);
const results = [];
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
const dateStr = format(date, "yyyy-MM-dd");
const outcome = settled[i];
let rates = outcome.status === "fulfilled" ? outcome.value : null;
if (rates) {
rateCache.set(dateStr, rates);
results.push(...rates);
} else {
// Weekend/holiday/error — fill with most recent available rate
const prevRates = await getPreviousDayRate(date, bank, currency, rateCache);
if (prevRates) {
const displayDate = format(date, DISPLAY_DATE_FORMAT);
results.push(...prevRates.map((r) => ({ ...r, date: displayDate })));
}
}
}
return results;
}
export async function POST(request) {
try {
const body = await request.json();
const { startDate, endDate, bank, currency = "USD" } = body;
if (!startDate || !endDate || !bank) {
return Response.json(
{ error: "Missing required parameters" },
{ status: 400 }
);
}
const dates = getDateRange(startDate, endDate);
const rateCache = new Map();
const results = [];
// Process dates in parallel chunks to avoid overwhelming upstream APIs
for (let i = 0; i < dates.length; i += PARALLEL_CHUNK_SIZE) {
const chunk = dates.slice(i, i + PARALLEL_CHUNK_SIZE);
const chunkResults = await fetchChunk(chunk, bank, currency, rateCache);
results.push(...chunkResults);
}
return Response.json({ data: results, total: dates.length, chunks: Math.ceil(dates.length / PARALLEL_CHUNK_SIZE) });
} catch (error) {
console.error("Server error:", error);
return Response.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
-100
View File
@@ -1,100 +0,0 @@
"use client";
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;
const num = parseFloat(String(val).replace(/,/g, ""));
return isNaN(num) ? null : num;
}
export function RateTrendChart({ results, selectedBank, compareMode }) {
const chartData = [];
if (compareMode) {
const byDate = new Map();
for (const row of results) {
if (!byDate.has(row.date)) byDate.set(row.date, {});
const entry = byDate.get(row.date);
entry.date = row.date;
if (row.bidvSell) entry["BIDV Sell"] = parseRate(row.bidvSell);
if (row.tcbAsk) entry["TCB Ask"] = parseRate(row.tcbAsk);
}
chartData.push(...byDate.values());
} else if (selectedBank === "bidv") {
const byDate = new Map();
for (const row of results) {
if (!byDate.has(row.date)) byDate.set(row.date, { date: row.date });
const entry = byDate.get(row.date);
const label = row.currency || "USD";
entry[`${label} Buy`] = parseRate(row.muaTm);
entry[`${label} Sell`] = parseRate(row.ban);
}
chartData.push(...byDate.values());
} else {
const byDate = new Map();
for (const row of results) {
if (!byDate.has(row.date)) byDate.set(row.date, { date: row.date });
const entry = byDate.get(row.date);
const label = row.sourceCurrency || "USD";
entry[`${label} Bid`] = parseRate(row.bidRateTM);
entry[`${label} Ask`] = parseRate(row.askRate);
}
chartData.push(...byDate.values());
}
if (chartData.length === 0) return null;
const lineKeys = new Set();
for (const row of chartData) {
for (const key of Object.keys(row)) {
if (key !== "date" && typeof row[key] === "number") lineKeys.add(key);
}
}
return (
<div className="rounded-lg p-4 mb-4 bg-[var(--card-bg)] border border-[var(--card-border)]">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--card-border)" />
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--muted)" }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: "var(--muted)" }}
tickLine={false}
domain={["auto", "auto"]}
label={{ value: "VND", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "var(--muted)" } }}
/>
<Tooltip
contentStyle={{
background: "var(--card-bg)",
border: "1px solid var(--card-border)",
borderRadius: 8,
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={CHART_COLORS[i % CHART_COLORS.length]}
strokeWidth={2}
dot={chartData.length <= MAX_DOTS_DAYS}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
@@ -1,26 +0,0 @@
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" };
@@ -1,20 +0,0 @@
export function rowsToCsv(rows) {
return `${rows.map((row) => row.map(formatCsvCell).join(",")).join("\r\n")}\r\n`;
}
export function downloadTextFile(text, filename, type) {
const blob = new Blob([text], { type });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function formatCsvCell(value) {
const text = value == null ? "" : String(value);
return /[",\r\n]/.test(text) ? `"${text.replaceAll("\"", "\"\"")}"` : text;
}
-47
View File
@@ -1,47 +0,0 @@
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,42 +0,0 @@
"use client";
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-[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>
{progress?.total > 1 && (
<div className="w-full h-1.5 rounded-full bg-[var(--card-border)] overflow-hidden">
<div className="h-full bg-blue-500 rounded-full animate-pulse" style={{ width: "100%" }} />
</div>
)}
</div>
</div>
);
}
export function ErrorMessage({ message }) {
return (
<div className="flex items-start gap-2 rounded-lg px-4 py-3 text-sm mb-4 bg-red-50 dark:bg-red-950 border border-red-500/20 text-red-500">
<svg aria-hidden="true" className="shrink-0 mt-0.5 w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
<span>{message}</span>
</div>
);
}
export function EmptyState() {
return (
<div className="text-center py-16 text-[var(--muted)]">
<svg aria-hidden="true" className="mx-auto mb-4 w-12 h-12 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 0 0 6 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0 1 18 16.5h-2.25m-7.5 0h7.5m-7.5 0-1 3m8.5-3 1 3m0 0 .5 1.5m-.5-1.5h-9.5m0 0-.5 1.5M9 11.25v-5.5m3 5.5V8.25" />
</svg>
<p className="text-lg font-medium mb-1">No exchange rate data</p>
<p className="text-sm">Select a bank and date range, then click Fetch Rates to get started.</p>
</div>
);
}
@@ -1,52 +0,0 @@
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);
}
}
-208
View File
@@ -1,208 +0,0 @@
"use client";
export function ComparisonTable({ results }) {
const headers = [
{ label: "Date", numeric: false },
{ label: "Currency", numeric: false },
{ label: "BIDV Buy TM", numeric: true },
{ label: "BIDV Sell", numeric: true },
{ label: "TCB Bid TM", numeric: true },
{ label: "TCB Ask", numeric: true },
];
return (
<>
{/* Desktop table */}
<div className="hidden sm:block overflow-x-auto rounded-lg border border-[var(--card-border)]">
<table className="min-w-full text-sm">
<thead className="sticky top-0 z-10">
<tr className="bg-[var(--table-header-bg)]">
{headers.map((h) => (
<th
key={h.label}
className={`px-3 py-2.5 font-semibold whitespace-nowrap border-b border-[var(--card-border)] ${h.numeric ? "text-right" : "text-left"}`}
>
{h.label}
</th>
))}
</tr>
</thead>
<tbody>
{results.map((row, idx) => {
const cells = [
{ value: row.date, numeric: false },
{ value: row.currency, numeric: false },
{ value: row.bidvBuyTm, numeric: true },
{ value: row.bidvSell, numeric: true },
{ value: row.tcbBidTm, numeric: true },
{ value: row.tcbAsk, numeric: true },
];
return (
<tr
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) => (
<td
key={i}
className={`px-3 py-2 whitespace-nowrap border-b border-[var(--card-border)] ${cell.numeric ? "text-right tabular-nums" : ""}`}
>
{cell.value || "-"}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden flex flex-col gap-2">
{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>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div className="flex justify-between"><span className="text-[var(--muted)]">BIDV Buy</span><span className="tabular-nums">{row.bidvBuyTm || "-"}</span></div>
<div className="flex justify-between"><span className="text-[var(--muted)]">TCB Bid</span><span className="tabular-nums">{row.tcbBidTm || "-"}</span></div>
<div className="flex justify-between"><span className="text-[var(--muted)]">BIDV Sell</span><span className="tabular-nums">{row.bidvSell || "-"}</span></div>
<div className="flex justify-between"><span className="text-[var(--muted)]">TCB Ask</span><span className="tabular-nums">{row.tcbAsk || "-"}</span></div>
</div>
</div>
))}
</div>
</>
);
}
export function RateTable({ results, selectedBank }) {
const bidvHeaders = [
{ label: "Date", numeric: false },
{ label: "Name (VI)", numeric: false },
{ label: "Buy TM", numeric: true },
{ label: "Buy CK", numeric: true },
{ label: "Currency", numeric: false },
{ label: "Name (EN)", numeric: false },
{ label: "Sell", numeric: true },
];
const tcbHeaders = [
{ label: "Date", numeric: false },
{ label: "Label", numeric: false },
{ label: "Ask Rate", numeric: true },
{ label: "Bid CK", numeric: true },
{ label: "Bid TM", numeric: true },
{ label: "Source", numeric: false },
{ label: "Target", numeric: false },
{ label: "Ask TM", numeric: true },
];
const headers = selectedBank === "bidv" ? bidvHeaders : tcbHeaders;
const getRowCells = (row) => {
if (selectedBank === "bidv") {
return [
{ value: row.date, numeric: false },
{ value: row.nameVI, numeric: false },
{ value: row.muaTm, numeric: true },
{ value: row.muaCk, numeric: true },
{ value: row.currency, numeric: false },
{ value: row.nameEN, numeric: false },
{ value: row.ban, numeric: true },
];
}
return [
{ value: row.date, numeric: false },
{ value: row.label, numeric: false },
{ value: row.askRate, numeric: true },
{ value: row.bidRateCK, numeric: true },
{ value: row.bidRateTM, numeric: true },
{ value: row.sourceCurrency, numeric: false },
{ value: row.targetCurrency, numeric: false },
{ value: row.askRateTM, numeric: true },
];
};
const getMobileCard = (row) => {
if (selectedBank === "bidv") {
return { title: row.nameVI || row.nameEN, subtitle: `${row.currency} - ${row.date}`, fields: [
{ label: "Buy TM", value: row.muaTm }, { label: "Buy CK", value: row.muaCk }, { label: "Sell", value: row.ban },
]};
}
return { title: row.label, subtitle: `${row.sourceCurrency} - ${row.date}`, fields: [
{ label: "Bid TM", value: row.bidRateTM }, { label: "Bid CK", value: row.bidRateCK },
{ label: "Ask", value: row.askRate }, { label: "Ask TM", value: row.askRateTM },
]};
};
return (
<>
{/* Desktop table */}
<div className="hidden sm:block overflow-x-auto rounded-lg border border-[var(--card-border)]">
<table className="min-w-full text-sm">
<thead className="sticky top-0 z-10">
<tr className="bg-[var(--table-header-bg)]">
{headers.map((h) => (
<th
key={h.label}
className={`px-3 py-2.5 font-semibold whitespace-nowrap border-b border-[var(--card-border)] ${h.numeric ? "text-right" : "text-left"}`}
>
{h.label}
</th>
))}
</tr>
</thead>
<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={rowKey}
className={`transition-colors hover:!bg-[var(--table-row-hover)] ${idx % 2 !== 0 ? "bg-[var(--table-row-stripe)]" : ""}`}
>
{cells.map((cell, i) => (
<td
key={i}
className={`px-3 py-2 whitespace-nowrap border-b border-[var(--card-border)] ${cell.numeric ? "text-right tabular-nums" : ""}`}
>
{cell.value}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden flex flex-col gap-2">
{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={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>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
{card.fields.map((f, i) => (
<div key={i} className="flex justify-between">
<span className="text-[var(--muted)]">{f.label}</span>
<span className="tabular-nums">{f.value || "-"}</span>
</div>
))}
</div>
</div>
);
})}
</div>
</>
);
}
-315
View File
@@ -1,315 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { format, subDays, differenceInCalendarDays } from "date-fns";
import writeExcelFile from "write-excel-file/browser";
import { LoadingSpinner, ErrorMessage, EmptyState } from "./exchange-rate-status";
import { RateTable, ComparisonTable } from "./exchange-rate-table";
import { RateTrendChart } from "./exchange-rate-chart";
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";
import { downloadTextFile, rowsToCsv } from "./exchange-rate-export-files";
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 [compareMode, setCompareMode] = useState(false);
const [showChart, setShowChart] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [results, setResults] = useState([]);
const [hasFetched, setHasFetched] = useState(false);
const [progress, setProgress] = useState(null);
const [theme, setTheme] = useState("system");
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);
}, []);
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]);
const fetchBank = async (bank) => {
const response = await fetch("/api/exchange-rates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
startDate: format(startDate, "yyyy-MM-dd"),
endDate: format(endDate, "yyyy-MM-dd"),
bank,
currency: selectedCurrency,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to fetch ${bank} rates`);
}
return response.json();
};
const handleFetch = useCallback(async () => {
if (!startDate || !endDate) {
setError("Please select both start and end dates");
return;
}
if (startDate > endDate) {
setError("Start date cannot be after end date");
return;
}
setError("");
setIsLoading(true);
setResults([]);
setHasFetched(false);
const totalDays = differenceInCalendarDays(endDate, startDate) + 1;
setProgress({ total: totalDays });
try {
if (compareMode) {
const [bidvData, tcbData] = await Promise.all([fetchBank("bidv"), fetchBank("tcb")]);
const merged = new Map();
for (const row of (bidvData.data || [])) {
const key = `${row.date}|${row.currency}`;
merged.set(key, { date: row.date, currency: row.currency, bidvBuyTm: row.muaTm, bidvSell: row.ban });
}
for (const row of (tcbData.data || [])) {
const key = `${row.date}|${row.sourceCurrency}`;
const existing = merged.get(key) || { date: row.date, currency: row.sourceCurrency };
existing.tcbBidTm = row.bidRateTM;
existing.tcbAsk = row.askRate;
merged.set(key, existing);
}
setResults(Array.from(merged.values()).sort((a, b) => a.date.localeCompare(b.date)));
} else {
const data = await fetchBank(selectedBank);
setResults(data.data || []);
}
setHasFetched(true);
} catch (err) {
setError(`An error occurred while fetching exchange rates: ${err.message}`);
console.error(err);
} finally {
setIsLoading(false);
setProgress(null);
}
}, [startDate, endDate, selectedBank, selectedCurrency, compareMode]);
useEffect(() => {
if (hydrated) handleFetch();
}, [hydrated]); // eslint-disable-line react-hooks/exhaustive-deps
const getExportRows = () => {
if (compareMode) {
const headers = ["Date", "Currency", "BIDV Buy TM", "BIDV Sell", "TCB Bid TM", "TCB Ask"];
return [headers, ...results.map((r) => [r.date, r.currency, r.bidvBuyTm || "", r.bidvSell || "", r.tcbBidTm || "", r.tcbAsk || ""])];
}
if (selectedBank === "bidv") {
const headers = ["Date", "NameVI", "MuaTm", "MuaCk", "Currency", "NameEN", "Ban"];
return [headers, ...results.map((r) => [r.date, r.nameVI, r.muaTm, r.muaCk, r.currency, r.nameEN, r.ban])];
}
const headers = ["Date", "Label", "AskRate", "BidRateCK", "BidRateTM", "SourceCurrency", "TargetCurrency", "AskRateTM"];
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}_${format(new Date(), "yyyy-MM-dd")}`;
const handleExportExcel = async () => {
if (results.length === 0) return;
await writeExcelFile(getExportRows(), { sheet: "Exchange Rates" }).toFile(`${baseFilename}.xlsx`);
};
const handleExportCsv = () => {
if (results.length === 0) return;
downloadTextFile(rowsToCsv(getExportRows()), `${baseFilename}.csv`, "text/csv;charset=utf-8;");
};
const activePreset = getActivePreset(startDate, endDate);
return (
<div className="container mx-auto px-4 py-8 max-w-5xl w-full">
<div className="mb-6 flex items-start justify-between">
<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 Excel or CSV.
</p>
</div>
<button
type="button"
title={`Theme: ${THEME_LABELS[theme]}`}
onClick={() => {
const next = THEME_CYCLE[(THEME_CYCLE.indexOf(theme) + 1) % THEME_CYCLE.length];
setTheme(next);
applyTheme(next);
localStorage.setItem("theme", next);
}}
className="p-2 rounded-lg border border-[var(--input-border)] text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
>
{theme === "dark" ? <MoonIcon /> : theme === "light" ? <SunIcon /> : <SystemIcon />}
</button>
</div>
<div
className="rounded-xl p-5 mb-6 bg-[var(--card-bg)] border border-[var(--card-border)]"
onKeyDown={(e) => { if (e.key === "Enter" && !isLoading) handleFetch(); }}
>
<div className="flex flex-col gap-4">
{/* Row 1: Selectors and date pickers */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="flex flex-col">
<label htmlFor="bank" className="text-sm font-medium mb-1.5">Bank</label>
<select
id="bank"
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)]"
>
<option value="bidv">BIDV</option>
<option value="tcb">Techcombank</option>
</select>
</div>
<div className="flex flex-col">
<label htmlFor="currency" className="text-sm font-medium mb-1.5">Currency</label>
<select
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)]"
>
{CURRENCY_OPTIONS.map((c) => (
<option key={c} value={c}>{c === "ALL" ? "All Currencies" : c}</option>
))}
</select>
</div>
<div className="flex flex-col">
<label htmlFor="start-date" className="text-sm font-medium mb-1.5">Start Date</label>
<DatePicker
id="start-date"
selected={startDate}
onChange={(date) => 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"
/>
</div>
<div className="flex flex-col">
<label htmlFor="end-date" className="text-sm font-medium mb-1.5">End Date</label>
<DatePicker
id="end-date"
selected={endDate}
onChange={(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"
/>
</div>
</div>
{/* Row 2: Toggles and date presets */}
<div className="flex flex-wrap items-center gap-3 pt-1 border-t border-[var(--card-border)]">
<label className="flex items-center gap-1.5 text-xs cursor-pointer text-[var(--muted)]">
<input type="checkbox" checked={compareMode} onChange={(e) => setCompareMode(e.target.checked)} className="rounded" />
Compare BIDV vs TCB
</label>
<label className="flex items-center gap-1.5 text-xs cursor-pointer text-[var(--muted)]">
<input type="checkbox" checked={showChart} onChange={(e) => setShowChart(e.target.checked)} className="rounded" />
Show Chart
</label>
<span className="text-[var(--card-border)]">|</span>
{DATE_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
className={`text-xs px-3 py-1 rounded-md border transition-colors ${
activePreset === preset.label
? "bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border-blue-300 dark:border-blue-700 font-medium"
: "border-[var(--input-border)] text-[var(--muted)] hover:bg-blue-50 dark:hover:bg-blue-900/20"
}`}
onClick={() => { setStartDate(preset.start()); setEndDate(preset.end()); }}
>
{preset.label}
</button>
))}
</div>
{/* Row 3: Action buttons */}
<div className="flex flex-col sm:flex-row gap-2 sm:justify-end pt-1">
<button
className="bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-medium px-5 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500/40 inline-flex items-center justify-center gap-2"
onClick={handleFetch}
disabled={isLoading}
>
<FetchIcon />
{isLoading ? "Fetching..." : "Fetch Rates"}
</button>
<button
className="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-medium px-5 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-emerald-500/40 inline-flex items-center justify-center gap-2"
onClick={handleExportExcel}
disabled={results.length === 0 || isLoading}
>
<ExcelIcon />
Export Excel
</button>
<button
className="text-sm font-medium px-5 py-2 rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500/30 inline-flex items-center justify-center gap-2 border-[var(--input-border)] text-[var(--foreground)]"
onClick={handleExportCsv}
disabled={results.length === 0 || isLoading}
>
<CsvIcon />
Export CSV
</button>
</div>
</div>
</div>
<div aria-live="polite" aria-atomic="true">
{error && <ErrorMessage message={error} />}
{isLoading && <LoadingSpinner progress={progress} />}
{!isLoading && hasFetched && results.length === 0 && <EmptyState />}
{!isLoading && hasFetched && results.length > 0 && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="text-sm text-[var(--muted)]">
{results.length} {results.length === 1 ? "record" : "records"} found
</p>
</div>
{showChart && (
<RateTrendChart results={results} selectedBank={selectedBank} compareMode={compareMode} />
)}
{compareMode
? <ComparisonTable results={results} />
: <RateTable results={results} selectedBank={selectedBank} />
}
</div>
)}
</div>
</div>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

-115
View File
@@ -1,115 +0,0 @@
@import "tailwindcss";
:root {
--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 {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
}
/* Dark mode: explicit toggle OR system preference (unless overridden to light) */
:root[data-theme="dark"] {
--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;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--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;
}
}
body {
background: var(--background);
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;
}
-28
View File
@@ -1,28 +0,0 @@
import { Geist } from "next/font/google";
import "./globals.css";
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from '@vercel/speed-insights/next';
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
export const metadata = {
title: "Exchange Rate Export",
description: "Fetch exchange rates from Vietnamese banks and export to Excel or CSV.",
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body
className={`${geistSans.variable} antialiased`}
>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}
-11
View File
@@ -1,11 +0,0 @@
import ExchangeRateViewer from "./components/exchange-rate-viewer";
export default function Home() {
return (
<div className="min-h-screen w-full flex flex-col items-center justify-center bg-background text-foreground font-[family-name:var(--font-geist-sans)]">
<main className="w-full flex flex-col items-center">
<ExchangeRateViewer />
</main>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 822.8 355.5" style="enable-background:new 0 0 822.8 355.5;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
@media(prefers-color-scheme:light){.st0{fill:#404242;}}
.st1{fill:#FFFFFF;}
.st2{fill:#FBAE40;}
.st3{fill:#F58220;}
</style>
<g id="Page-1">
<path id="CLOUDFLARE-_xAE_" class="st0" d="M772.2,252.6c-3.4,0-6.1-2.7-6.1-6.1c0-3.3,2.7-6.1,6.1-6.1c3.3,0,6.1,2.7,6.1,6.1
C778.3,249.8,775.5,252.6,772.2,252.6L772.2,252.6z M772.2,241.6c-2.7,0-4.9,2.2-4.9,4.9s2.2,4.9,4.9,4.9c2.7,0,4.9-2.2,4.9-4.9
S774.9,241.6,772.2,241.6L772.2,241.6z M775.3,249.7h-1.4l-1.2-2.3h-1.6v2.3h-1.3V243h3.2c1.4,0,2.3,0.9,2.3,2.2c0,1-0.6,1.7-1.4,2
L775.3,249.7z M772.9,246.2c0.5,0,1-0.3,1-1c0-0.8-0.4-1-1-1h-2v2H772.9z M136.7,239.8h15.6v42.5h27.1v13.6h-42.7V239.8z
M195.5,268v-0.2c0-16.1,13-29.2,30.3-29.2s30.1,12.9,30.1,29v0.2c0,16.1-13,29.2-30.3,29.2S195.5,284.1,195.5,268z M240.1,268
v-0.2c0-8.1-5.8-15.1-14.4-15.1c-8.5,0-14.2,6.9-14.2,15v0.2c0,8.1,5.8,15.1,14.3,15.1C234.4,283,240.1,276.1,240.1,268z
M275,271.3v-31.5h15.8V271c0,8.1,4.1,11.9,10.3,11.9c6.2,0,10.3-3.7,10.3-11.5v-31.6h15.8v31.1c0,18.1-10.3,26-26.3,26
C285,296.9,275,288.9,275,271.3z M351,239.8h21.6c20,0,31.7,11.5,31.7,27.7v0.2c0,16.2-11.8,28.2-32,28.2H351V239.8z M372.9,282.1
c9.3,0,15.5-5.1,15.5-14.2v-0.2c0-9-6.2-14.2-15.5-14.2h-6.3V282L372.9,282.1L372.9,282.1z M426.9,239.8h44.9v13.6h-29.4v9.6H469
v12.9h-26.6v20h-15.5V239.8z M493.4,239.8h15.5v42.5h27.2v13.6h-42.7V239.8z M576.7,239.4h15l23.9,56.5h-16.7l-4.1-10h-21.6l-4,10
h-16.3L576.7,239.4z M590.4,273.8l-6.2-15.9l-6.3,15.9H590.4z M635.6,239.8h26.5c8.6,0,14.5,2.2,18.3,6.1c3.3,3.2,5,7.5,5,13.1v0.2
c0,8.6-4.6,14.3-11.5,17.2l13.4,19.6h-18L658,279h-6.8v17h-15.6V239.8z M661.4,266.7c5.3,0,8.3-2.6,8.3-6.6v-0.2
c0-4.4-3.2-6.6-8.4-6.6h-10.2v13.4H661.4z M707.8,239.8h45.1V253h-29.7v8.5h26.9v12.3h-26.9v8.9h30.1v13.2h-45.5V239.8z
M102.7,274.6c-2.2,4.9-6.8,8.4-12.8,8.4c-8.5,0-14.3-7.1-14.3-15.1v-0.2c0-8.1,5.7-15,14.2-15c6.4,0,11.3,3.9,13.3,9.3h16.4
c-2.6-13.4-14.4-23.3-29.6-23.3c-17.3,0-30.3,13.1-30.3,29.2v0.2c0,16.1,12.8,29,30.1,29c14.8,0,26.4-9.6,29.4-22.4L102.7,274.6z"
/>
<path id="flare" class="st1" d="M734.5,150.4l-40.7-24.7c-0.6-0.1-4.4,0.3-6.4-0.7c-1.4-0.7-2.5-1.9-3.2-4c-3.2,0-175.5,0-175.5,0
v91.8h225.8V150.4z"/>
<path id="right-cloud" class="st2" d="M692.2,125.8c-0.8,0-1.5,0.6-1.8,1.4l-4.8,16.7c-2.1,7.2-1.3,13.8,2.2,18.7
c3.2,4.5,8.6,7.1,15.1,7.4l26.2,1.6c0.8,0,1.5,0.4,1.9,1c0.4,0.6,0.5,1.5,0.3,2.2c-0.4,1.2-1.6,2.1-2.9,2.2l-27.3,1.6
c-14.8,0.7-30.7,12.6-36.3,27.2l-2,5.1c-0.4,1,0.3,2,1.4,2H758c1.1,0,2.1-0.7,2.4-1.8c1.6-5.8,2.5-11.9,2.5-18.2
c0-37-30.2-67.2-67.3-67.2C694.5,125.7,693.3,125.7,692.2,125.8z"/>
<path id="left-cloud" class="st3" d="M656.4,204.6c2.1-7.2,1.3-13.8-2.2-18.7c-3.2-4.5-8.6-7.1-15.1-7.4L516,176.9
c-0.8,0-1.5-0.4-1.9-1c-0.4-0.6-0.5-1.4-0.3-2.2c0.4-1.2,1.6-2.1,2.9-2.2l124.2-1.6c14.7-0.7,30.7-12.6,36.3-27.2l7.1-18.5
c0.3-0.8,0.4-1.6,0.2-2.4c-8-36.2-40.3-63.2-78.9-63.2c-35.6,0-65.8,23-76.6,54.9c-7-5.2-15.9-8-25.5-7.1
c-17.1,1.7-30.8,15.4-32.5,32.5c-0.4,4.4-0.1,8.7,0.9,12.7c-27.9,0.8-50.2,23.6-50.2,51.7c0,2.5,0.2,5,0.5,7.5
c0.2,1.2,1.2,2.1,2.4,2.1h227.2c1.3,0,2.5-0.9,2.9-2.2L656.4,204.6z"/>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+68
View File
@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+242
View File
@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exchange Rate Export - Techcombank</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 40px;
background: #f7f7f7;
}
.container {
background: #fff;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
max-width: 700px;
margin: auto;
}
h1 {
text-align: center;
}
label {
margin-right: 8px;
}
input[type="date"] {
margin-right: 16px;
padding: 4px 8px;
}
button {
padding: 8px 16px;
margin-right: 8px;
border: none;
border-radius: 4px;
background: #007bff;
color: #fff;
cursor: pointer;
font-size: 1rem;
}
button:disabled {
background: #aaa;
cursor: not-allowed;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 24px;
}
th, td {
border: 1px solid #ddd;
padding: 8px;
text-align: center;
}
th {
background: #007bff;
color: #fff;
}
.loading {
text-align: center;
margin-top: 20px;
color: #007bff;
}
.error {
color: red;
text-align: center;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<h1>Exchange Rate Export (USD, Techcombank)</h1>
<div style="margin-bottom: 20px;">
<label for="start-date">Start Date:</label>
<input type="date" id="start-date">
<label for="end-date">End Date:</label>
<input type="date" id="end-date">
<button id="fetch-btn">Fetch</button>
<button id="export-btn" disabled>Export CSV</button>
</div>
<div id="status"></div>
<table id="result-table" style="display:none;">
<thead>
<tr>
<th>Date</th>
<th>label</th>
<th>askRate</th>
<th>bidRateCK</th>
<th>bidRateTM</th>
<th>askRateTM</th>
<th>sourceCurrency</th>
<th>targetCurrency</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<script>
const fetchBtn = document.getElementById('fetch-btn');
const exportBtn = document.getElementById('export-btn');
const startDateInput = document.getElementById('start-date');
const endDateInput = document.getElementById('end-date');
const statusDiv = document.getElementById('status');
const table = document.getElementById('result-table');
const tbody = table.querySelector('tbody');
let results = [];
function formatDate(date) {
// Format as dd/MM/yyyy
const d = new Date(date);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}/${month}/${year}`;
}
function getDateRange(start, end) {
const dates = [];
let current = new Date(start);
const last = new Date(end);
while (current <= last) {
dates.push(new Date(current));
current.setDate(current.getDate() + 1);
}
return dates;
}
async function fetchUSD(dateObj) {
// Techcombank API expects yyyy-mm-dd in URL
const yyyy = dateObj.getFullYear();
const mm = String(dateObj.getMonth() + 1).padStart(2, '0');
const dd = String(dateObj.getDate()).padStart(2, '0');
const dateStr = `${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 fetch(url);
if (!res.ok) throw new Error('API error');
const data = await res.json();
if (!data.exchangeRate || !data.exchangeRate.data) return null;
// Find USD (50,100)
const usd = data.exchangeRate.data.find(item => item.label === 'USD (50,100)');
if (!usd) return null;
return {
date: formatDate(dateObj),
label: usd.label,
askRate: usd.askRate,
bidRateCK: usd.bidRateCK,
bidRateTM: usd.bidRateTM,
askRateTM: usd.askRateTM,
sourceCurrency: usd.sourceCurrency,
targetCurrency: usd.targetCurrency
};
} catch (e) {
return null;
}
}
function renderTable(data) {
tbody.innerHTML = '';
for (const row of data) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${row.date}</td>
<td>${row.label}</td>
<td>${row.askRate || ''}</td>
<td>${row.bidRateCK || ''}</td>
<td>${row.bidRateTM || ''}</td>
<td>${row.askRateTM || ''}</td>
<td>${row.sourceCurrency}</td>
<td>${row.targetCurrency}</td>
`;
tbody.appendChild(tr);
}
table.style.display = data.length ? '' : 'none';
}
function exportCSV(data) {
const header = ['Date', 'label', 'askRate', 'bidRateCK', 'bidRateTM', 'askRateTM', 'sourceCurrency', 'targetCurrency'];
const rows = [header];
for (const row of data) {
rows.push([
row.date,
row.label,
row.askRate,
row.bidRateCK,
row.bidRateTM,
row.askRateTM,
row.sourceCurrency,
row.targetCurrency
]);
}
const csvContent = rows.map(r => r.map(cell => '"' + String(cell || '').replace(/"/g, '""') + '"').join(',')).join('\r\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'usd_exchange_rates_tcb.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
fetchBtn.onclick = async () => {
const start = startDateInput.value;
const end = endDateInput.value;
if (!start || !end) {
statusDiv.innerHTML = '<div class="error">Please select both start and end dates.</div>';
return;
}
if (start > end) {
statusDiv.innerHTML = '<div class="error">Start date must be before or equal to end date.</div>';
return;
}
statusDiv.innerHTML = '<div class="loading">Fetching data, please wait...</div>';
fetchBtn.disabled = true;
exportBtn.disabled = true;
results = [];
renderTable([]);
const dates = getDateRange(start, end);
for (let i = 0; i < dates.length; i++) {
const dateObj = dates[i];
const dateStr = formatDate(dateObj);
statusDiv.innerHTML = `<div class=\"loading\">Fetching: ${dateStr} (${i+1}/${dates.length})</div>`;
const usd = await fetchUSD(dateObj);
if (usd) {
results.push(usd);
renderTable(results);
}
}
statusDiv.innerHTML = results.length ? '' : '<div class="error">No data found for the selected range.</div>';
fetchBtn.disabled = false;
exportBtn.disabled = results.length === 0;
};
exportBtn.onclick = () => {
if (results.length) exportCSV(results);
};
</script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { cloudflare } from "@cloudflare/vite-plugin";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), cloudflare()],
})
+13
View File
@@ -0,0 +1,13 @@
export default {
fetch(request, env) {
const url = new URL(request.url);
if (url.pathname.startsWith("/api/")) {
return Response.json({
name: "Cloudflare",
});
}
return new Response(null, { status: 404 });
},
}
+50
View File
@@ -0,0 +1,50 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "exchange-rate-export",
"main": "worker/index.js",
"compatibility_date": "2025-06-20",
"assets": {
"not_found_handling": "single-page-application"
},
"observability": {
"enabled": true
}
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" },
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
// "vars": { "MY_VARIABLE": "production_value" },
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" },
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
}