From 38b6561e2656b7cf7ed91a5fc035542a2e62f020 Mon Sep 17 00:00:00 2001 From: Tien Nguyen Minh Date: Sun, 5 Apr 2026 11:11:48 +0700 Subject: [PATCH] Merge pull request #20 from tiennm99/dependabot/npm_and_yarn/flatted-3.4.2 build(deps-dev): bump flatted from 3.3.3 to 3.4.2 --- .claude/settings.json | 88 +++ .gitignore | 4 + package-lock.json | 498 +++++++++++++++- package.json | 4 +- src/app/api/exchange-rates/route.js | 239 ++++---- src/app/components/ExchangeRateViewer.js | 640 +++++++++++++-------- src/app/components/exchange-rate-chart.js | 99 ++++ src/app/components/exchange-rate-status.js | 44 ++ src/app/components/exchange-rate-table.js | 204 +++++++ src/app/globals.css | 95 ++- src/app/layout.js | 15 +- src/app/page.js | 20 +- 12 files changed, 1565 insertions(+), 385 deletions(-) create mode 100644 .claude/settings.json create mode 100644 src/app/components/exchange-rate-chart.js create mode 100644 src/app/components/exchange-rate-status.js create mode 100644 src/app/components/exchange-rate-table.js diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c3ab258 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,88 @@ +{ + "permissions": { + "allow": [ + "Read", + "Edit", + "Bash(git status *)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git branch *)", + "Bash(git checkout *)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git stash *)", + "Bash(git merge *)", + "Bash(git rebase *)", + "Bash(git fetch *)", + "Bash(git pull *)", + "Bash(git show *)", + "Bash(git blame *)", + "Bash(ls *)", + "Bash(cat *)", + "Bash(find *)", + "Bash(grep *)", + "Bash(rg *)", + "Bash(head *)", + "Bash(tail *)", + "Bash(wc *)", + "Bash(sort *)", + "Bash(uniq *)", + "Bash(diff *)", + "Bash(echo *)", + "Bash(pwd)", + "Bash(which *)", + "Bash(mkdir *)", + "Bash(cp *)", + "Bash(mv *)", + "Bash(touch *)", + "Bash(cd *)", + "Bash(npm run *)", + "Bash(npm test *)", + "Bash(npm install *)", + "Bash(npx *)", + "Bash(yarn *)", + "Bash(pnpm *)", + "Bash(gradle *)", + "Bash(./gradlew *)", + "Bash(go build *)", + "Bash(go test *)", + "Bash(go run *)", + "Bash(go mod *)", + "Bash(cargo *)", + "Bash(make *)", + "Bash(python *)", + "Bash(pip *)", + "Bash(node *)", + "Bash(java *)", + "Bash(javac *)", + "Bash(sed *)", + "Bash(awk *)", + "Bash(jq *)", + "Bash(tree *)", + "Bash(date *)", + "Bash(env *)", + "Bash(printenv *)", + "Bash(realpath *)", + "Bash(dirname *)", + "Bash(basename *)", + "Bash(stat *)", + "Bash(file *)", + "Bash(xargs *)", + "Bash(tee *)" + ], + "deny": [ + "Bash(git push --force *)", + "Bash(git push -f *)", + "Bash(git push * --force *)", + "Bash(git push * -f *)", + "Bash(rm -rf /)", + "Bash(rm -rf /*)", + "Bash(rm -rf ~)", + "Bash(rm -rf ~/*)", + "Bash(curl * | bash *)", + "Bash(curl * | sh *)", + "Bash(wget * | bash *)", + "Bash(wget * | sh *)" + ] + } +} diff --git a/.gitignore b/.gitignore index 5ef6a52..c52f499 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +.claude/session-state + + + # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies diff --git a/package-lock.json b/package-lock.json index 3ad2fe1..9f6da9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "next": "16.1.5", "react": "^19.0.0", "react-datepicker": "^8.4.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "recharts": "^3.8.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -1095,6 +1097,42 @@ "node": ">=12.4.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1109,6 +1147,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1405,6 +1455,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1426,6 +1539,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.35.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz", @@ -2078,6 +2197,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2477,6 +2605,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2519,6 +2660,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2558,6 +2708,18 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2573,6 +2735,127 @@ "node": ">= 8" } }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2662,6 +2945,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2945,6 +3234,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3384,6 +3683,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3561,6 +3866,15 @@ "node": ">= 6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3839,6 +4153,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3881,6 +4205,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5340,9 +5673,76 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5387,6 +5787,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -5739,6 +6145,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -5985,6 +6403,12 @@ "node": ">=18" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -6245,6 +6669,37 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6350,6 +6805,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -6360,6 +6833,27 @@ "node": ">=0.10.0" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index ee0d8fb..a6a611d 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "next": "16.1.5", "react": "^19.0.0", "react-datepicker": "^8.4.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "recharts": "^3.8.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/src/app/api/exchange-rates/route.js b/src/app/api/exchange-rates/route.js index 64ade56..8576418 100644 --- a/src/app/api/exchange-rates/route.js +++ b/src/app/api/exchange-rates/route.js @@ -3,80 +3,102 @@ import axios from "axios"; import https from "https"; import crypto from "crypto"; -// Configure axios with legacy SSL support const axiosInstance = axios.create({ httpsAgent: new https.Agent({ secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, }), }); -// Helper function to fetch BIDV exchange rates -async function fetchBIDVRates(dateObj) { +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 { - // First API call to get namerecord const timeUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailSearchTimeServlet?date=${dateStr}`; - const timeRes = await axiosInstance.get(timeUrl); + const timeRes = await withRetry(() => axiosInstance.get(timeUrl)); const timeData = timeRes.data; if (timeData.status !== 1 || !timeData.data?.length) return null; - // Get the latest record + const latest = timeData.data.reduce( - (latest, current) => (current.time > latest.time ? current : latest), + (best, current) => (current.time > best.time ? current : best), timeData.data[0] ); - // Second API call to get exchange rates + const rateUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailServlet?date=${dateStr}&time=${latest.namerecord}`; - const rateRes = await axiosInstance.get(rateUrl); + const rateRes = await withRetry(() => axiosInstance.get(rateUrl)); const rateData = rateRes.data; if (rateData.status !== 1 || !rateData.data) return null; - // Find USD data - const usd = rateData.data.find((item) => item.currency === "USD"); - if (!usd) return null; - return { - date: dateStr, - nameVI: usd.nameVI || "", - muaTm: usd.muaTm || "", - muaCk: usd.muaCk || "", - currency: usd.currency || "USD", - nameEN: usd.nameEN || "", - ban: usd.ban || "", - }; + + 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); + console.error("Error fetching BIDV rates:", error.message); return null; } } -// Helper function to fetch TCB exchange rates -async function fetchTCBRates(dateObj) { +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 axiosInstance.get(url); + const res = await withRetry(() => axiosInstance.get(url)); const data = res.data; if (!data.exchangeRate?.data) return null; - // Find USD data - const usd = data.exchangeRate.data.find( - (item) => item.label === "USD (50,100)" - ); - if (!usd) return null; - return { + + 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: usd.label || "", - askRate: usd.askRate || "", - bidRateCK: usd.bidRateCK || "", - bidRateTM: usd.bidRateTM || "", - sourceCurrency: usd.sourceCurrency || "", - targetCurrency: usd.targetCurrency || "", - askRateTM: usd.askRateTM || "", - }; + 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); + console.error("Error fetching TCB rates:", error.message); return null; } } -// Get date range between start and end dates +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); @@ -88,105 +110,82 @@ function getDateRange(start, end) { return dates; } -// Helper function to get previous business day rate with recursive search -async function getPreviousDayRate(date, bank, rateCache, maxAttempts = 30) { +// 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 = 1; attempt <= maxAttempts; attempt++) { + for (let attempt = 0; attempt < maxAttempts; attempt++) { currentDate.setDate(currentDate.getDate() - 1); - const dateStr = format(currentDate, 'yyyy-MM-dd'); - - - // Try to find in cache first + const dateStr = format(currentDate, "yyyy-MM-dd"); + if (rateCache.has(dateStr)) { return rateCache.get(dateStr); } - - // Fetch from API - let rate = null; - try { - if (bank === "bidv") { - rate = await fetchBIDVRates(currentDate); - } else if (bank === "tcb") { - rate = await fetchTCBRates(currentDate); - } - - if (rate) { - rateCache.set(dateStr, rate); - return rate; - } - } catch (error) { - console.error(`Error fetching rates for ${dateStr}:`, error); + + 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 } = body; + const { startDate, endDate, bank, currency = "USD" } = body; if (!startDate || !endDate || !bank) { - return new Response( - JSON.stringify({ error: "Missing required parameters" }), - { status: 400, headers: { "Content-Type": "application/json" } } + return Response.json( + { error: "Missing required parameters" }, + { status: 400 } ); } + const dates = getDateRange(startDate, endDate); + const rateCache = new Map(); const results = []; - const rateCache = new Map(); // Cache to store rates we've fetched - - for (const date of dates) { - let rate = null; - const dateStr = format(date, 'yyyy-MM-dd'); - - - try { - if (bank === "bidv") { - rate = await fetchBIDVRates(date); - } else if (bank === "tcb") { - rate = await fetchTCBRates(date); - } - - if (rate) { - results.push(rate); - rateCache.set(dateStr, rate); - } else { - // No data for this date (likely weekend), try to get previous day's data - const prevRate = await getPreviousDayRate(date, bank, rateCache); - if (prevRate) { - // Create a new rate object with current date but previous day's data - const filledRate = { - ...prevRate, - date: bank === "bidv" ? format(date, 'dd/MM/yyyy') : dateStr - }; - results.push(filledRate); - } else { - } - } - } catch (error) { - console.error(`Error fetching rates for ${date}:`, error); - // Try to fill with previous day's data even on error - const prevRate = await getPreviousDayRate(date, bank, rateCache); - if (prevRate) { - const filledRate = { - ...prevRate, - date: bank === "bidv" ? format(date, 'dd/MM/yyyy') : dateStr - }; - results.push(filledRate); - } - } + + // 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 new Response(JSON.stringify({ data: results }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + + return Response.json({ data: results, total: dates.length, chunks: Math.ceil(dates.length / PARALLEL_CHUNK_SIZE) }); } catch (error) { console.error("Server error:", error); - return new Response(JSON.stringify({ error: "Internal server error" }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); + return Response.json( + { error: "Internal server error" }, + { status: 500 } + ); } } diff --git a/src/app/components/ExchangeRateViewer.js b/src/app/components/ExchangeRateViewer.js index 36b160d..ba6737b 100644 --- a/src/app/components/ExchangeRateViewer.js +++ b/src/app/components/ExchangeRateViewer.js @@ -1,274 +1,454 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import DatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; -import { parse, isDate, format } from "date-fns"; +import { format, subDays, startOfMonth, isSameDay, 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"; -export default function ExchangeRateViewer({ - defaultStartDate, - defaultEndDate, -}) { - const [startDate, setStartDate] = useState( - defaultStartDate - ? isDate(defaultStartDate) - ? defaultStartDate - : parse(defaultStartDate, "yyyy-MM-dd", new Date()) - : null +// 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 ( + ); - const [endDate, setEndDate] = useState( - defaultEndDate - ? isDate(defaultEndDate) - ? defaultEndDate - : parse(defaultEndDate, "yyyy-MM-dd", new Date()) - : null +} + +function MoonIcon() { + return ( + ); +} + +function SystemIcon() { + return ( + + ); +} + +// Inline SVG icons (16x16) to avoid adding an icon library dependency +function FetchIcon() { + return ( + + ); +} + +function ExcelIcon() { + return ( + + ); +} + +function CsvIcon() { + return ( + + ); +} + +export default function ExchangeRateViewer() { + const [startDate, setStartDate] = useState(() => subDays(new Date(), 7)); + const [endDate, setEndDate] = useState(() => new Date()); const [selectedBank, setSelectedBank] = useState("bidv"); + const [selectedCurrency, setSelectedCurrency] = useState("USD"); + const [compareMode, setCompareMode] = useState(false); + const [showChart, setShowChart] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(""); const [results, setResults] = useState([]); - const [showTable, setShowTable] = useState(false); + const [hasFetched, setHasFetched] = useState(false); + const [progress, setProgress] = useState(null); + const [theme, setTheme] = useState("system"); - const handleFetch = async () => { + // Restore saved settings on client mount — URL params take priority over localStorage + const [hydrated, setHydrated] = useState(false); + useEffect(() => { + const urlSettings = loadFromUrl(); + const saved = urlSettings || loadSavedSettings(); + if (saved) { + if (saved.bank) setSelectedBank(saved.bank); + if (saved.currency) setSelectedCurrency(saved.currency); + if (saved.startDate) setStartDate(saved.startDate); + if (saved.endDate) setEndDate(saved.endDate); + } + const savedTheme = localStorage.getItem("theme") || "system"; + setTheme(savedTheme); + applyTheme(savedTheme); + setHydrated(true); + }, []); + + // Sync state to both localStorage and URL + useEffect(() => { + if (!hydrated) return; + saveSettings(selectedBank, selectedCurrency, startDate, endDate); + const params = new URLSearchParams(); + params.set("bank", selectedBank); + params.set("currency", selectedCurrency); + if (startDate) params.set("start", format(startDate, "yyyy-MM-dd")); + if (endDate) params.set("end", format(endDate, "yyyy-MM-dd")); + window.history.replaceState(null, "", `?${params.toString()}`); + }, [selectedBank, selectedCurrency, startDate, endDate, hydrated]); + + 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([]); - setShowTable(false); + setHasFetched(false); + const totalDays = differenceInCalendarDays(endDate, startDate) + 1; + setProgress({ current: 0, total: totalDays }); try { - const start = startDate; - const end = endDate; - if (start > end) { - setError("Start date cannot be after end date"); - setIsLoading(false); - return; + 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 || []); } - const response = await fetch("/api/exchange-rates", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - startDate: format(start, 'yyyy-MM-dd'), - endDate: format(end, 'yyyy-MM-dd'), - bank: selectedBank, - }), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to fetch exchange rates"); - } - const data = await response.json(); - setResults(data.data || []); - setShowTable(true); + setHasFetched(true); } catch (err) { - setError( - `An error occurred while fetching exchange rates: ${err.message}` - ); + setError(`An error occurred while fetching exchange rates: ${err.message}`); console.error(err); } finally { setIsLoading(false); + 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 handleExport = () => { + const baseFilename = `exchange_rates_${compareMode ? "compare" : selectedBank}_${selectedCurrency}_${new Date().toISOString().split("T")[0]}`; + + const handleExportExcel = () => { if (results.length === 0) return; - let csvContent = ""; - let headers = []; - if (selectedBank === "bidv") { - headers = [ - "Date", - "NameVI", - "MuaTm", - "MuaCk", - "Currency", - "NameEN", - "Ban", - ]; - csvContent = headers.join(",") + "\n"; - csvContent += results - .map( - (row) => - `"${row.date}","${row.nameVI}","${row.muaTm}","${row.muaCk}","${row.currency}","${row.nameEN}","${row.ban}"` - ) - .join("\n"); - } else { - headers = [ - "Date", - "Label", - "AskRate", - "BidRateCK", - "BidRateTM", - "SourceCurrency", - "TargetCurrency", - "AskRateTM", - ]; - csvContent = headers.join(",") + "\n"; - csvContent += results - .map( - (row) => - `"${row.date}","${row.label}","${row.askRate}","${row.bidRateCK}","${row.bidRateTM}","${row.sourceCurrency}","${row.targetCurrency}","${row.askRateTM}"` - ) - .join("\n"); - } - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const ws = XLSX.utils.aoa_to_sheet(getExportRows()); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Exchange Rates"); + XLSX.writeFile(wb, `${baseFilename}.xlsx`); + }; + + const handleExportCsv = () => { + if (results.length === 0) return; + const ws = XLSX.utils.aoa_to_sheet(getExportRows()); + const csv = XLSX.utils.sheet_to_csv(ws); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = `exchange_rates_${selectedBank}_${ - new Date().toISOString().split("T")[0] - }.csv`; + link.download = `${baseFilename}.csv`; document.body.appendChild(link); link.click(); document.body.removeChild(link); + URL.revokeObjectURL(url); }; + const activePreset = getActivePreset(startDate, endDate); + return ( -
-

Exchange Rate Export

-
- {/* Row 1: Start & End Date Pickers and Bank Select */} -
-
- - -
-
- - setStartDate(date)} - maxDate={endDate || new Date()} - dateFormat="yyyy-MM-dd" - className="border rounded p-2 w-full" - placeholderText="Select start date" - /> -
-
- - setEndDate(date)} - minDate={startDate} - maxDate={new Date()} - dateFormat="yyyy-MM-dd" - className="border rounded p-2 w-full" - placeholderText="Select end date" - /> -
+
+
+
+

Exchange Rate Export

+

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

- {/* Row 2: Fetch & Export Buttons aligned with End Date */} -
- - + +
+ +
{ if (e.key === "Enter" && !isLoading) handleFetch(); }} + > +
+ {/* Row 1: Selectors and date pickers */} +
+
+ + +
+
+ + +
+
+ + setStartDate(date)} + maxDate={endDate || new Date()} + dateFormat="yyyy-MM-dd" + className="border rounded-lg px-3 py-2 text-sm w-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/30" + placeholderText="Select start date" + /> +
+
+ + setEndDate(date)} + minDate={startDate} + maxDate={new Date()} + dateFormat="yyyy-MM-dd" + className="border rounded-lg px-3 py-2 text-sm w-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/30" + placeholderText="Select end date" + /> +
+
+ + {/* Row 2: Toggles and date presets */} +
+ + + | + {DATE_PRESETS.map((preset) => ( + + ))} +
+ + {/* Row 3: Action buttons */} +
+ + + +
- {error &&
{error}
} - {isLoading && ( -
Loading data, please wait...
- )} - {showTable && ( -
- {selectedBank === "bidv" ? ( - - - - - - - - - - - - - - {results.map((result, idx) => ( - - - - - - - - - - ))} - -
DateNameVIMuaTmMuaCkCurrencyNameENBan
{result.date}{result.nameVI}{result.muaTm}{result.muaCk}{result.currency}{result.nameEN}{result.ban}
- ) : ( - - - - - - - - - - - - - - - {results.map((result, idx) => ( - - - - - - - - - - - ))} - -
DateLabelAskRateBidRateCKBidRateTMSourceCurrencyTargetCurrencyAskRateTM
{result.date}{result.label}{result.askRate}{result.bidRateCK}{result.bidRateTM} - {result.sourceCurrency} - - {result.targetCurrency} - {result.askRateTM}
- )} -
- )} + +
+ {error && } + {isLoading && } + {!isLoading && hasFetched && results.length === 0 && } + {!isLoading && hasFetched && results.length > 0 && ( +
+
+

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

+
+ {showChart && ( + + )} + {compareMode + ? + : + } +
+ )} +
); } diff --git a/src/app/components/exchange-rate-chart.js b/src/app/components/exchange-rate-chart.js new file mode 100644 index 0000000..4f51c3a --- /dev/null +++ b/src/app/components/exchange-rate-chart.js @@ -0,0 +1,99 @@ +"use client"; + +import React from "react"; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"; + +// 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); + } + } + + const colors = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"]; + + return ( +
+ + + + + + + {lineKeys.size <= 8 && } + {Array.from(lineKeys).map((key, i) => ( + + ))} + + +
+ ); +} diff --git a/src/app/components/exchange-rate-status.js b/src/app/components/exchange-rate-status.js new file mode 100644 index 0000000..8d0f549 --- /dev/null +++ b/src/app/components/exchange-rate-status.js @@ -0,0 +1,44 @@ +"use client"; + +import React from "react"; + +export function LoadingSpinner({ progress }) { + return ( +
+
+
+

+ {progress?.total ? `Fetching rates for ${progress.total} day${progress.total > 1 ? "s" : ""}...` : "Fetching exchange rates..."} +

+ {progress?.total > 1 && ( +
+
+
+ )} +
+
+ ); +} + +export function ErrorMessage({ message }) { + return ( +
+ + {message} +
+ ); +} + +export function EmptyState() { + return ( +
+ +

No exchange rate data

+

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

+
+ ); +} diff --git a/src/app/components/exchange-rate-table.js b/src/app/components/exchange-rate-table.js new file mode 100644 index 0000000..88a1dc5 --- /dev/null +++ b/src/app/components/exchange-rate-table.js @@ -0,0 +1,204 @@ +"use client"; + +import React from "react"; + +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 */} +
+ + + + {headers.map((h) => ( + + ))} + + + + {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 ( + + {cells.map((cell, i) => ( + + ))} + + ); + })} + +
+ {h.label} +
+ {cell.value || "-"} +
+
+ {/* Mobile cards */} +
+ {results.map((row, idx) => ( +
+
+ {row.date} + {row.currency} +
+
+
BIDV Buy{row.bidvBuyTm || "-"}
+
TCB Bid{row.tcbBidTm || "-"}
+
BIDV Sell{row.bidvSell || "-"}
+
TCB Ask{row.tcbAsk || "-"}
+
+
+ ))} +
+ + ); +} + +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 */} +
+ + + + {headers.map((h) => ( + + ))} + + + + {results.map((row, idx) => { + const cells = getRowCells(row); + return ( + + {cells.map((cell, i) => ( + + ))} + + ); + })} + +
+ {h.label} +
+ {cell.value} +
+
+ {/* Mobile cards */} +
+ {results.map((row, idx) => { + const card = getMobileCard(row); + return ( +
+
+ {card.title} + {card.subtitle} +
+
+ {card.fields.map((f, i) => ( +
+ {f.label} + {f.value || "-"} +
+ ))} +
+
+ ); + })} +
+ + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..4beb2e1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,21 +1,53 @@ @import "tailwindcss"; :root { - --background: #ffffff; + --background: #f8fafc; --foreground: #171717; + --card-bg: #ffffff; + --card-border: #e2e8f0; + --input-bg: #ffffff; + --input-border: #cbd5e1; + --input-focus: #3b82f6; + --table-header-bg: #f1f5f9; + --table-row-hover: #e2e8f0; + --table-row-stripe: #f1f5f9; + --muted: #64748b; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); +} + +/* 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 { + :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; } } @@ -24,3 +56,60 @@ body { color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } + +/* Date picker dark mode support */ +.react-datepicker-wrapper { + width: 100%; +} + +.react-datepicker__input-container input { + background: var(--input-bg) !important; + color: var(--foreground) !important; + border-color: var(--input-border) !important; +} + +.react-datepicker { + background-color: var(--card-bg) !important; + border-color: var(--card-border) !important; + color: var(--foreground) !important; + font-family: inherit !important; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12) !important; +} + +.react-datepicker__header { + background-color: var(--table-header-bg) !important; + border-bottom-color: var(--card-border) !important; +} + +.react-datepicker__current-month, +.react-datepicker__day-name { + color: var(--foreground) !important; +} + +.react-datepicker__day { + color: var(--foreground) !important; + border-radius: 6px !important; +} + +.react-datepicker__day:hover { + background-color: var(--table-row-hover) !important; +} + +.react-datepicker__day--selected, +.react-datepicker__day--keyboard-selected { + background-color: #3b82f6 !important; + color: #fff !important; +} + +.react-datepicker__day--disabled { + color: var(--muted) !important; + opacity: 0.4; +} + +.react-datepicker__navigation-icon::before { + border-color: var(--muted) !important; +} + +.react-datepicker__triangle { + display: none !important; +} diff --git a/src/app/layout.js b/src/app/layout.js index bb0c64c..ae7d66a 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -1,4 +1,4 @@ -import { Geist, Geist_Mono } from "next/font/google"; +import { Geist } from "next/font/google"; import "./globals.css"; import { Analytics } from "@vercel/analytics/react"; import { SpeedInsights } from '@vercel/speed-insights/next'; @@ -8,21 +8,16 @@ const geistSans = Geist({ subsets: ["latin"], }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Exchange Rate Export", + description: "Fetch exchange rates from BIDV or Techcombank and export to CSV", }; export default function RootLayout({ children }) { return ( - + {children} diff --git a/src/app/page.js b/src/app/page.js index dfc33ed..4f7f4a4 100644 --- a/src/app/page.js +++ b/src/app/page.js @@ -1,28 +1,10 @@ -"use client"; -import React, { useEffect, useState } from "react"; import ExchangeRateViewer from "./components/ExchangeRateViewer"; -import { format, subDays } from "date-fns"; export default function Home() { - const [defaultStartDate, setDefaultStartDate] = useState(""); - const [defaultEndDate, setDefaultEndDate] = useState(""); - - useEffect(() => { - const today = new Date(); - const weekAgo = subDays(today, 7); - setDefaultStartDate(format(weekAgo, "yyyy-MM-dd")); - setDefaultEndDate(format(today, "yyyy-MM-dd")); - }, []); - return (
- {defaultStartDate && defaultEndDate && ( - - )} +
);