diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..11468b96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.DS_Store +coverage +*.tsbuildinfo +*.log diff --git a/README.md b/README.md index fccbe0ad..2455c549 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,48 @@ # Cash Register +Cash Register is a React + TypeScript app that calculates physical change for individual transactions or flat-file inputs. It supports configurable currencies, denominations, local transaction history, and a twist rule that can return random denominations when the owed amount matches the configured divisor. + +## Live Demo + +https://d3o90obztbl98t.cloudfront.net + +## Getting Started + +```sh +npm install +npm run dev +``` + +## Quality Checks + +```sh +npm run check +``` + +`npm run check` runs the TypeScript project build followed by the Vitest suite. Use `npm run test:watch` while developing focused changes. + +## Project Structure + +- `src/domain/changeRules.ts` contains the cash-register rule engine and pure change-calculation behavior. +- `src/domain/currency.ts` defines supported denomination data and money parsing/formatting for output strings. +- `src/domain/currencies.ts`, `src/domain/denominations.ts`, and `src/domain/transactions.ts` keep application business helpers out of the React component. +- `src/app/routes.ts` defines URL routes for the calculator, history, settings, denominations, and about pages. +- `src/app/storageKeys.ts` centralizes browser storage keys so persisted data contracts are explicit. +- `src/pages/` contains one component per routed page. +- `src/components/` contains reusable UI pieces shared across pages. +- `src/App.tsx` owns app-level state, storage synchronization, routing layout, and page callbacks. + +## Input Format + +Flat-file input expects one transaction per line: + +```txt +2.12,3.00 +1.97,2.00 +3.33,5.00 +``` +# Cash Register + ## The Problem Creative Cash Draw Solutions is a client who wants to provide something different for the cashiers who use their system. The function of the application is to tell the cashier how much change is owed, and what denominations should be used. In most cases the app should return the minimum amount of physical change, but the client would like to add a twist. If the "owed" amount is divisible by 3, the app should randomly generate the change denominations (but the math still needs to be right :)) diff --git a/index.html b/index.html new file mode 100644 index 00000000..8ae1a41a --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Cash Register + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..36670d0e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2487 @@ +{ + "name": "cash-register", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cash-register", + "version": "1.0.0", + "dependencies": { + "@vitejs/plugin-react": "^6.0.1", + "lucide-react": "^0.468.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.15.0", + "vite": "^8.0.10" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "jsdom": "^25.0.1", + "typescript": "^5.6.0", + "vitest": "^4.1.5" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-router": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", + "integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.0.tgz", + "integrity": "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..f9cdb719 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "cash-register", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -b", + "test": "vitest run", + "test:watch": "vitest", + "check": "npm run typecheck && npm run test", + "preview": "vite preview" + }, + "dependencies": { + "@vitejs/plugin-react": "^6.0.1", + "lucide-react": "^0.468.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.15.0", + "vite": "^8.0.10" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "jsdom": "^25.0.1", + "typescript": "^5.6.0", + "vitest": "^4.1.5" + } +} diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 00000000..1dfa925e --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,401 @@ +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { App } from './App'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function renderApp(initialEntries: string[] = ['/']) { + return render( + + + , + ); +} + +describe('App', () => { + it('calculates change from the transaction form', async () => { + const user = userEvent.setup(); + renderApp(); + + await user.clear(screen.getByLabelText('Amount Owed')); + await user.type(screen.getByLabelText('Amount Owed'), '2.12'); + await user.clear(screen.getByLabelText('Amount Paid')); + await user.type(screen.getByLabelText('Amount Paid'), '3.00'); + await user.click(screen.getByRole('button', { name: /calculate change/i })); + + expect(screen.getAllByText('$0.88').length).toBeGreaterThan(0); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('quarters'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('dime'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('pennies'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('Total'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('$0.75'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('$0.10'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('$0.03'); + }); + + it('shows validation errors', async () => { + const user = userEvent.setup(); + renderApp(); + + await user.clear(screen.getByLabelText('Amount Owed')); + await user.type(screen.getByLabelText('Amount Owed'), '4.00'); + await user.clear(screen.getByLabelText('Amount Paid')); + await user.type(screen.getByLabelText('Amount Paid'), '3.00'); + await user.click(screen.getByRole('button', { name: /calculate change/i })); + + expect(screen.getByRole('alert')).toHaveTextContent( + 'Line 1: amount paid is less than amount owed', + ); + }); + + it('generates a valid random sample transaction', async () => { + const user = userEvent.setup(); + vi.spyOn(Math, 'random') + .mockReturnValueOnce(0.456) + .mockReturnValueOnce(0.75); + renderApp(); + + await user.click(screen.getByRole('button', { name: /sample/i })); + + expect(screen.getByLabelText('Amount Owed')).toHaveValue('5.10'); + expect(screen.getByLabelText('Amount Paid')).toHaveValue('9.00'); + expect(screen.getAllByText('$3.90').length).toBeGreaterThan(0); + expect(screen.getByRole('link', { name: /download sample input/i })).toHaveAttribute( + 'download', + 'sample-input.txt', + ); + }); + + it('regenerates a random transaction from the history badge', async () => { + const user = userEvent.setup(); + vi.spyOn(Math, 'random').mockReturnValue(0.99); + renderApp(); + + await user.type(screen.getByLabelText('Amount Owed'), '3.33'); + await user.type(screen.getByLabelText('Amount Paid'), '5.00'); + await user.click(screen.getByRole('button', { name: /calculate change/i })); + + expect(screen.getByText('167 pennies')).toBeInTheDocument(); + + vi.mocked(Math.random).mockReturnValue(0); + await user.click(screen.getByRole('button', { name: /^random$/i })); + + expect(screen.getByText('1 dollar bill,2 quarters,1 dime,1 nickel,2 pennies')).toBeInTheDocument(); + }); + + it('regenerates the main change breakdown from the random denominations badge', async () => { + const user = userEvent.setup(); + vi.spyOn(Math, 'random').mockReturnValue(0.99); + renderApp(); + + await user.type(screen.getByLabelText('Amount Owed'), '3.33'); + await user.type(screen.getByLabelText('Amount Paid'), '5.00'); + await user.click(screen.getByRole('button', { name: /calculate change/i })); + + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('pennies'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('167'); + + vi.mocked(Math.random).mockReturnValue(0); + await user.click(screen.getByRole('button', { name: /random denominations/i })); + + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('dollar bill'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('quarters'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('dime'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('nickel'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('pennies'); + }); + + it('stores transactions in local storage and shows them on the history page', async () => { + const user = userEvent.setup(); + renderApp(); + + await user.type(screen.getByLabelText('Amount Owed'), '2.12'); + await user.type(screen.getByLabelText('Amount Paid'), '3.00'); + await user.click(screen.getByRole('button', { name: /calculate change/i })); + + await waitFor(() => { + const rawHistory = localStorage.getItem('cash-register:transaction-history'); + expect(rawHistory).not.toBeNull(); + expect(JSON.parse(rawHistory ?? '[]')).toHaveLength(1); + }); + + await user.click(screen.getByRole('link', { name: /^history$/i })); + + expect(screen.getByRole('heading', { name: /transaction history/i })).toBeInTheDocument(); + expect(screen.getByLabelText('All transactions')).toHaveTextContent('$2.12'); + expect(screen.getByLabelText('All transactions')).toHaveTextContent('$3.00'); + expect(screen.getByLabelText('All transactions')).toHaveTextContent('3 quarters,1 dime,3 pennies'); + }); + + it('shows a recent transaction detail in the change panel when View is clicked', async () => { + const user = userEvent.setup(); + renderApp(); + + await user.type(screen.getByLabelText('Amount Owed'), '2.12'); + await user.type(screen.getByLabelText('Amount Paid'), '3.00'); + await user.click(screen.getByRole('button', { name: /calculate change/i })); + + await user.clear(screen.getByLabelText('Amount Owed')); + await user.type(screen.getByLabelText('Amount Owed'), '1.00'); + await user.clear(screen.getByLabelText('Amount Paid')); + await user.type(screen.getByLabelText('Amount Paid'), '2.00'); + await user.click(screen.getByRole('button', { name: /calculate change/i })); + + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('dollar bill'); + + await user.click(screen.getAllByRole('button', { name: /^view$/i })[1]); + + expect(screen.getByLabelText('Amount Owed')).toHaveValue('2.12'); + expect(screen.getByLabelText('Amount Paid')).toHaveValue('3.00'); + expect(screen.getAllByText('$0.88').length).toBeGreaterThan(0); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('quarters'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('dime'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('pennies'); + }); + + it('uses the settings trick number for the twist rule', async () => { + const user = userEvent.setup(); + vi.spyOn(Math, 'random').mockReturnValue(0.99); + renderApp(); + + await user.click(screen.getByRole('link', { name: /^settings$/i })); + await user.clear(screen.getByLabelText('Trick Number')); + await user.type(screen.getByLabelText('Trick Number'), '4'); + await user.click(screen.getByRole('button', { name: /save settings/i })); + + expect(screen.getByText('Settings saved.')).toBeInTheDocument(); + expect(localStorage.getItem('cash-register:trick-number')).toBe('4'); + + await user.click(screen.getByRole('link', { name: /^calculator$/i })); + await user.type(screen.getByLabelText('Amount Owed'), '2.12'); + await user.type(screen.getByLabelText('Amount Paid'), '3.00'); + await user.click(screen.getByRole('button', { name: /calculate change/i })); + + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('pennies'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('88'); + }); + + it('resets software state and clears local storage from settings', async () => { + const user = userEvent.setup(); + renderApp(); + + await user.type(screen.getByLabelText('Amount Owed'), '2.12'); + await user.type(screen.getByLabelText('Amount Paid'), '3.00'); + await user.click(screen.getByRole('button', { name: /calculate change/i })); + await user.click(screen.getByRole('link', { name: /^settings$/i })); + await user.clear(screen.getByLabelText('Trick Number')); + await user.type(screen.getByLabelText('Trick Number'), '4'); + await user.click(screen.getByRole('button', { name: /save settings/i })); + + expect(localStorage.getItem('cash-register:trick-number')).toBe('4'); + expect(localStorage.getItem('cash-register:transaction-history')).not.toBeNull(); + + await user.click(screen.getByRole('button', { name: /reset software/i })); + + expect(screen.getByText('Software reset.')).toBeInTheDocument(); + expect(screen.getByLabelText('Trick Number')).toHaveValue(3); + await waitFor(() => { + expect(localStorage.length).toBe(0); + }); + + await user.click(screen.getByRole('link', { name: /^calculator$/i })); + + expect(screen.getByLabelText('Amount Owed')).toHaveValue(''); + expect(screen.getByLabelText('Amount Paid')).toHaveValue(''); + expect(screen.getByText('No transaction calculated yet.')).toBeInTheDocument(); + }); + + it('converts a non-USD amount paid before calculating change', async () => { + const user = userEvent.setup(); + renderApp(); + + await user.click(screen.getByRole('link', { name: /^settings$/i })); + await user.clear(screen.getByLabelText('EUR USD Equivalent')); + await user.type(screen.getByLabelText('EUR USD Equivalent'), '2.00'); + await user.click(screen.getByRole('button', { name: /save settings/i })); + + await user.click(screen.getByRole('link', { name: /^calculator$/i })); + await user.type(screen.getByLabelText('Amount Owed'), '2.12'); + await user.type(screen.getByLabelText('Amount Paid'), '1.50'); + await user.selectOptions(screen.getByLabelText('Amount Paid Currency'), 'EUR'); + + expect(screen.getByText('Equivalent in USD: $3.00')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /calculate change/i })); + + expect(screen.getAllByText('$0.88').length).toBeGreaterThan(0); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('quarters'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('dime'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('pennies'); + }); + + it('uses the selected base currency for equivalents and change calculations', async () => { + const user = userEvent.setup(); + renderApp(); + + await user.click(screen.getByRole('link', { name: /^settings$/i })); + await user.selectOptions(screen.getByLabelText('Base Currency'), 'EUR'); + + expect(screen.getByLabelText('EUR EUR Equivalent')).toHaveValue(1); + + await user.click(screen.getByRole('button', { name: /save settings/i })); + + await waitFor(() => { + expect(localStorage.getItem('cash-register:base-currency')).toBe('EUR'); + }); + + await user.click(screen.getByRole('link', { name: /^calculator$/i })); + await user.type(screen.getByLabelText('Amount Owed'), '2.12'); + await user.type(screen.getByLabelText('Amount Paid'), '3.00'); + await user.selectOptions(screen.getByLabelText('Amount Paid Currency'), 'USD'); + + expect(screen.getByText('Equivalent in EUR: €2.78')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /calculate change/i })); + + expect(screen.getAllByText('€0.66').length).toBeGreaterThan(0); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('quarters'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('dime'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('nickel'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('penny'); + }); + + it('applies the euro denominations preset and switches the base currency', async () => { + const user = userEvent.setup(); + renderApp(); + + await user.click(screen.getByRole('link', { name: /^settings$/i })); + await user.click(screen.getByRole('button', { name: /edit denominations/i })); + await user.click(screen.getByRole('button', { name: /reset euro defaults/i })); + + expect(screen.getByText('Euro denominations restored.')).toBeInTheDocument(); + + await waitFor(() => { + expect(localStorage.getItem('cash-register:base-currency')).toBe('EUR'); + }); + + const savedDenominations = JSON.parse( + localStorage.getItem('cash-register:denominations') ?? '[]', + ); + expect(savedDenominations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ cents: 50, singular: 'fifty-cent coin' }), + expect.objectContaining({ cents: 2, singular: 'two-cent coin' }), + ]), + ); + + await user.click(screen.getByRole('link', { name: /^calculator$/i })); + await user.type(screen.getByLabelText('Amount Owed'), '2.12'); + await user.type(screen.getByLabelText('Amount Paid'), '3.00'); + await user.click(screen.getByRole('button', { name: /calculate change/i })); + + expect(screen.getAllByText('€0.88').length).toBeGreaterThan(0); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('fifty-cent coin'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('twenty-cent coin'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('two-cent coin'); + expect(screen.getByLabelText('Change denomination breakdown')).not.toHaveTextContent('quarters'); + }); + + it('excludes denominations disabled from settings and shows a change shortfall', async () => { + const user = userEvent.setup(); + renderApp(); + + await user.click(screen.getByRole('link', { name: /^settings$/i })); + await user.click(screen.getByLabelText('pennies enabled')); + + await user.click(screen.getByRole('link', { name: /^calculator$/i })); + await user.type(screen.getByLabelText('Amount Owed'), '2.12'); + await user.type(screen.getByLabelText('Amount Paid'), '3.00'); + await user.click(screen.getByRole('button', { name: /calculate change/i })); + + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('quarters'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('dime'); + expect(screen.getByLabelText('Change denomination breakdown')).not.toHaveTextContent('pennies'); + expect(screen.getByText(/missing \$0\.03/i)).toBeInTheDocument(); + expect(screen.getByText('$0.85')).toBeInTheDocument(); + }); + + it('adds a custom denomination with editable token styling', async () => { + const user = userEvent.setup(); + const { container } = renderApp(); + + await user.click(screen.getByRole('link', { name: /^settings$/i })); + await user.click(screen.getByRole('button', { name: /edit denominations/i })); + await user.click(screen.getByRole('button', { name: /^add$/i })); + + const singularInputs = screen.getAllByLabelText(/singular name/i); + const pluralInputs = screen.getAllByLabelText(/plural name/i); + const labelInputs = screen.getAllByLabelText(/token label/i); + const primaryColorInputs = screen.getAllByLabelText(/primary color/i); + + await user.clear(singularInputs[singularInputs.length - 1]); + await user.type(singularInputs[singularInputs.length - 1], 'duo coin'); + await user.clear(pluralInputs[pluralInputs.length - 1]); + await user.type(pluralInputs[pluralInputs.length - 1], 'duo coins'); + await user.clear(labelInputs[labelInputs.length - 1]); + await user.type(labelInputs[labelInputs.length - 1], '2x'); + fireEvent.change(primaryColorInputs[primaryColorInputs.length - 1], { + target: { value: '#123456' }, + }); + + await user.click(screen.getByRole('button', { name: /save denominations/i })); + + const savedDenominations = JSON.parse( + localStorage.getItem('cash-register:denominations') ?? '[]', + ); + expect(savedDenominations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + cents: 2, + singular: 'duo coin', + plural: 'duo coins', + visual: expect.objectContaining({ + label: '2x', + primaryColor: '#123456', + }), + }), + ]), + ); + + await user.click(screen.getByRole('link', { name: /^calculator$/i })); + await user.type(screen.getByLabelText('Amount Owed'), '0.98'); + await user.type(screen.getByLabelText('Amount Paid'), '1.00'); + await user.click(screen.getByRole('button', { name: /calculate change/i })); + + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('duo coin'); + expect(screen.getByLabelText('Change denomination breakdown')).toHaveTextContent('2c'); + expect(container.querySelector('.denomination-type .coin-svg')).toHaveStyle( + '--token-primary: #123456', + ); + }); + + it('opens the about page from navigation', async () => { + const user = userEvent.setup(); + renderApp(); + + const aboutLink = screen.getByRole('link', { name: /^about$/i }); + expect(aboutLink).toHaveAttribute('href', '/about'); + + await user.click(aboutLink); + + expect(screen.getByRole('heading', { name: /about cash register/i })).toBeInTheDocument(); + expect(screen.getByLabelText('About Cash Register')).toHaveTextContent('Functionality'); + expect(screen.getByLabelText('Code organization summary')).toHaveTextContent('src/pages'); + expect(screen.getByLabelText('About Cash Register')).toHaveTextContent('Testing'); + }); + + it('renders a page directly from its URL route', () => { + renderApp(['/history']); + + expect(screen.getByRole('heading', { name: /transaction history/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /^history$/i })).toHaveAttribute( + 'aria-current', + 'page', + ); + }); +}); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 00000000..5e9d6ede --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,885 @@ +import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { NavLink, useLocation, useNavigate } from 'react-router-dom'; +import { BadgeDollarSign, Calculator, Clock3, Info, Settings } from 'lucide-react'; +import { appRoutes, getPageFromPath, isAppRoute } from './app/routes'; +import type { Page } from './app/routes'; +import { storageKeys } from './app/storageKeys'; +import { ChangeResult, calculateChangeForLine, processRegisterFile } from './domain/changeRules'; +import { Denomination, parseMoneyToCents } from './domain/currency'; +import { + CurrencyCode, + CurrencyOption, + DEFAULT_BASE_CURRENCY_CODE as defaultBaseCurrencyCode, + DEFAULT_CURRENCIES as defaultCurrencies, + EURO_CURRENCY_CODE as euroCurrencyCode, + NewCurrencyDraft, + USD_CURRENCY_CODE as usdCurrencyCode, + buildCurrencyRateInputs, + convertCurrencyCents, + formatMoneyInput, + formatRateInput, + getCurrencyByCode, + getDefaultBaseCurrencyCode, + isCurrencyCode, + isStoredCustomCurrency, + parseCurrencyRateInput, +} from './domain/currencies'; +import { + DenominationAvailability, + DenominationDraft, + createDefaultDenominationAvailability, + createDenominationAvailability, + createDenominationDraft, + createDenominationDrafts, + findNextDraftCentValue, + getDefaultDenominations, + getDefaultVisual, + getEuroDenominations, + normalizeDenomination, + parseDenominationDrafts, + parseStoredDenominations, + sortDenominations, +} from './domain/denominations'; +import { + ProcessedState, + StoredTransaction, + getTransactionCurrency, + isStoredTransaction, + storeTransaction, + withChangeTotals, +} from './domain/transactions'; +import { AboutPage } from './pages/AboutPage'; +import { CalculatorPage } from './pages/CalculatorPage'; +import { DenominationsPage } from './pages/DenominationsPage'; +import { HistoryPage } from './pages/HistoryPage'; +import { SettingsPage } from './pages/SettingsPage'; + +const defaultTrickNumber = 3; + +function loadDenominations(): Denomination[] { + try { + const rawValue = localStorage.getItem(storageKeys.denominations); + const parsedValue: unknown = rawValue ? JSON.parse(rawValue) : null; + return parseStoredDenominations(parsedValue); + } catch { + return getDefaultDenominations(); + } +} + +function randomIntegerBetween(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function parseFirstLine(input: string): { owed: string; paid: string } | null { + const firstLine = input + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0); + + if (!firstLine) return null; + + const [owed, paid] = firstLine.split(','); + return owed && paid ? { owed: owed.trim(), paid: paid.trim() } : null; +} + +function loadTransactionHistory(): StoredTransaction[] { + try { + const rawHistory = localStorage.getItem(storageKeys.transactionHistory); + if (!rawHistory) return []; + + const parsedHistory: unknown = JSON.parse(rawHistory); + return Array.isArray(parsedHistory) + ? parsedHistory.filter(isStoredTransaction).map(withChangeTotals) + : []; + } catch { + return []; + } +} + +function loadDenominationAvailability(denominations: Denomination[]): DenominationAvailability { + try { + const rawValue = localStorage.getItem(storageKeys.denominationAvailability); + const parsedValue: unknown = rawValue ? JSON.parse(rawValue) : null; + return createDenominationAvailability(denominations, parsedValue); + } catch { + return createDefaultDenominationAvailability(denominations); + } +} + +function loadTrickNumber(): number { + const rawValue = localStorage.getItem(storageKeys.trickNumber); + const parsedValue = rawValue ? Number(rawValue) : defaultTrickNumber; + + return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : defaultTrickNumber; +} + +function loadCurrencies(): CurrencyOption[] { + try { + const rawValue = localStorage.getItem(storageKeys.currencies); + const parsedValue: unknown = rawValue ? JSON.parse(rawValue) : null; + const savedCurrencies = Array.isArray(parsedValue) ? parsedValue : []; + + const base = defaultCurrencies.map((defaultCurrency) => { + const savedCurrency = savedCurrencies.find( + (currency) => + currency && + typeof currency === 'object' && + 'code' in currency && + currency.code === defaultCurrency.code, + ) as Partial | undefined; + const savedRate = Number(savedCurrency?.usdRate); + const usdRate = + defaultCurrency.editable && Number.isFinite(savedRate) && savedRate > 0 + ? savedRate + : defaultCurrency.usdRate; + + return { ...defaultCurrency, usdRate }; + }); + + const custom = savedCurrencies.filter(isStoredCustomCurrency); + + return [...base, ...custom]; + } catch { + return defaultCurrencies; + } +} + +function loadBaseCurrencyCode(currencies: CurrencyOption[]): CurrencyCode { + const storedCode = localStorage.getItem(storageKeys.baseCurrency); + + return storedCode && isCurrencyCode(storedCode, currencies) + ? storedCode + : getDefaultBaseCurrencyCode(currencies); +} + +export function App() { + const location = useLocation(); + const navigate = useNavigate(); + const initialTrickNumber = loadTrickNumber(); + const initialCurrencies = loadCurrencies(); + const initialBaseCurrencyCode = loadBaseCurrencyCode(initialCurrencies); + const initialBaseCurrency = + getCurrencyByCode(initialCurrencies, initialBaseCurrencyCode) ?? defaultCurrencies[0]; + const initialDenominations = loadDenominations(); + const initialDenominationAvailability = loadDenominationAvailability(initialDenominations); + const skipNextStorageSyncRef = useRef(false); + const activePage = getPageFromPath(location.pathname); + const [transactionHistory, setTransactionHistory] = useState(() => + loadTransactionHistory(), + ); + const [trickNumber, setTrickNumber] = useState(initialTrickNumber); + const [trickNumberInput, setTrickNumberInput] = useState(() => String(initialTrickNumber)); + const [currencies, setCurrencies] = useState(initialCurrencies); + const [baseCurrencyCode, setBaseCurrencyCode] = useState(initialBaseCurrencyCode); + const [newCurrencyDraft, setNewCurrencyDraft] = useState(null); + const [currencyRateInputs, setCurrencyRateInputs] = useState>(() => + buildCurrencyRateInputs(initialCurrencies, initialBaseCurrency), + ); + const [amountPaidCurrency, setAmountPaidCurrency] = + useState(initialBaseCurrencyCode); + const [denominations, setDenominations] = useState(initialDenominations); + const [denominationDrafts, setDenominationDrafts] = useState(() => + createDenominationDrafts(initialDenominations), + ); + const [denominationAvailability, setDenominationAvailability] = useState( + initialDenominationAvailability, + ); + const [settingsMessage, setSettingsMessage] = useState(''); + const [amountOwed, setAmountOwed] = useState(''); + const [amountPaid, setAmountPaid] = useState(''); + const [processed, setProcessed] = useState({ + status: 'idle', + results: transactionHistory.slice(0, 8), + error: null, + }); + + const currentResult = processed.status === 'error' ? null : processed.results[0] ?? null; + const output = useMemo( + () => transactionHistory.map((result) => result.output).join('\n'), + [transactionHistory], + ); + const totalProcessed = transactionHistory.length; + const randomCount = transactionHistory.filter((result) => result.strategy === 'random').length; + const minimumCount = transactionHistory.filter((result) => result.strategy === 'minimum').length; + const resultStrategyLabel = + currentResult?.strategy === 'random' ? 'Random denominations' : 'Normal (minimum change)'; + const selectedBaseCurrency = + getCurrencyByCode(currencies, baseCurrencyCode) ?? defaultCurrencies[0]; + const selectedPaidCurrency = + getCurrencyByCode(currencies, amountPaidCurrency) ?? selectedBaseCurrency; + const displayedResultCurrency = currentResult + ? getTransactionCurrency(currentResult) + : selectedBaseCurrency; + const amountPaidBaseEquivalent = useMemo(() => { + try { + const paidCents = parseMoneyToCents(amountPaid); + return convertCurrencyCents(paidCents, selectedPaidCurrency, selectedBaseCurrency); + } catch { + return null; + } + }, [amountPaid, selectedBaseCurrency, selectedPaidCurrency]); + const activeDenominations = useMemo( + () => + sortDenominations( + denominations.filter((denomination) => denominationAvailability[denomination.cents]), + ), + [denominations, denominationAvailability], + ); + const activeDenominationCount = activeDenominations.length; + + useEffect(() => { + if (!isAppRoute(location.pathname)) { + navigate(appRoutes.calculator, { replace: true }); + } + }, [location.pathname, navigate]); + + useEffect(() => { + if (skipNextStorageSyncRef.current) return; + + localStorage.setItem(storageKeys.transactionHistory, JSON.stringify(transactionHistory)); + }, [transactionHistory]); + + useEffect(() => { + if (skipNextStorageSyncRef.current) return; + + localStorage.setItem(storageKeys.trickNumber, String(trickNumber)); + }, [trickNumber]); + + useEffect(() => { + if (skipNextStorageSyncRef.current) return; + + localStorage.setItem(storageKeys.currencies, JSON.stringify(currencies)); + }, [currencies]); + + useEffect(() => { + if (skipNextStorageSyncRef.current) return; + + localStorage.setItem(storageKeys.baseCurrency, baseCurrencyCode); + }, [baseCurrencyCode]); + + useEffect(() => { + if (skipNextStorageSyncRef.current) return; + + localStorage.setItem(storageKeys.denominations, JSON.stringify(denominations)); + }, [denominations]); + + useEffect(() => { + if (skipNextStorageSyncRef.current) return; + + localStorage.setItem( + storageKeys.denominationAvailability, + JSON.stringify(denominationAvailability), + ); + }, [denominationAvailability]); + + useEffect(() => { + if (skipNextStorageSyncRef.current) { + skipNextStorageSyncRef.current = false; + } + }); + + function setActivePage(page: Page) { + navigate(appRoutes[page]); + } + + function saveResults( + results: ChangeResult[], + currency: Pick = selectedBaseCurrency, + ) { + const storedResults = results.map((result) => storeTransaction(result, currency)); + + setTransactionHistory((current) => [...storedResults, ...current]); + setProcessed({ + status: 'ready', + results: storedResults, + error: null, + }); + } + + function markIdle() { + setProcessed((current) => ({ + status: 'idle', + results: current.results, + error: null, + })); + } + + function updateAmountOwed(value: string) { + setAmountOwed(value); + markIdle(); + } + + function updateAmountPaid(value: string) { + setAmountPaid(value); + markIdle(); + } + + function updateAmountPaidCurrency(nextCurrencyCode: CurrencyCode) { + if (!isCurrencyCode(nextCurrencyCode, currencies)) return; + + setAmountPaidCurrency(nextCurrencyCode); + markIdle(); + } + + function updateTrickNumberInput(value: string) { + setTrickNumberInput(value); + setSettingsMessage(''); + } + + function updateCurrencyRateInput(currencyCode: CurrencyCode, value: string) { + setCurrencyRateInputs((current) => ({ + ...current, + [currencyCode]: value, + })); + setSettingsMessage(''); + } + + function updateNewCurrencyDraft(updates: Partial) { + setNewCurrencyDraft((draft) => (draft ? { ...draft, ...updates } : draft)); + } + + function startNewCurrencyDraft() { + setNewCurrencyDraft({ code: '', label: '', symbol: '', usdRate: '1.00' }); + } + + function cancelNewCurrencyDraft() { + setNewCurrencyDraft(null); + setSettingsMessage(''); + } + + function runRegister() { + try { + const paidAmountBase = + amountPaidCurrency === baseCurrencyCode + ? amountPaid + : formatMoneyInput(convertAmountPaidToBaseCents()); + const result = calculateChangeForLine(`${amountOwed},${paidAmountBase}`, 1, { + denominations: activeDenominations, + randomDivisor: trickNumber, + }); + saveResults([result], selectedBaseCurrency); + } catch (error) { + setProcessed({ + status: 'error', + results: transactionHistory.slice(0, 8), + error: error instanceof Error ? error.message : 'Unexpected processing error', + }); + } + } + + async function readFlatFile(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + + const contents = await file.text(); + + try { + const results = processRegisterFile(contents, { + denominations: activeDenominations, + randomDivisor: trickNumber, + }); + const firstLine = parseFirstLine(contents); + if (firstLine) { + setAmountOwed(firstLine.owed); + setAmountPaid(firstLine.paid); + setAmountPaidCurrency(baseCurrencyCode); + } + saveResults(results, selectedBaseCurrency); + } catch (error) { + setProcessed({ + status: 'error', + results: transactionHistory.slice(0, 8), + error: error instanceof Error ? error.message : 'Unexpected processing error', + }); + } finally { + event.target.value = ''; + } + } + + function generateSampleTransaction() { + const owedCents = randomIntegerBetween(100, 999); + const minimumPaidDollars = Math.floor(owedCents / 100) + 1; + const paidCents = randomIntegerBetween(minimumPaidDollars, 10) * 100; + const nextAmountOwed = formatMoneyInput(owedCents); + const nextAmountPaid = formatMoneyInput(paidCents); + + setAmountOwed(nextAmountOwed); + setAmountPaid(nextAmountPaid); + setAmountPaidCurrency(baseCurrencyCode); + + try { + const result = calculateChangeForLine(`${nextAmountOwed},${nextAmountPaid}`, 1, { + denominations: activeDenominations, + randomDivisor: trickNumber, + }); + saveResults([result], selectedBaseCurrency); + } catch (error) { + setProcessed({ + status: 'error', + results: transactionHistory.slice(0, 8), + error: error instanceof Error ? error.message : 'Unexpected processing error', + }); + } + } + + function rerollRandomTransaction(transactionId: string) { + const original = transactionHistory.find((transaction) => transaction.id === transactionId); + if (!original || original.strategy !== 'random') return; + + const nextResult = calculateChangeForLine( + `${formatMoneyInput(original.amountOwedCents)},${formatMoneyInput(original.amountPaidCents)}`, + original.lineNumber, + { denominations: activeDenominations, randomDivisor: trickNumber }, + ); + const replacement: StoredTransaction = { + ...nextResult, + id: original.id, + createdAt: original.createdAt, + currencyCode: original.currencyCode ?? defaultBaseCurrencyCode, + currencySymbol: original.currencySymbol ?? defaultCurrencies[0].symbol, + }; + + setTransactionHistory((current) => + current.map((transaction) => (transaction.id === transactionId ? replacement : transaction)), + ); + + setProcessed((current) => { + if (current.status === 'error') return current; + + return { + status: 'ready', + results: current.results.map((result) => (result.id === transactionId ? replacement : result)), + error: null, + }; + }); + } + + function clearHistory() { + setTransactionHistory([]); + setProcessed({ + status: 'idle', + results: [], + error: null, + }); + } + + function viewTransactionDetails(transaction: StoredTransaction) { + setAmountOwed(formatMoneyInput(transaction.amountOwedCents)); + setAmountPaid(formatMoneyInput(transaction.amountPaidCents)); + setAmountPaidCurrency( + transaction.currencyCode && isCurrencyCode(transaction.currencyCode, currencies) + ? transaction.currencyCode + : baseCurrencyCode, + ); + setProcessed({ + status: 'ready', + results: [transaction], + error: null, + }); + setActivePage('calculator'); + } + + function saveSettings() { + const nextTrickNumber = Number(trickNumberInput); + + if (!Number.isInteger(nextTrickNumber) || nextTrickNumber <= 0) { + setSettingsMessage('Enter a whole number greater than 0.'); + return; + } + + try { + const nextBaseCurrencyCode = isCurrencyCode(baseCurrencyCode, currencies) + ? baseCurrencyCode + : getDefaultBaseCurrencyCode(currencies); + const nextBaseUsdRate = + nextBaseCurrencyCode === usdCurrencyCode + ? 1 + : 1 / parseCurrencyRateInput(currencyRateInputs[usdCurrencyCode]); + const nextCurrencies = currencies.map((currency) => { + if (currency.code === usdCurrencyCode) { + return { ...currency, usdRate: 1 }; + } + + if (currency.code === nextBaseCurrencyCode) { + return { ...currency, usdRate: nextBaseUsdRate }; + } + + return { + ...currency, + usdRate: parseCurrencyRateInput(currencyRateInputs[currency.code]) * nextBaseUsdRate, + }; + }); + const nextBaseCurrency = + getCurrencyByCode(nextCurrencies, nextBaseCurrencyCode) ?? defaultCurrencies[0]; + const nextDenominations = parseDenominationDrafts(denominationDrafts, nextBaseCurrency); + + setTrickNumber(nextTrickNumber); + setTrickNumberInput(String(nextTrickNumber)); + setCurrencies(nextCurrencies); + setBaseCurrencyCode(nextBaseCurrencyCode); + setCurrencyRateInputs(buildCurrencyRateInputs(nextCurrencies, nextBaseCurrency)); + setDenominations(nextDenominations); + setDenominationDrafts(createDenominationDrafts(nextDenominations)); + setDenominationAvailability((current) => + nextDenominations.reduce( + (availability, denomination) => ({ + ...availability, + [denomination.cents]: current[denomination.cents] ?? true, + }), + {} as DenominationAvailability, + ), + ); + setSettingsMessage('Settings saved.'); + } catch (error) { + setSettingsMessage(error instanceof Error ? error.message : 'Unable to save settings.'); + } + } + + function convertAmountPaidToBaseCents(): number { + const paidCents = parseMoneyToCents(amountPaid); + return convertCurrencyCents(paidCents, selectedPaidCurrency, selectedBaseCurrency); + } + + function changeBaseCurrency(nextBaseCurrencyCode: CurrencyCode) { + const nextBaseCurrency = getCurrencyByCode(currencies, nextBaseCurrencyCode); + if (!nextBaseCurrency) return; + + setBaseCurrencyCode(nextBaseCurrencyCode); + setCurrencyRateInputs(buildCurrencyRateInputs(currencies, nextBaseCurrency)); + setAmountPaidCurrency((current) => + current === baseCurrencyCode ? nextBaseCurrencyCode : current, + ); + setSettingsMessage(''); + markIdle(); + } + + function setDenominationAvailable(denomination: Denomination, isAvailable: boolean) { + setDenominationAvailability((current) => ({ + ...current, + [denomination.cents]: isAvailable, + })); + setSettingsMessage(''); + } + + function updateDenominationDraft(index: number, updates: Partial) { + setDenominationDrafts((current) => + current.map((draft, draftIndex) => + draftIndex === index + ? { + ...draft, + ...updates, + } + : draft, + ), + ); + setSettingsMessage(''); + } + + function addDenominationDraft() { + setDenominationDrafts((current) => { + const cents = findNextDraftCentValue(current); + const visualKind = cents >= 100 ? 'bill' : 'coin'; + const draft = createDenominationDraft( + normalizeDenomination({ + singular: visualKind === 'bill' ? 'custom bill' : 'custom coin', + plural: visualKind === 'bill' ? 'custom bills' : 'custom coins', + cents, + visual: getDefaultVisual(cents, visualKind), + }), + ); + + return [...current, draft]; + }); + setSettingsMessage(''); + } + + function addNewCurrency() { + if (!newCurrencyDraft) return; + + const code = newCurrencyDraft.code.trim().toUpperCase(); + const label = newCurrencyDraft.label.trim(); + const symbol = newCurrencyDraft.symbol.trim(); + const baseRate = Number(newCurrencyDraft.usdRate); + + if (!code || !/^[A-Z]{1,4}$/.test(code)) { + setSettingsMessage('Enter a valid currency code (1–4 letters).'); + return; + } + if (!label) { + setSettingsMessage('Enter a currency label.'); + return; + } + if (!symbol) { + setSettingsMessage('Enter a currency symbol.'); + return; + } + if (!Number.isFinite(baseRate) || baseRate <= 0) { + setSettingsMessage('Enter a currency equivalent greater than 0.'); + return; + } + if (currencies.some((currency) => currency.code === code)) { + setSettingsMessage(`Currency code ${code} already exists.`); + return; + } + + const newCurrency: CurrencyOption = { + code, + label, + symbol, + usdRate: baseRate * selectedBaseCurrency.usdRate, + editable: true, + custom: true, + }; + setCurrencies((current) => [...current, newCurrency]); + setCurrencyRateInputs((current) => ({ ...current, [code]: formatRateInput(baseRate) })); + setNewCurrencyDraft(null); + setSettingsMessage(''); + } + + function removeCustomCurrency(code: string) { + const nextCurrencies = currencies.filter((currency) => currency.code !== code); + const nextBaseCurrencyCode = + baseCurrencyCode === code ? getDefaultBaseCurrencyCode(nextCurrencies) : baseCurrencyCode; + const nextBaseCurrency = + getCurrencyByCode(nextCurrencies, nextBaseCurrencyCode) ?? defaultCurrencies[0]; + + setCurrencies(nextCurrencies); + setBaseCurrencyCode(nextBaseCurrencyCode); + setCurrencyRateInputs(buildCurrencyRateInputs(nextCurrencies, nextBaseCurrency)); + if (amountPaidCurrency === code) { + setAmountPaidCurrency(nextBaseCurrencyCode); + } + setSettingsMessage(''); + } + + function resetSoftware() { + const nextCurrencies = defaultCurrencies; + const nextBaseCurrencyCode = defaultBaseCurrencyCode; + const nextBaseCurrency = + getCurrencyByCode(nextCurrencies, nextBaseCurrencyCode) ?? defaultCurrencies[0]; + const nextDenominations = getDefaultDenominations(); + + skipNextStorageSyncRef.current = true; + localStorage.clear(); + setTransactionHistory([]); + setTrickNumber(defaultTrickNumber); + setTrickNumberInput(String(defaultTrickNumber)); + setCurrencies(nextCurrencies); + setBaseCurrencyCode(nextBaseCurrencyCode); + setNewCurrencyDraft(null); + setCurrencyRateInputs(buildCurrencyRateInputs(nextCurrencies, nextBaseCurrency)); + setAmountPaidCurrency(nextBaseCurrencyCode); + setDenominations(nextDenominations); + setDenominationDrafts(createDenominationDrafts(nextDenominations)); + setDenominationAvailability(createDefaultDenominationAvailability(nextDenominations)); + setAmountOwed(''); + setAmountPaid(''); + setProcessed({ + status: 'idle', + results: [], + error: null, + }); + setSettingsMessage('Software reset.'); + } + + function removeDenominationDraft(index: number) { + setDenominationDrafts((current) => current.filter((_, draftIndex) => draftIndex !== index)); + setSettingsMessage(''); + } + + function applyDenominationPreset( + nextDenominations: Denomination[], + nextBaseCurrencyCode: CurrencyCode, + message: string, + ) { + const nextBaseCurrency = getCurrencyByCode(currencies, nextBaseCurrencyCode); + if (!nextBaseCurrency) return; + + setBaseCurrencyCode(nextBaseCurrencyCode); + setCurrencyRateInputs(buildCurrencyRateInputs(currencies, nextBaseCurrency)); + setAmountPaidCurrency((current) => + current === baseCurrencyCode ? nextBaseCurrencyCode : current, + ); + setDenominations(nextDenominations); + setDenominationDrafts(createDenominationDrafts(nextDenominations)); + setDenominationAvailability(createDefaultDenominationAvailability(nextDenominations)); + setSettingsMessage(message); + markIdle(); + } + + function resetDenominationsToDefault() { + applyDenominationPreset(getDefaultDenominations(), usdCurrencyCode, 'US denominations restored.'); + } + + function resetDenominationsToEuro() { + applyDenominationPreset(getEuroDenominations(), euroCurrencyCode, 'Euro denominations restored.'); + } + + function saveDenominations() { + saveSettings(); + setActivePage('settings'); + } + + function downloadOutput() { + const blob = new Blob([output], { type: 'text/plain;charset=utf-8' }); + const href = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = href; + link.download = 'cash-register-output.txt'; + link.click(); + URL.revokeObjectURL(href); + } + + function renderActivePage() { + if (activePage === 'history') { + return ( + + ); + } + + if (activePage === 'settings') { + return ( + setActivePage('denominations')} + onNewCurrencyDraftChange={updateNewCurrencyDraft} + onRemoveCustomCurrency={removeCustomCurrency} + onResetSoftware={resetSoftware} + onSaveSettings={saveSettings} + onSetDenominationAvailable={setDenominationAvailable} + onStartNewCurrencyDraft={startNewCurrencyDraft} + onTrickNumberInputChange={updateTrickNumberInput} + /> + ); + } + + if (activePage === 'denominations') { + return ( + + ); + } + + if (activePage === 'about') { + return ; + } + + return ( + setActivePage('history')} + onViewTransactionDetails={viewTransactionDetails} + /> + ); + } + + return ( +
+ + +
{renderActivePage()}
+
+ ); +} diff --git a/src/app/routes.ts b/src/app/routes.ts new file mode 100644 index 00000000..bb061b0c --- /dev/null +++ b/src/app/routes.ts @@ -0,0 +1,21 @@ +export type Page = 'calculator' | 'history' | 'settings' | 'denominations' | 'about'; + +export const appRoutes: Record = { + calculator: '/', + history: '/history', + settings: '/settings', + denominations: '/denominations', + about: '/about', +}; + +const routePages = new Map( + Object.entries(appRoutes).map(([page, path]) => [path, page as Page]), +); + +export function getPageFromPath(pathname: string): Page { + return routePages.get(pathname) ?? 'calculator'; +} + +export function isAppRoute(pathname: string): boolean { + return routePages.has(pathname); +} diff --git a/src/app/storageKeys.ts b/src/app/storageKeys.ts new file mode 100644 index 00000000..275c7d74 --- /dev/null +++ b/src/app/storageKeys.ts @@ -0,0 +1,8 @@ +export const storageKeys = { + transactionHistory: 'cash-register:transaction-history', + trickNumber: 'cash-register:trick-number', + currencies: 'cash-register:currencies', + baseCurrency: 'cash-register:base-currency', + denominations: 'cash-register:denominations', + denominationAvailability: 'cash-register:denomination-availability', +} as const; diff --git a/src/assets/sample-input.txt b/src/assets/sample-input.txt new file mode 100644 index 00000000..fba6116f --- /dev/null +++ b/src/assets/sample-input.txt @@ -0,0 +1,3 @@ +2.12,3.00 +1.97,2.00 +3.33,5.00 \ No newline at end of file diff --git a/src/components/HistoryTableHeader.tsx b/src/components/HistoryTableHeader.tsx new file mode 100644 index 00000000..6201c116 --- /dev/null +++ b/src/components/HistoryTableHeader.tsx @@ -0,0 +1,12 @@ +export function HistoryTableHeader() { + return ( +
+ Owed + Paid + Change + Result + Date + Actions +
+ ); +} diff --git a/src/components/MoneyToken.tsx b/src/components/MoneyToken.tsx new file mode 100644 index 00000000..b2d00dc1 --- /dev/null +++ b/src/components/MoneyToken.tsx @@ -0,0 +1,59 @@ +import type { CSSProperties } from 'react'; +import type { Denomination } from '../domain/currency'; +import { getDenominationVisual } from '../domain/denominations'; + +type TokenStyleProperties = CSSProperties & Record; + +export function MoneyToken({ denomination }: { denomination: Denomination }) { + const visual = getDenominationVisual(denomination); + const tokenStyle: TokenStyleProperties = { + '--token-primary': visual.primaryColor, + '--token-secondary': visual.secondaryColor, + '--token-accent': visual.accentColor, + '--token-text': visual.textColor, + }; + + if (visual.kind === 'bill') { + return ( + + ); + } + + const ridges = Array.from({ length: 36 }, (_, index) => ( + + )); + + return ( + + ); +} diff --git a/src/components/TransactionRows.tsx b/src/components/TransactionRows.tsx new file mode 100644 index 00000000..dc49c8e0 --- /dev/null +++ b/src/components/TransactionRows.tsx @@ -0,0 +1,66 @@ +import { Dices, Eye } from 'lucide-react'; +import { formatMoney } from '../domain/currencies'; +import type { StoredTransaction } from '../domain/transactions'; +import { formatHistoryDate, getTransactionCurrency } from '../domain/transactions'; + +type TransactionRowsProps = { + emptyText: string; + results: StoredTransaction[]; + onRerollRandomTransaction: (transactionId: string) => void; + onViewTransactionDetails: (transaction: StoredTransaction) => void; +}; + +export function TransactionRows({ + emptyText, + results, + onRerollRandomTransaction, + onViewTransactionDetails, +}: TransactionRowsProps) { + return results.length > 0 ? ( + results.map((result) => { + const transactionCurrency = getTransactionCurrency(result); + + return ( +
+ {formatMoney(result.amountOwedCents, transactionCurrency)} + {formatMoney(result.amountPaidCents, transactionCurrency)} + {formatMoney(result.changeCents, transactionCurrency)} + + {result.output} + {result.strategy === 'random' ? ( + + ) : ( + Normal + )} + {result.changeShortfallCents > 0 ? ( + + Short {formatMoney(result.changeShortfallCents, transactionCurrency)} + + ) : null} + + {formatHistoryDate(result.createdAt)} + + + +
+ ); + }) + ) : ( +
{emptyText}
+ ); +} diff --git a/src/domain/changeRules.test.ts b/src/domain/changeRules.test.ts new file mode 100644 index 00000000..c3316f3e --- /dev/null +++ b/src/domain/changeRules.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { + calculateChangeForLine, + minimumChangeRule, + processRegisterFile, +} from './changeRules'; +import { parseMoneyToCents } from './currency'; + +describe('currency parsing', () => { + it('converts whole and decimal money strings to cents', () => { + expect(parseMoneyToCents('2')).toBe(200); + expect(parseMoneyToCents('2.1')).toBe(210); + expect(parseMoneyToCents('2.13')).toBe(213); + }); + + it('rejects invalid money strings', () => { + expect(() => parseMoneyToCents('2.999')).toThrow('Invalid money amount'); + expect(() => parseMoneyToCents('abc')).toThrow('Invalid money amount'); + }); +}); + +describe('cash register rules', () => { + it('returns minimum physical change when the owed amount is not divisible by the random divisor', () => { + const result = calculateChangeForLine('2.12,3.00', 1, { + rules: [minimumChangeRule], + }); + + expect(result.output).toBe('3 quarters,1 dime,3 pennies'); + expect(result.strategy).toBe('minimum'); + }); + + it('uses the random rule when owed cents are divisible by three', () => { + const result = calculateChangeForLine('3.33,5.00', 1, { + random: () => 0.99, + }); + + expect(result.strategy).toBe('random'); + expect(result.output).toBe('167 pennies'); + }); + + it('uses all common US bills for large change amounts', () => { + const result = calculateChangeForLine('2.12,100.00', 1, { + rules: [minimumChangeRule], + }); + + expect(result.output).toBe( + '1 fifty-dollar bill,2 twenty-dollar bills,1 five-dollar bill,1 two-dollar bill,3 quarters,1 dime,3 pennies', + ); + }); + + it('reports a shortfall when unavailable denominations cannot make exact change', () => { + const result = calculateChangeForLine('2.12,3.00', 1, { + denominations: [ + { singular: 'quarter', plural: 'quarters', cents: 25 }, + { singular: 'dime', plural: 'dimes', cents: 10 }, + ], + rules: [minimumChangeRule], + }); + + expect(result.output).toBe('3 quarters,1 dime'); + expect(result.returnedChangeCents).toBe(85); + expect(result.changeShortfallCents).toBe(3); + }); + + it('does not loop forever when random change cannot be completed exactly', () => { + const result = calculateChangeForLine('3.33,5.00', 1, { + denominations: [{ singular: 'quarter', plural: 'quarters', cents: 25 }], + random: () => 0, + }); + + expect(result.strategy).toBe('random'); + expect(result.returnedChangeCents).toBe(150); + expect(result.changeShortfallCents).toBe(17); + }); + + it('processes multiple non-empty file lines', () => { + const results = processRegisterFile('2.12,3.00\n\n1.97,2.00', { + rules: [minimumChangeRule], + }); + + expect(results.map((result) => result.output)).toEqual([ + '3 quarters,1 dime,3 pennies', + '3 pennies', + ]); + }); + + it('rejects payments that do not cover the owed amount', () => { + expect(() => calculateChangeForLine('5.00,3.00', 1)).toThrow( + 'Line 1: amount paid is less than amount owed', + ); + }); +}); diff --git a/src/domain/changeRules.ts b/src/domain/changeRules.ts new file mode 100644 index 00000000..7412be02 --- /dev/null +++ b/src/domain/changeRules.ts @@ -0,0 +1,155 @@ +import { Denomination, US_DENOMINATIONS, formatChange, parseMoneyToCents } from './currency'; + +export type RandomSource = () => number; + +export type ChangeRuleContext = { + amountOwedCents: number; + amountPaidCents: number; + changeCents: number; + denominations: Denomination[]; + random: RandomSource; + randomDivisor: number; +}; + +export type ChangeRule = { + name: string; + applies: (context: ChangeRuleContext) => boolean; + makeChange: (context: ChangeRuleContext) => Map; +}; + +export type RegisterOptions = { + denominations?: Denomination[]; + random?: RandomSource; + randomDivisor?: number; + rules?: ChangeRule[]; +}; + +export type ChangeResult = { + lineNumber: number; + amountOwedCents: number; + amountPaidCents: number; + changeCents: number; + returnedChangeCents: number; + changeShortfallCents: number; + output: string; + strategy: string; + breakdown: ChangeBreakdownItem[]; +}; + +export type ChangeBreakdownItem = Denomination & { + count: number; + totalCents: number; +}; + +export const minimumChangeRule: ChangeRule = { + name: 'minimum', + applies: () => true, + makeChange: ({ changeCents, denominations }) => { + let remaining = changeCents; + const counts = new Map(); + + for (const denomination of denominations) { + const count = Math.floor(remaining / denomination.cents); + if (count > 0) { + counts.set(denomination.cents, count); + remaining -= count * denomination.cents; + } + } + + return counts; + }, +}; + +export const randomWhenOwedAmountDivisibleRule: ChangeRule = { + name: 'random', + applies: ({ amountOwedCents, randomDivisor }) => amountOwedCents % randomDivisor === 0, + makeChange: ({ changeCents, denominations, random }) => { + let remaining = changeCents; + const counts = new Map(); + + while (remaining > 0) { + const eligible = denominations.filter((denomination) => denomination.cents <= remaining); + if (eligible.length === 0) break; + + const index = Math.floor(random() * eligible.length); + const selected = eligible[Math.min(index, eligible.length - 1)]; + counts.set(selected.cents, (counts.get(selected.cents) ?? 0) + 1); + remaining -= selected.cents; + } + + return counts; + }, +}; + +export const defaultRules: ChangeRule[] = [ + randomWhenOwedAmountDivisibleRule, + minimumChangeRule, +]; + +export function calculateChangeForLine( + rawLine: string, + lineNumber: number, + options: RegisterOptions = {}, +): ChangeResult { + const [rawAmountOwed, rawAmountPaid, ...extraColumns] = rawLine.split(','); + + if (!rawAmountOwed || !rawAmountPaid || extraColumns.length > 0) { + throw new Error(`Line ${lineNumber}: expected "amount owed,amount paid"`); + } + + const amountOwedCents = parseMoneyToCents(rawAmountOwed); + const amountPaidCents = parseMoneyToCents(rawAmountPaid); + const changeCents = amountPaidCents - amountOwedCents; + + if (changeCents < 0) { + throw new Error(`Line ${lineNumber}: amount paid is less than amount owed`); + } + + const denominations = options.denominations ?? US_DENOMINATIONS; + const context: ChangeRuleContext = { + amountOwedCents, + amountPaidCents, + changeCents, + denominations, + random: options.random ?? Math.random, + randomDivisor: options.randomDivisor ?? 3, + }; + const rules = options.rules ?? defaultRules; + const rule = rules.find((candidate) => candidate.applies(context)) ?? minimumChangeRule; + const counts = rule.makeChange(context); + const returnedChangeCents = Array.from(counts.entries()).reduce( + (total, [cents, count]) => total + cents * count, + 0, + ); + const changeShortfallCents = changeCents - returnedChangeCents; + + return { + lineNumber, + amountOwedCents, + amountPaidCents, + changeCents, + returnedChangeCents, + changeShortfallCents, + output: formatChange(counts, denominations), + strategy: rule.name, + breakdown: denominations + .map((denomination) => { + const count = counts.get(denomination.cents) ?? 0; + + return { + ...denomination, + count, + totalCents: count * denomination.cents, + }; + }) + .filter((item) => item.count > 0), + }; +} + +export function processRegisterFile(input: string, options: RegisterOptions = {}): ChangeResult[] { + return input + .split(/\r?\n/) + .map((line, index) => ({ line: line.trim(), lineNumber: index + 1 })) + .filter(({ line }) => line.length > 0) + .map(({ line, lineNumber }) => calculateChangeForLine(line, lineNumber, options)); +} diff --git a/src/domain/currencies.ts b/src/domain/currencies.ts new file mode 100644 index 00000000..fb50ff12 --- /dev/null +++ b/src/domain/currencies.ts @@ -0,0 +1,127 @@ +export type CurrencyCode = string; + +export type CurrencyOption = { + code: CurrencyCode; + label: string; + symbol: string; + usdRate: number; + editable: boolean; + custom?: boolean; +}; + +export type NewCurrencyDraft = { + code: string; + label: string; + symbol: string; + usdRate: string; +}; + +export const USD_CURRENCY_CODE = 'USD'; +export const EURO_CURRENCY_CODE = 'EUR'; +export const DEFAULT_BASE_CURRENCY_CODE = USD_CURRENCY_CODE; + +export const DEFAULT_CURRENCIES: CurrencyOption[] = [ + { code: USD_CURRENCY_CODE, label: 'Dollar', symbol: '$', usdRate: 1, editable: false }, + { code: EURO_CURRENCY_CODE, label: 'Euro', symbol: '€', usdRate: 1.08, editable: true }, +]; + +export function formatMoney( + cents: number, + currency: Pick = DEFAULT_CURRENCIES[0], +): string { + return `${currency.symbol}${(cents / 100).toFixed(2)}`; +} + +export function formatDenominationValue( + cents: number, + currency: Pick = DEFAULT_CURRENCIES[0], +): string { + return cents >= 100 ? formatMoney(cents, currency) : `${cents}c`; +} + +export function formatMoneyInput(cents: number): string { + return (cents / 100).toFixed(2); +} + +export function formatRateInput(rate: number): string { + if (Number.isInteger(rate)) { + return rate.toFixed(2); + } + + return rate.toFixed(6).replace(/0+$/, '').replace(/\.$/, ''); +} + +export function isCurrencyCode(value: string, allCurrencies: CurrencyOption[]): boolean { + return allCurrencies.some((currency) => currency.code === value); +} + +export function isStoredCustomCurrency(value: unknown): value is CurrencyOption { + if (!value || typeof value !== 'object') return false; + + const currency = value as Partial; + return ( + typeof currency.code === 'string' && + typeof currency.label === 'string' && + typeof currency.symbol === 'string' && + typeof currency.usdRate === 'number' && + currency.usdRate > 0 && + currency.custom === true && + !DEFAULT_CURRENCIES.some((defaultCurrency) => defaultCurrency.code === currency.code) + ); +} + +export function getCurrencyByCode( + currencies: CurrencyOption[], + code: CurrencyCode, +): CurrencyOption | null { + return currencies.find((currency) => currency.code === code) ?? null; +} + +export function getDefaultBaseCurrencyCode(currencies: CurrencyOption[]): CurrencyCode { + return ( + getCurrencyByCode(currencies, DEFAULT_BASE_CURRENCY_CODE)?.code ?? + currencies[0]?.code ?? + DEFAULT_BASE_CURRENCY_CODE + ); +} + +export function getCurrencyRateInBase( + currency: CurrencyOption, + baseCurrency: CurrencyOption, +): number { + return currency.usdRate / baseCurrency.usdRate; +} + +export function buildCurrencyRateInputs( + currencies: CurrencyOption[], + baseCurrency: CurrencyOption, +): Record { + return currencies.reduce( + (inputs, currency) => ({ + ...inputs, + [currency.code]: + currency.code === baseCurrency.code + ? formatRateInput(1) + : formatRateInput(getCurrencyRateInBase(currency, baseCurrency)), + }), + {} as Record, + ); +} + +export function parseCurrencyRateInput(value: string | undefined): number { + const rate = Number(value); + + if (!Number.isFinite(rate) || rate <= 0) { + throw new Error('Enter currency equivalents greater than 0.'); + } + + return rate; +} + +export function convertCurrencyCents( + cents: number, + fromCurrency: CurrencyOption, + toCurrency: CurrencyOption, +): number { + return Math.round(cents * getCurrencyRateInBase(fromCurrency, toCurrency)); +} diff --git a/src/domain/currency.ts b/src/domain/currency.ts new file mode 100644 index 00000000..9178dee5 --- /dev/null +++ b/src/domain/currency.ts @@ -0,0 +1,386 @@ +export type DenominationVisualKind = 'bill' | 'coin'; + +export type DenominationVisual = { + kind: DenominationVisualKind; + label: string; + primaryColor: string; + secondaryColor: string; + accentColor: string; + textColor: string; +}; + +export type Denomination = { + singular: string; + plural: string; + cents: number; + visual?: Partial; +}; + +export const US_DENOMINATIONS: Denomination[] = [ + { + singular: 'hundred-dollar bill', + plural: 'hundred-dollar bills', + cents: 10_000, + visual: { + kind: 'bill', + label: '100', + primaryColor: '#74d7a2', + secondaryColor: '#2f9b63', + accentColor: '#bee9cf', + textColor: '#0c663c', + }, + }, + { + singular: 'fifty-dollar bill', + plural: 'fifty-dollar bills', + cents: 5_000, + visual: { + kind: 'bill', + label: '50', + primaryColor: '#4db883', + secondaryColor: '#1e7a52', + accentColor: '#a8d9bc', + textColor: '#0d5437', + }, + }, + { + singular: 'twenty-dollar bill', + plural: 'twenty-dollar bills', + cents: 2_000, + visual: { + kind: 'bill', + label: '20', + primaryColor: '#52b87d', + secondaryColor: '#1f7d54', + accentColor: '#aaddc6', + textColor: '#0c5437', + }, + }, + { + singular: 'ten-dollar bill', + plural: 'ten-dollar bills', + cents: 1_000, + visual: { + kind: 'bill', + label: '10', + primaryColor: '#7ecfa0', + secondaryColor: '#2e9e6a', + accentColor: '#c0e8d4', + textColor: '#0f6640', + }, + }, + { + singular: 'five-dollar bill', + plural: 'five-dollar bills', + cents: 500, + visual: { + kind: 'bill', + label: '5', + primaryColor: '#8dd4a8', + secondaryColor: '#3aa872', + accentColor: '#c8e9d6', + textColor: '#1b6845', + }, + }, + { + singular: 'two-dollar bill', + plural: 'two-dollar bills', + cents: 200, + visual: { + kind: 'bill', + label: '2', + primaryColor: '#5abe8c', + secondaryColor: '#21865e', + accentColor: '#aadac5', + textColor: '#0b5038', + }, + }, + { + singular: 'dollar bill', + plural: 'dollar bills', + cents: 100, + visual: { + kind: 'bill', + label: '1', + primaryColor: '#66d395', + secondaryColor: '#2aa869', + accentColor: '#bee9cf', + textColor: '#0c663c', + }, + }, + { + singular: 'quarter', + plural: 'quarters', + cents: 25, + visual: { + kind: 'coin', + label: '25c', + primaryColor: '#868b95', + secondaryColor: '#d7dbe1', + accentColor: '#f7f8fb', + textColor: '#626976', + }, + }, + { + singular: 'dime', + plural: 'dimes', + cents: 10, + visual: { + kind: 'coin', + label: '10c', + primaryColor: '#9299a4', + secondaryColor: '#e0e3e8', + accentColor: '#f8f9fb', + textColor: '#626976', + }, + }, + { + singular: 'nickel', + plural: 'nickels', + cents: 5, + visual: { + kind: 'coin', + label: '5c', + primaryColor: '#777d87', + secondaryColor: '#c8ccd3', + accentColor: '#eff1f5', + textColor: '#565d68', + }, + }, + { + singular: 'penny', + plural: 'pennies', + cents: 1, + visual: { + kind: 'coin', + label: '1c', + primaryColor: '#9d522f', + secondaryColor: '#df8d58', + accentColor: '#ffd3aa', + textColor: '#7f3e22', + }, + }, +]; + +export const EURO_DENOMINATIONS: Denomination[] = [ + { + singular: 'five-hundred-euro bill', + plural: 'five-hundred-euro bills', + cents: 50_000, + visual: { + kind: 'bill', + label: '500', + primaryColor: '#9b6fd3', + secondaryColor: '#7046a8', + accentColor: '#dcc9f3', + textColor: '#4e2f80', + }, + }, + { + singular: 'two-hundred-euro bill', + plural: 'two-hundred-euro bills', + cents: 20_000, + visual: { + kind: 'bill', + label: '200', + primaryColor: '#e8d35a', + secondaryColor: '#b99f24', + accentColor: '#fff1a3', + textColor: '#7a6410', + }, + }, + { + singular: 'hundred-euro bill', + plural: 'hundred-euro bills', + cents: 10_000, + visual: { + kind: 'bill', + label: '100', + primaryColor: '#68c98a', + secondaryColor: '#2f965c', + accentColor: '#bde7cb', + textColor: '#17613a', + }, + }, + { + singular: 'fifty-euro bill', + plural: 'fifty-euro bills', + cents: 5_000, + visual: { + kind: 'bill', + label: '50', + primaryColor: '#e59b57', + secondaryColor: '#bf6f2f', + accentColor: '#f6cfaa', + textColor: '#7a3f15', + }, + }, + { + singular: 'twenty-euro bill', + plural: 'twenty-euro bills', + cents: 2_000, + visual: { + kind: 'bill', + label: '20', + primaryColor: '#6aa6db', + secondaryColor: '#2e75b2', + accentColor: '#c1dbf2', + textColor: '#1d4f7f', + }, + }, + { + singular: 'ten-euro bill', + plural: 'ten-euro bills', + cents: 1_000, + visual: { + kind: 'bill', + label: '10', + primaryColor: '#d46f85', + secondaryColor: '#a5425a', + accentColor: '#f0bac6', + textColor: '#7a2638', + }, + }, + { + singular: 'five-euro bill', + plural: 'five-euro bills', + cents: 500, + visual: { + kind: 'bill', + label: '5', + primaryColor: '#8f98a8', + secondaryColor: '#5c6676', + accentColor: '#d7dce3', + textColor: '#394250', + }, + }, + { + singular: 'two-euro coin', + plural: 'two-euro coins', + cents: 200, + visual: { + kind: 'coin', + label: '2', + primaryColor: '#c4a85b', + secondaryColor: '#d7dbe1', + accentColor: '#f7f8fb', + textColor: '#6b5622', + }, + }, + { + singular: 'one-euro coin', + plural: 'one-euro coins', + cents: 100, + visual: { + kind: 'coin', + label: '1', + primaryColor: '#d7dbe1', + secondaryColor: '#c4a85b', + accentColor: '#f6e3a8', + textColor: '#5d542d', + }, + }, + { + singular: 'fifty-cent coin', + plural: 'fifty-cent coins', + cents: 50, + visual: { + kind: 'coin', + label: '50c', + primaryColor: '#c49a43', + secondaryColor: '#efcf75', + accentColor: '#fff0b9', + textColor: '#735314', + }, + }, + { + singular: 'twenty-cent coin', + plural: 'twenty-cent coins', + cents: 20, + visual: { + kind: 'coin', + label: '20c', + primaryColor: '#c08e33', + secondaryColor: '#e7bf65', + accentColor: '#f7dda0', + textColor: '#70490f', + }, + }, + { + singular: 'ten-cent coin', + plural: 'ten-cent coins', + cents: 10, + visual: { + kind: 'coin', + label: '10c', + primaryColor: '#b9812d', + secondaryColor: '#d9ad59', + accentColor: '#f3d18b', + textColor: '#68420d', + }, + }, + { + singular: 'five-cent coin', + plural: 'five-cent coins', + cents: 5, + visual: { + kind: 'coin', + label: '5c', + primaryColor: '#a95f35', + secondaryColor: '#d48a59', + accentColor: '#f4c4a3', + textColor: '#653018', + }, + }, + { + singular: 'two-cent coin', + plural: 'two-cent coins', + cents: 2, + visual: { + kind: 'coin', + label: '2c', + primaryColor: '#a35430', + secondaryColor: '#cf7d4f', + accentColor: '#f2ba98', + textColor: '#653018', + }, + }, + { + singular: 'one-cent coin', + plural: 'one-cent coins', + cents: 1, + visual: { + kind: 'coin', + label: '1c', + primaryColor: '#944525', + secondaryColor: '#c76d45', + accentColor: '#efad88', + textColor: '#5d2a15', + }, + }, +]; + +export function parseMoneyToCents(value: string): number { + const trimmed = value.trim(); + const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(trimmed); + + if (!match) { + throw new Error(`Invalid money amount: "${value}"`); + } + + const dollars = Number(match[1]); + const cents = Number((match[2] ?? '').padEnd(2, '0')); + return dollars * 100 + cents; +} + +export function formatChange(counts: Map, denominations = US_DENOMINATIONS): string { + const parts = denominations + .map((denomination) => { + const count = counts.get(denomination.cents) ?? 0; + const label = count === 1 ? denomination.singular : denomination.plural; + return count > 0 ? `${count} ${label}` : null; + }) + .filter((part): part is string => part !== null); + + return parts.length > 0 ? parts.join(',') : 'no change'; +} diff --git a/src/domain/denominations.ts b/src/domain/denominations.ts new file mode 100644 index 00000000..9deb6c48 --- /dev/null +++ b/src/domain/denominations.ts @@ -0,0 +1,284 @@ +import { + Denomination, + DenominationVisual, + DenominationVisualKind, + EURO_DENOMINATIONS, + US_DENOMINATIONS, +} from './currency'; +import { CurrencyOption, DEFAULT_CURRENCIES, formatDenominationValue } from './currencies'; + +export type DenominationAvailability = Record; + +export type DenominationDraft = { + singular: string; + plural: string; + cents: string; + visualKind: DenominationVisualKind; + visualLabel: string; + primaryColor: string; + secondaryColor: string; + accentColor: string; + textColor: string; +}; + +const defaultBillVisual: DenominationVisual = { + kind: 'bill', + label: '1', + primaryColor: '#66d395', + secondaryColor: '#2aa869', + accentColor: '#bee9cf', + textColor: '#0c663c', +}; + +const defaultCoinVisual: DenominationVisual = { + kind: 'coin', + label: '1c', + primaryColor: '#868b95', + secondaryColor: '#d7dbe1', + accentColor: '#f7f8fb', + textColor: '#626976', +}; + +function formatTokenLabel(cents: number, kind: DenominationVisualKind): string { + return kind === 'bill' ? `${cents / 100}` : `${cents}c`; +} + +function isHexColor(value: unknown): value is string { + return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value); +} + +function isDenominationVisualKind(value: unknown): value is DenominationVisualKind { + return value === 'bill' || value === 'coin'; +} + +export function getDefaultVisual(cents: number, kind?: DenominationVisualKind): DenominationVisual { + const visualKind = kind ?? (cents >= 100 ? 'bill' : 'coin'); + const base = visualKind === 'bill' ? defaultBillVisual : defaultCoinVisual; + + return { + ...base, + kind: visualKind, + label: formatTokenLabel(cents, visualKind), + }; +} + +export function getDenominationVisual(denomination: Denomination): DenominationVisual { + const visualKind = isDenominationVisualKind(denomination.visual?.kind) + ? denomination.visual.kind + : undefined; + const fallback = getDefaultVisual(denomination.cents, visualKind); + const visualLabel = denomination.visual?.label?.trim(); + + return { + kind: fallback.kind, + label: visualLabel ? visualLabel.slice(0, 6) : fallback.label, + primaryColor: isHexColor(denomination.visual?.primaryColor) + ? denomination.visual.primaryColor + : fallback.primaryColor, + secondaryColor: isHexColor(denomination.visual?.secondaryColor) + ? denomination.visual.secondaryColor + : fallback.secondaryColor, + accentColor: isHexColor(denomination.visual?.accentColor) + ? denomination.visual.accentColor + : fallback.accentColor, + textColor: isHexColor(denomination.visual?.textColor) + ? denomination.visual.textColor + : fallback.textColor, + }; +} + +export function normalizeDenomination(denomination: Denomination): Denomination { + const cents = Math.trunc(denomination.cents); + const singular = denomination.singular.trim() || 'custom denomination'; + const plural = denomination.plural.trim() || `${singular}s`; + const normalized = { + singular, + plural, + cents, + visual: denomination.visual, + }; + + return { + ...normalized, + visual: getDenominationVisual(normalized), + }; +} + +export function sortDenominations(denominations: Denomination[]): Denomination[] { + return [...denominations].sort( + (first, second) => second.cents - first.cents || first.singular.localeCompare(second.singular), + ); +} + +export function getDefaultDenominations(): Denomination[] { + return sortDenominations(US_DENOMINATIONS.map(normalizeDenomination)); +} + +export function getEuroDenominations(): Denomination[] { + return sortDenominations(EURO_DENOMINATIONS.map(normalizeDenomination)); +} + +export function isStoredDenomination(value: unknown): value is Denomination { + if (!value || typeof value !== 'object') return false; + + const denomination = value as Partial; + return ( + typeof denomination.singular === 'string' && + typeof denomination.plural === 'string' && + typeof denomination.cents === 'number' && + Number.isInteger(denomination.cents) && + denomination.cents > 0 + ); +} + +export function parseStoredDenominations(value: unknown): Denomination[] { + const savedDenominations = Array.isArray(value) ? value : []; + const usedValues = new Set(); + const denominations = savedDenominations + .filter(isStoredDenomination) + .map(normalizeDenomination) + .filter((denomination) => { + if (usedValues.has(denomination.cents)) return false; + usedValues.add(denomination.cents); + return true; + }); + + return denominations.length > 0 ? sortDenominations(denominations) : getDefaultDenominations(); +} + +export function createDenominationDraft(denomination: Denomination): DenominationDraft { + const visual = getDenominationVisual(denomination); + + return { + singular: denomination.singular, + plural: denomination.plural, + cents: String(denomination.cents), + visualKind: visual.kind, + visualLabel: visual.label, + primaryColor: visual.primaryColor, + secondaryColor: visual.secondaryColor, + accentColor: visual.accentColor, + textColor: visual.textColor, + }; +} + +export function createDenominationDrafts(denominations: Denomination[]): DenominationDraft[] { + return denominations.map(createDenominationDraft); +} + +export function parseDraftCents(value: string): number | null { + const cents = Number(value); + return Number.isInteger(cents) && cents > 0 ? cents : null; +} + +export function getDraftDisplayName(draft: DenominationDraft): string { + return draft.plural.trim() || draft.singular.trim() || 'denomination'; +} + +export function previewDenominationFromDraft(draft: DenominationDraft): Denomination { + const cents = parseDraftCents(draft.cents) ?? 1; + + return normalizeDenomination({ + singular: draft.singular || 'custom denomination', + plural: draft.plural || 'custom denominations', + cents, + visual: { + kind: draft.visualKind, + label: draft.visualLabel, + primaryColor: draft.primaryColor, + secondaryColor: draft.secondaryColor, + accentColor: draft.accentColor, + textColor: draft.textColor, + }, + }); +} + +export function findNextDraftCentValue(drafts: DenominationDraft[]): number { + const usedValues = new Set( + drafts.map((draft) => parseDraftCents(draft.cents)).filter((value): value is number => value !== null), + ); + let nextValue = 1; + + while (usedValues.has(nextValue)) { + nextValue += 1; + } + + return nextValue; +} + +export function parseDenominationDrafts( + drafts: DenominationDraft[], + currency: Pick = DEFAULT_CURRENCIES[0], +): Denomination[] { + if (drafts.length === 0) { + throw new Error('Add at least one denomination.'); + } + + const usedValues = new Set(); + const denominations = drafts.map((draft) => { + const singular = draft.singular.trim(); + const plural = draft.plural.trim(); + const cents = parseDraftCents(draft.cents); + + if (!singular || !plural) { + throw new Error('Enter singular and plural names for each denomination.'); + } + + if (cents === null) { + throw new Error('Enter denomination values as whole cents greater than 0.'); + } + + if (usedValues.has(cents)) { + throw new Error( + `${formatDenominationValue(cents, currency)} is duplicated. Denomination values must be unique.`, + ); + } + + usedValues.add(cents); + + return normalizeDenomination({ + singular, + plural, + cents, + visual: { + kind: draft.visualKind, + label: draft.visualLabel, + primaryColor: draft.primaryColor, + secondaryColor: draft.secondaryColor, + accentColor: draft.accentColor, + textColor: draft.textColor, + }, + }); + }); + + return sortDenominations(denominations); +} + +export function createDefaultDenominationAvailability( + denominations: Denomination[], +): DenominationAvailability { + return denominations.reduce( + (availability, denomination) => ({ + ...availability, + [denomination.cents]: true, + }), + {} as DenominationAvailability, + ); +} + +export function createDenominationAvailability( + denominations: Denomination[], + value: unknown, +): DenominationAvailability { + const savedAvailability = + value && typeof value === 'object' ? (value as Record) : {}; + + return denominations.reduce((availability, denomination) => { + const savedValue = savedAvailability[String(denomination.cents)]; + + return { + ...availability, + [denomination.cents]: typeof savedValue === 'boolean' ? savedValue : true, + }; + }, {} as DenominationAvailability); +} diff --git a/src/domain/transactions.ts b/src/domain/transactions.ts new file mode 100644 index 00000000..6513ed58 --- /dev/null +++ b/src/domain/transactions.ts @@ -0,0 +1,102 @@ +import { ChangeResult } from './changeRules'; +import { + CurrencyCode, + CurrencyOption, + DEFAULT_BASE_CURRENCY_CODE, + DEFAULT_CURRENCIES, +} from './currencies'; + +export type StoredTransaction = ChangeResult & { + id: string; + createdAt: string; + currencyCode?: CurrencyCode; + currencySymbol?: string; +}; + +export type ProcessedState = + | { status: 'idle'; results: StoredTransaction[]; error: null } + | { status: 'ready'; results: StoredTransaction[]; error: null } + | { status: 'error'; results: StoredTransaction[]; error: string }; + +export function formatHistoryDate(value: string): string { + return new Date(value).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function makeTransactionId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +export function storeTransaction( + result: ChangeResult, + currency: Pick, +): StoredTransaction { + return { + ...result, + id: makeTransactionId(), + createdAt: new Date().toISOString(), + currencyCode: currency.code, + currencySymbol: currency.symbol, + }; +} + +export function withChangeTotals(transaction: StoredTransaction): StoredTransaction { + const returnedChangeCents = + typeof transaction.returnedChangeCents === 'number' + ? transaction.returnedChangeCents + : transaction.changeCents; + const changeShortfallCents = + typeof transaction.changeShortfallCents === 'number' + ? transaction.changeShortfallCents + : Math.max(transaction.changeCents - returnedChangeCents, 0); + + return { + ...transaction, + returnedChangeCents, + changeShortfallCents, + currencyCode: transaction.currencyCode ?? DEFAULT_BASE_CURRENCY_CODE, + currencySymbol: transaction.currencySymbol ?? DEFAULT_CURRENCIES[0].symbol, + }; +} + +export function isStoredTransaction(value: unknown): value is StoredTransaction { + if (!value || typeof value !== 'object') return false; + + const transaction = value as Partial; + return ( + typeof transaction.id === 'string' && + typeof transaction.createdAt === 'string' && + typeof transaction.lineNumber === 'number' && + typeof transaction.amountOwedCents === 'number' && + typeof transaction.amountPaidCents === 'number' && + typeof transaction.changeCents === 'number' && + (typeof transaction.returnedChangeCents === 'number' || + transaction.returnedChangeCents === undefined) && + (typeof transaction.changeShortfallCents === 'number' || + transaction.changeShortfallCents === undefined) && + typeof transaction.output === 'string' && + typeof transaction.strategy === 'string' && + Array.isArray(transaction.breakdown) && + (typeof transaction.currencyCode === 'string' || transaction.currencyCode === undefined) && + (typeof transaction.currencySymbol === 'string' || transaction.currencySymbol === undefined) + ); +} + +export function getTransactionCurrency(transaction: StoredTransaction): CurrencyOption { + return { + code: transaction.currencyCode ?? DEFAULT_BASE_CURRENCY_CODE, + label: transaction.currencyCode ?? DEFAULT_BASE_CURRENCY_CODE, + symbol: transaction.currencySymbol ?? DEFAULT_CURRENCIES[0].symbol, + usdRate: 1, + editable: false, + }; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 00000000..43ace481 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { App } from './App'; +import './styles.css'; + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx new file mode 100644 index 00000000..5a8863ec --- /dev/null +++ b/src/pages/AboutPage.tsx @@ -0,0 +1,141 @@ +export function AboutPage() { + return ( + <> +
+
+

About Cash Register

+

+ A configurable cash-register calculator built to demonstrate product behavior, + maintainable code organization, and reliable automated tests. +

+
+
+ +
+
+
+
+

Functionality

+

+ The app calculates the change that should be returned for a cash transaction. It + supports manual transactions, flat-file uploads, configurable currencies, editable + denominations, local transaction history, and a twist rule that can return random + denominations when the owed amount matches the configured divisor. +

+
+ +
+
+

Calculator

+
    +
  • Accepts amount owed and amount paid.
  • +
  • Converts non-base paid currencies before calculating change.
  • +
  • Shows total change, returned change, shortfalls, and denomination breakdowns.
  • +
+
+ +
+

File Processing

+
    +
  • Loads plain-text or CSV-style files with one transaction per line.
  • +
  • Ignores blank lines and validates malformed rows.
  • +
  • Lets users download the output history as a text file.
  • +
+
+ +
+

Settings

+
    +
  • Changes the twist-rule divisor without touching code.
  • +
  • Configures base currency and exchange equivalents.
  • +
  • Adds custom currencies and restores the full app state when needed.
  • +
+
+ +
+

Denominations

+
    +
  • Enables or disables available bills and coins.
  • +
  • Supports US and Euro presets.
  • +
  • Allows custom denomination names, values, labels, and token colors.
  • +
+
+
+ +
+ +
+

Code Organization

+

+ The code is intentionally separated so the application can grow without turning one + component into the entire system. React pages focus on rendering, shared UI lives in + reusable components, and business logic stays in pure TypeScript domain modules. +

+
+ +
+
+ src/pages + One routed file per screen: calculator, history, settings, denominations, and about. +
+
+ src/components + Reusable UI pieces such as money tokens, history rows, and table headers. +
+
+ src/domain + Pure change rules, currency helpers, denomination helpers, and transaction contracts. +
+
+ src/app + Application-level constants such as routes and local-storage keys. +
+
+ src/App.tsx + Coordinates app state, browser storage, routing layout, and page callbacks. +
+
+ +
+ +
+

Testing

+

+ The test suite covers both the pure rule engine and the user-facing React workflows. + This keeps the most important behavior protected while still making refactors safe. +

+
+ +
+
+

Domain Tests

+
    +
  • Money parsing and validation.
  • +
  • Minimum-change calculations.
  • +
  • Random twist-rule behavior with controlled randomness.
  • +
  • Shortfall reporting when exact change is impossible.
  • +
+
+ +
+

UI Tests

+
    +
  • Manual change calculation from the form.
  • +
  • Validation errors and local-storage persistence.
  • +
  • Currency conversion and denomination settings.
  • +
  • URL routing, history navigation, and the About page.
  • +
+
+
+ +
+ Quality command + npm run check + Runs TypeScript typechecking followed by the Vitest suite. +
+
+
+
+ + ); +} diff --git a/src/pages/CalculatorPage.tsx b/src/pages/CalculatorPage.tsx new file mode 100644 index 00000000..7f0f92af --- /dev/null +++ b/src/pages/CalculatorPage.tsx @@ -0,0 +1,300 @@ +import type { ChangeEvent } from 'react'; +import { + AlertCircle, + Calculator, + CheckCircle2, + ChevronRight, + Dices, + Download, + Sparkles, + Upload, +} from 'lucide-react'; +import { HistoryTableHeader } from '../components/HistoryTableHeader'; +import { MoneyToken } from '../components/MoneyToken'; +import { TransactionRows } from '../components/TransactionRows'; +import type { CurrencyCode, CurrencyOption } from '../domain/currencies'; +import { formatDenominationValue, formatMoney } from '../domain/currencies'; +import type { ProcessedState, StoredTransaction } from '../domain/transactions'; + +const sampleInputUrl = new URL('../assets/sample-input.txt', import.meta.url).href; + +type CalculatorPageProps = { + amountOwed: string; + amountPaid: string; + amountPaidBaseEquivalent: number | null; + amountPaidCurrency: CurrencyCode; + baseCurrencyCode: CurrencyCode; + currencies: CurrencyOption[]; + currentResult: StoredTransaction | null; + displayedResultCurrency: CurrencyOption; + minimumCount: number; + output: string; + processed: ProcessedState; + randomCount: number; + resultStrategyLabel: string; + selectedBaseCurrency: CurrencyOption; + selectedPaidCurrency: CurrencyOption; + totalProcessed: number; + transactionHistory: StoredTransaction[]; + trickNumber: number; + onAmountOwedChange: (value: string) => void; + onAmountPaidChange: (value: string) => void; + onAmountPaidCurrencyChange: (currencyCode: CurrencyCode) => void; + onCalculate: () => void; + onDownloadOutput: () => void; + onGenerateSampleTransaction: () => void; + onLoadFlatFile: (event: ChangeEvent) => Promise; + onRerollRandomTransaction: (transactionId: string) => void; + onViewAllHistory: () => void; + onViewTransactionDetails: (transaction: StoredTransaction) => void; +}; + +export function CalculatorPage({ + amountOwed, + amountPaid, + amountPaidBaseEquivalent, + amountPaidCurrency, + baseCurrencyCode, + currencies, + currentResult, + displayedResultCurrency, + minimumCount, + output, + processed, + randomCount, + resultStrategyLabel, + selectedBaseCurrency, + selectedPaidCurrency, + totalProcessed, + transactionHistory, + trickNumber, + onAmountOwedChange, + onAmountPaidChange, + onAmountPaidCurrencyChange, + onCalculate, + onDownloadOutput, + onGenerateSampleTransaction, + onLoadFlatFile, + onRerollRandomTransaction, + onViewAllHistory, + onViewTransactionDetails, +}: CalculatorPageProps) { + return ( + <> +
+
+

Hello, Cashier!

+

Enter the amount owed and the amount paid to calculate change.

+
+
+ +
+
+
+

Transaction

+ + + + + + + +
+ + +
+ +

+ Need a flat-file example?{' '} + + Download sample input + +

+
+ +
+
+
+
+

+ If the owed amount in cents is divisible by {trickNumber}, the system returns + random denominations. +

+
+ +
+
+ +
+
+

Change to Return

+ +
+ + {processed.status === 'error' ? ( +
+
+ ) : !currentResult ? ( +
No transaction calculated yet.
+ ) : ( + <> +
+ {formatMoney(currentResult.changeCents, displayedResultCurrency)} +
+ {currentResult.strategy === 'random' ? ( + + ) : ( +
{resultStrategyLabel}
+ )} + +
+ {currentResult.breakdown.length > 0 ? ( + <> +
+ Type + Value + Qty + Total +
+ {currentResult.breakdown.map((item) => ( +
+
+ + {item.count === 1 ? item.singular : item.plural} +
+ {formatDenominationValue(item.cents, displayedResultCurrency)} + {item.count} + + {formatMoney(item.totalCents, displayedResultCurrency)} + +
+ ))} + + ) : ( +
+ {currentResult.changeCents === 0 + ? 'No denominations needed.' + : 'No available denominations can be returned.'} +
+ )} +
+ + {currentResult.changeShortfallCents > 0 ? ( +
+
+ ) : null} + +
+ + + {formatMoney(currentResult.returnedChangeCents, displayedResultCurrency)} +
+ + )} +
+
+ +
+
+

Recent Transactions

+
+
+ {totalProcessed} processed + {minimumCount} normal + {randomCount} random +
+ + +
+
+ +
+ + +
+
+ + ); +} diff --git a/src/pages/DenominationsPage.tsx b/src/pages/DenominationsPage.tsx new file mode 100644 index 00000000..d84a2374 --- /dev/null +++ b/src/pages/DenominationsPage.tsx @@ -0,0 +1,233 @@ +import { Plus, RotateCcw, Settings, Trash2 } from 'lucide-react'; +import { MoneyToken } from '../components/MoneyToken'; +import type { Denomination, DenominationVisualKind } from '../domain/currency'; +import type { DenominationAvailability, DenominationDraft } from '../domain/denominations'; +import { getDraftDisplayName, parseDraftCents, previewDenominationFromDraft } from '../domain/denominations'; + +type DenominationsPageProps = { + denominationAvailability: DenominationAvailability; + denominationDrafts: DenominationDraft[]; + settingsMessage: string; + onAddDenominationDraft: () => void; + onRemoveDenominationDraft: (index: number) => void; + onResetDenominationsToDefault: () => void; + onResetDenominationsToEuro: () => void; + onSaveDenominations: () => void; + onSetDenominationAvailable: (denomination: Denomination, isAvailable: boolean) => void; + onUpdateDenominationDraft: (index: number, updates: Partial) => void; +}; + +export function DenominationsPage({ + denominationAvailability, + denominationDrafts, + settingsMessage, + onAddDenominationDraft, + onRemoveDenominationDraft, + onResetDenominationsToDefault, + onResetDenominationsToEuro, + onSaveDenominations, + onSetDenominationAvailable, + onUpdateDenominationDraft, +}: DenominationsPageProps) { + const isSuccessMessage = /(saved|restored|reset)/i.test(settingsMessage); + + return ( + <> +
+
+

Edit Denominations

+

US denominations load by default. Use a preset or save edits for new change calculations.

+
+
+ +
+
+
+
+ + + +
+ +
+ {denominationDrafts.map((draft, index) => { + const cents = parseDraftCents(draft.cents); + const previewDenomination = previewDenominationFromDraft(draft); + const displayName = getDraftDisplayName(draft); + const isAvailable = cents === null ? true : denominationAvailability[cents] ?? true; + + return ( +
+
+
+ +
+ +
+ + + +
+ +
+ + +
+
+ +
+ + + + + + +
+
+ ); + })} +
+ + + + {settingsMessage ? ( +

+ {settingsMessage} +

+ ) : null} +
+
+
+ + ); +} diff --git a/src/pages/HistoryPage.tsx b/src/pages/HistoryPage.tsx new file mode 100644 index 00000000..c4ddaef0 --- /dev/null +++ b/src/pages/HistoryPage.tsx @@ -0,0 +1,75 @@ +import { Download, Trash2 } from 'lucide-react'; +import { HistoryTableHeader } from '../components/HistoryTableHeader'; +import { TransactionRows } from '../components/TransactionRows'; +import type { StoredTransaction } from '../domain/transactions'; + +type HistoryPageProps = { + minimumCount: number; + output: string; + randomCount: number; + totalProcessed: number; + transactionHistory: StoredTransaction[]; + onClearHistory: () => void; + onDownloadOutput: () => void; + onRerollRandomTransaction: (transactionId: string) => void; + onViewTransactionDetails: (transaction: StoredTransaction) => void; +}; + +export function HistoryPage({ + minimumCount, + output, + randomCount, + totalProcessed, + transactionHistory, + onClearHistory, + onDownloadOutput, + onRerollRandomTransaction, + onViewTransactionDetails, +}: HistoryPageProps) { + return ( + <> +
+
+

Transaction History

+

All transactions are saved in this browser using local storage.

+
+
+ + +
+
+ +
+
+

All Transactions

+
+ {totalProcessed} processed + {minimumCount} normal + {randomCount} random +
+
+ +
+ + +
+
+ + ); +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx new file mode 100644 index 00000000..f5129c07 --- /dev/null +++ b/src/pages/SettingsPage.tsx @@ -0,0 +1,299 @@ +import { ChevronRight, Plus, Settings, Trash2 } from 'lucide-react'; +import { MoneyToken } from '../components/MoneyToken'; +import type { Denomination } from '../domain/currency'; +import type { CurrencyCode, CurrencyOption, NewCurrencyDraft } from '../domain/currencies'; +import { formatDenominationValue } from '../domain/currencies'; +import type { DenominationAvailability } from '../domain/denominations'; + +type SettingsPageProps = { + activeDenominationCount: number; + baseCurrencyCode: CurrencyCode; + currencies: CurrencyOption[]; + currencyRateInputs: Record; + denominationAvailability: DenominationAvailability; + denominations: Denomination[]; + newCurrencyDraft: NewCurrencyDraft | null; + selectedBaseCurrency: CurrencyOption; + settingsMessage: string; + trickNumberInput: string; + onAddNewCurrency: () => void; + onBaseCurrencyChange: (currencyCode: CurrencyCode) => void; + onCancelNewCurrencyDraft: () => void; + onCurrencyRateInputChange: (currencyCode: CurrencyCode, value: string) => void; + onEditDenominations: () => void; + onNewCurrencyDraftChange: (updates: Partial) => void; + onRemoveCustomCurrency: (currencyCode: CurrencyCode) => void; + onResetSoftware: () => void; + onSaveSettings: () => void; + onSetDenominationAvailable: (denomination: Denomination, isAvailable: boolean) => void; + onStartNewCurrencyDraft: () => void; + onTrickNumberInputChange: (value: string) => void; +}; + +export function SettingsPage({ + activeDenominationCount, + baseCurrencyCode, + currencies, + currencyRateInputs, + denominationAvailability, + denominations, + newCurrencyDraft, + selectedBaseCurrency, + settingsMessage, + trickNumberInput, + onAddNewCurrency, + onBaseCurrencyChange, + onCancelNewCurrencyDraft, + onCurrencyRateInputChange, + onEditDenominations, + onNewCurrencyDraftChange, + onRemoveCustomCurrency, + onResetSoftware, + onSaveSettings, + onSetDenominationAvailable, + onStartNewCurrencyDraft, + onTrickNumberInputChange, +}: SettingsPageProps) { + const isSuccessMessage = /(saved|restored|reset)/i.test(settingsMessage); + + return ( + <> +
+
+

Settings

+

Control the number that activates the twist rule.

+
+
+ +
+
+
+
+

Twist Rule

+

+ When the owed amount in cents is divisible by this number, change is returned using + random denominations. +

+
+ + + +
+ +
+

Currencies

+

+ Choose the base currency for owed amounts, denominations, and change results. The + base currency equivalent is always 1. +

+
+ + + +
+ {currencies.map((currency) => ( +
+ + {currency.label} + {currency.code} · {currency.symbol} + +
+ + onCurrencyRateInputChange(currency.code, event.target.value) + } + /> + {selectedBaseCurrency.code} +
+ {currency.custom ? ( + + ) : null} +
+ ))} + + {newCurrencyDraft ? ( +
+ onNewCurrencyDraftChange({ code: event.target.value })} + /> + onNewCurrencyDraftChange({ label: event.target.value })} + /> + onNewCurrencyDraftChange({ symbol: event.target.value })} + /> +
+ onNewCurrencyDraftChange({ usdRate: event.target.value })} + /> + {selectedBaseCurrency.code} +
+
+ + +
+
+ ) : ( + + )} +
+ +
+ +
+
+

Denominations

+

+ {activeDenominationCount} of {denominations.length} denomination + {denominations.length !== 1 ? 's' : ''} enabled for change calculations. +

+
+ +
+ +
+ {denominations.map((denomination) => { + const isAvailable = denominationAvailability[denomination.cents] ?? true; + + return ( + + ); + })} +
+ + + + {settingsMessage ? ( +

+ {settingsMessage} +

+ ) : null} + +
+ +
+
+

Reset Software

+

Clear local storage and restore currencies, denominations, settings, and history.

+
+ +
+
+
+
+ + ); +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 00000000..9a11ff1b --- /dev/null +++ b/src/styles.css @@ -0,0 +1,1442 @@ +:root { + color: #080d2b; + background: #f3f5fb; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; +} + +button, +input, +select { + font: inherit; +} + +button, +.file-loader { + align-items: center; + border: 0; + border-radius: 8px; + cursor: pointer; + display: inline-flex; + font-weight: 700; + gap: 0.55rem; + justify-content: center; + min-height: 2.7rem; + padding: 0.7rem 1rem; + transition: + box-shadow 160ms ease, + transform 160ms ease, + background 160ms ease; +} + +button:active, +.file-loader:active { + transform: translateY(1px); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + color: #080d2b; + font-size: clamp(1.65rem, 3vw, 2.15rem); + line-height: 1.08; + margin-bottom: 0.35rem; +} + +h2 { + color: #080d2b; + font-size: 1.45rem; + line-height: 1.15; + margin-bottom: 1.25rem; +} + +.app-frame { + display: flex; + gap: 1.35rem; + min-height: 100vh; + padding: 1rem; +} + +.sidebar { + background: + radial-gradient(circle at 20% 10%, rgba(113, 97, 218, 0.45), transparent 28rem), + linear-gradient(155deg, #11194a 0%, #171f58 55%, #10164b 100%); + border-radius: 8px; + box-shadow: 0 18px 45px rgba(20, 29, 83, 0.24); + color: #ffffff; + display: flex; + flex: 0 0 270px; + flex-direction: column; + min-height: calc(100vh - 2rem); + overflow: hidden; + padding: 2.1rem 0.9rem 1.25rem; + position: sticky; + top: 1rem; +} + +.sidebar-brand { + align-items: center; + display: flex; + gap: 1rem; + margin-bottom: 3rem; + padding: 0 1rem; +} + +.brand-icon { + align-items: center; + border: 4px solid rgba(255, 255, 255, 0.95); + border-radius: 8px; + display: inline-flex; + height: 3.7rem; + justify-content: center; + width: 3.7rem; +} + +.sidebar-brand span { + display: block; + font-size: 1.85rem; + font-weight: 900; + line-height: 1.12; +} + +.side-nav { + display: grid; + gap: 0.75rem; +} + +.nav-item { + align-items: center; + background: transparent; + border: 0; + border-radius: 8px; + color: #ffffff; + cursor: pointer; + display: inline-flex; + font-size: 1rem; + font-weight: 700; + gap: 0.55rem; + justify-content: flex-start; + min-height: 4rem; + padding: 0.9rem 1rem; + text-decoration: none; + transition: + box-shadow 160ms ease, + background 160ms ease; + width: 100%; +} + +.nav-item:hover, +.nav-item.active { + background: rgba(124, 116, 222, 0.62); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +.main-stage { + flex: 1; + min-width: 0; +} + +.top-card, +.surface-card, +.twist-card { + background: rgba(255, 255, 255, 0.92); + border: 1px solid #d9deeb; + border-radius: 8px; + box-shadow: 0 10px 28px rgba(23, 30, 69, 0.09); +} + +.top-card { + align-items: center; + display: flex; + gap: 1rem; + justify-content: space-between; + margin-bottom: 1.35rem; + min-height: 6.75rem; + padding: 1.45rem 1.5rem; +} + +.top-card p { + color: #59617f; + font-size: 1rem; + line-height: 1.4; + margin-bottom: 0; +} + +.calculator-grid { + display: grid; + gap: 1.35rem; + grid-template-columns: minmax(340px, 0.9fr) minmax(460px, 1.3fr); +} + +.left-stack { + display: grid; + gap: 1.2rem; +} + +.surface-card { + padding: 1.65rem; +} + +.transaction-card { + min-height: 0; +} + +.amount-field { + display: grid; + gap: 0.65rem; + margin-bottom: 1.35rem; +} + +.amount-field > span { + color: #080d2b; + font-size: 1rem; + font-weight: 700; +} + +.input-shell { + align-items: center; + background: #ffffff; + border: 1px solid #d8ddea; + border-radius: 8px; + display: flex; + gap: 0.75rem; + min-height: 3.6rem; + padding: 0 1.05rem; +} + +.paid-input-shell { + padding-right: 0.45rem; +} + +.input-shell:focus-within { + border-color: #6f5bd7; + box-shadow: 0 0 0 4px rgba(111, 91, 215, 0.13); +} + +.input-shell span { + color: #707894; + font-size: 1.35rem; + font-weight: 600; +} + +.input-shell input { + background: transparent; + border: 0; + color: #080d2b; + flex: 1; + font-size: 1.35rem; + font-weight: 700; + min-width: 0; + outline: 0; +} + +.currency-select { + background: transparent; + border: 0; + border-left: 1px solid #e3e7f0; + border-radius: 0; + color: #303852; + cursor: pointer; + flex: 0 0 auto; + font-weight: 800; + min-height: 2.65rem; + padding: 0 0.35rem 0 0.9rem; +} + +.currency-select:focus { + outline: 0; +} + +.base-currency-select { + background: transparent; + border: 0; + color: #080d2b; + cursor: pointer; + flex: 1; + font-weight: 800; + min-width: 0; + outline: 0; +} + +.currency-equivalent { + color: #59617f; + font-size: 0.85rem; + font-weight: 800; +} + +.calculate-button { + background: linear-gradient(180deg, #765ce0, #5b45c4); + box-shadow: 0 10px 22px rgba(91, 69, 196, 0.2); + color: #ffffff; + font-size: 1rem; + margin-top: 0.1rem; + min-height: 3.85rem; + width: 100%; +} + +.calculate-button:hover { + background: linear-gradient(180deg, #8168e6, #5b45c4); + box-shadow: 0 16px 30px rgba(91, 69, 196, 0.34); +} + +.secondary-actions { + align-items: center; + display: grid; + gap: 0.75rem; + grid-template-columns: 1fr 1fr; + margin-top: 1.25rem; +} + +.file-loader, +.ghost-button, +.view-button { + background: #fbfcff; + border: 1px solid #d9deeb; + color: #4e3cc7; +} + +.file-loader:hover, +.ghost-button:hover, +.view-button:hover { + box-shadow: 0 8px 20px rgba(91, 69, 196, 0.12); +} + +.file-loader { + position: relative; +} + +.file-loader input { + height: 1px; + opacity: 0; + overflow: hidden; + position: absolute; + width: 1px; +} + +.sample-download-text { + color: #59617f; + font-size: 0.88rem; + line-height: 1.35; + margin: 0.8rem 0 0; + text-align: center; +} + +.sample-download-text a { + color: #4e3cc7; + font-weight: 800; + text-decoration: none; +} + +.sample-download-text a:hover { + text-decoration: underline; +} + +.twist-card { + align-items: center; + color: #3b4261; + display: flex; + justify-content: space-between; + min-height: 6.25rem; + overflow: hidden; + padding: 1.1rem 1.35rem; + position: relative; +} + +.twist-card::after { + background: radial-gradient(circle, rgba(119, 96, 224, 0.14), transparent 70%); + content: ""; + height: 10rem; + position: absolute; + right: -4rem; + top: -3rem; + width: 10rem; +} + +.twist-title { + align-items: center; + color: #4e3cc7; + display: flex; + gap: 0.45rem; + margin-bottom: 0.35rem; +} + +.twist-title h2 { + color: #4e3cc7; + font-size: 1rem; + margin-bottom: 0; +} + +.twist-card p { + line-height: 1.35; + margin-bottom: 0; + max-width: 30rem; +} + +.dice-art { + color: #6b5ad0; + flex: 0 0 auto; + position: relative; + z-index: 1; +} + +.result-card { + min-height: 36rem; +} + +.result-empty { + align-items: center; + border: 1px dashed #cfd5e4; + border-radius: 8px; + color: #59617f; + display: flex; + font-weight: 700; + justify-content: center; + min-height: 22rem; + text-align: center; +} + +.result-head { + align-items: flex-start; + display: flex; + justify-content: space-between; +} + +.money-art { + align-items: center; + background: linear-gradient(145deg, #66d395, #33a96d); + border-radius: 8px; + box-shadow: + 10px 12px 18px rgba(28, 107, 75, 0.2), + inset 0 0 0 9px rgba(26, 118, 76, 0.16); + color: #16814f; + display: inline-flex; + font-size: 1.35rem; + font-weight: 900; + height: 4rem; + justify-content: center; + margin-top: 0.35rem; + transform: rotate(-13deg); + width: 5.6rem; +} + +.money-art span { + align-items: center; + background: rgba(255, 255, 255, 0.35); + border-radius: 999px; + display: inline-flex; + height: 1.8rem; + justify-content: center; + width: 1.8rem; +} + +.change-total { + color: #1d9662; + font-size: clamp(2.7rem, 6vw, 3.85rem); + font-weight: 900; + line-height: 1; + margin-bottom: 0.55rem; +} + +.status-chip { + background: #d9f5e5; + border-radius: 8px; + color: #09854b; + display: inline-flex; + font-size: 0.88rem; + font-style: normal; + font-weight: 700; + line-height: 1; + padding: 0.48rem 0.7rem; +} + +.status-chip.random { + background: #eeeaff; + color: #5a45d3; +} + +.status-chip.shortfall { + background: #fff0ed; + color: #ae321f; +} + +.status-chip.compact { + align-items: center; + border: 0; + font-size: 0.74rem; + margin-left: 0.55rem; + min-height: 0; + padding: 0.38rem 0.55rem; + vertical-align: middle; +} + +.reroll-chip { + cursor: pointer; + gap: 0.35rem; +} + +.reroll-chip:hover { + box-shadow: 0 8px 18px rgba(90, 69, 211, 0.18); +} + +.denomination-list { + border: 1px solid #d9deeb; + border-radius: 8px; + margin-top: 1.1rem; + overflow: hidden; +} + +.denomination-row { + align-items: center; + background: #ffffff; + border-top: 1px solid #e3e7f0; + display: grid; + gap: 1rem; + grid-template-columns: minmax(11rem, 1fr) 5rem 4rem 6rem; + min-height: 5rem; + padding: 0.8rem 1.35rem; +} + +.denomination-row:first-child { + border-top: 0; +} + +.denomination-head { + background: #fbfcff; + color: #6b728c; + font-size: 0.78rem; + font-weight: 900; + min-height: 2.55rem; + padding-bottom: 0.55rem; + padding-top: 0.55rem; + text-transform: uppercase; +} + +.denomination-type { + align-items: center; + display: flex; + gap: 1rem; + min-width: 0; +} + +.denomination-row strong { + font-size: 1rem; +} + +.denomination-row > span { + color: #6b728c; +} + +.denomination-row b { + font-size: 1.45rem; +} + +.row-total { + color: #118752; + justify-self: end; + text-align: right; +} + +.money-token { + align-items: center; + display: inline-flex; + height: 3.1rem; + justify-content: center; + width: 3.1rem; +} + +.token-bill { + background: linear-gradient(145deg, var(--token-primary, #66d395), var(--token-secondary, #2aa869)); + border: 1px solid var(--token-accent, #bee9cf); + border-radius: 8px; + box-shadow: + inset 0 0 0 2px rgba(255, 255, 255, 0.22), + 0 7px 12px rgba(22, 26, 44, 0.12); + color: var(--token-text, #0c663c); + font-size: 1rem; + font-weight: 900; + height: 2.55rem; + letter-spacing: 0; + width: 4.25rem; +} + +.coin-svg { + --coin-edge: var(--token-primary, #868b95); + --coin-face: var(--token-secondary, #d7dbe1); + --coin-highlight: var(--token-accent, #f7f8fb); + --coin-line: var(--token-text, #626976); + border-radius: 999px; + filter: drop-shadow(0 7px 8px rgba(22, 26, 44, 0.16)); + flex: 0 0 auto; + overflow: visible; +} + +.coin-svg-tight { + height: 2.75rem; + width: 2.75rem; +} + +.coin-shadow { + fill: rgba(13, 18, 43, 0.16); +} + +.coin-rim { + fill: var(--coin-edge); + stroke: rgba(255, 255, 255, 0.5); + stroke-width: 1.4; +} + +.coin-ridge { + stroke: rgba(255, 255, 255, 0.45); + stroke-linecap: round; + stroke-width: 1.2; +} + +.coin-face { + fill: var(--coin-face); + stroke: rgba(255, 255, 255, 0.55); + stroke-width: 1.6; +} + +.coin-inner-ring { + fill: none; + stroke: rgba(255, 255, 255, 0.52); + stroke-width: 1.2; +} + +.coin-value-plate { + fill: var(--coin-highlight); + opacity: 0.42; + stroke: rgba(255, 255, 255, 0.42); + stroke-width: 1; +} + +.coin-text { + fill: var(--coin-line); + font-size: 13px; + font-weight: 900; + letter-spacing: 0; +} + +.coin-text-tight { + font-size: 10px; +} + +.empty-breakdown { + background: #ffffff; + color: #6b728c; + padding: 1.25rem; +} + +.total-strip { + align-items: center; + background: #eafaf2; + border: 1px solid #bcebd1; + border-radius: 8px; + color: #118752; + display: flex; + justify-content: space-between; + margin-top: 1.9rem; + min-height: 4.1rem; + padding: 0.9rem 1.15rem; +} + +.shortfall-strip { + align-items: center; + background: #fff0ed; + border: 1px solid #ffc7bc; + border-radius: 8px; + color: #ae321f; + display: flex; + font-weight: 800; + gap: 0.65rem; + line-height: 1.35; + margin-top: 1rem; + padding: 0.9rem 1rem; +} + +.total-strip span { + align-items: center; + display: inline-flex; + font-size: 1rem; + font-weight: 800; + gap: 0.65rem; +} + +.total-strip strong { + font-size: 1.4rem; +} + +.error-state { + align-items: center; + background: #fff0ed; + border: 1px solid #ffc7bc; + border-radius: 8px; + color: #ae321f; + display: flex; + font-weight: 800; + gap: 0.75rem; + min-height: 4rem; + padding: 1rem; +} + +.history-card { + margin-top: 1.35rem; +} + +.history-head { + align-items: center; + display: flex; + gap: 1rem; + justify-content: space-between; + margin-bottom: 1rem; +} + +.history-head h2 { + font-size: 1.05rem; + margin-bottom: 0; +} + +.history-actions { + align-items: center; + display: flex; + gap: 0.75rem; +} + +.mini-stats { + color: #6b728c; + display: flex; + flex-wrap: wrap; + font-size: 0.82rem; + font-weight: 700; + gap: 0.6rem; +} + +.mini-stats span { + background: #f3f5fb; + border-radius: 999px; + padding: 0.35rem 0.6rem; +} + +.view-button { + min-height: 2.4rem; + padding: 0.5rem 0.75rem; +} + +.compact-view-button { + min-height: 2.15rem; + padding: 0.42rem 0.6rem; + white-space: nowrap; +} + +.danger-button { + background: #fff0ed; + border-color: #ffc7bc; + color: #ae321f; +} + +.danger-button:hover { + box-shadow: 0 8px 20px rgba(174, 50, 31, 0.12); +} + +.full-history-card { + min-height: 35rem; +} + +.settings-layout { + align-items: start; + display: grid; + gap: 1.35rem; + grid-template-columns: 1fr; + max-width: none; + width: 100%; +} + +.denominations-page-layout { + display: grid; + gap: 1.35rem; + max-width: 900px; +} + +.settings-panel { + min-width: 0; +} + +.denominations-panel { + padding-bottom: 1.25rem; +} + +.denominations-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.settings-section { + display: grid; + gap: 1.15rem; +} + +.settings-section h2 { + font-size: 1.25rem; + margin-bottom: 0.35rem; +} + +.settings-section p { + color: #59617f; + line-height: 1.45; + margin-bottom: 0; +} + +.about-section h3 { + color: #080d2b; + font-size: 1rem; + margin: 0 0 0.55rem; +} + +.about-grid { + display: grid; + gap: 0.9rem; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); +} + +.about-card { + background: #fbfcff; + border: 1px solid #d9deeb; + border-radius: 8px; + padding: 1rem; +} + +.about-card ul { + color: #59617f; + display: grid; + gap: 0.45rem; + line-height: 1.4; + margin: 0; + padding-left: 1.1rem; +} + +.about-code-list { + border: 1px solid #d9deeb; + border-radius: 8px; + display: grid; + overflow: hidden; +} + +.about-code-list div { + background: #ffffff; + border-top: 1px solid #e3e7f0; + display: grid; + gap: 0.25rem; + padding: 0.85rem 1rem; +} + +.about-code-list div:first-child { + border-top: 0; +} + +.about-code-list strong, +.about-check-command strong { + color: #080d2b; +} + +.about-code-list span, +.about-check-command span { + color: #59617f; + line-height: 1.4; +} + +.about-check-command { + align-items: center; + background: #f7f8fc; + border: 1px solid #d9deeb; + border-radius: 8px; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + padding: 1rem; +} + +.about-check-command code { + background: #080d2b; + border-radius: 6px; + color: #ffffff; + font-weight: 800; + padding: 0.4rem 0.55rem; +} + +.settings-field { + margin-bottom: 0; + max-width: 13rem; +} + +.settings-field .input-shell { + max-width: 11rem; +} + +.settings-field input { + text-align: left; +} + +.settings-save-button { + max-width: 13rem; +} + +.settings-divider { + background: #e3e7f0; + height: 1px; + width: 100%; +} + +.settings-currencies-row { + align-items: center; + display: flex; + gap: 16px; + justify-content: space-between; +} + +.settings-currencies-row > div { + flex: 1; + min-width: 0; +} + +.settings-currencies-row p { + color: #6b7280; + font-size: 13px; + margin: 2px 0 0; +} + +.settings-denomination-list { + background: #e8ecf6; + border: 1px solid #d9deeb; + border-radius: 8px; + display: grid; + gap: 1px; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + overflow: hidden; +} + +.settings-denomination-toggle { + align-items: center; + background: #ffffff; + display: flex; + gap: 1rem; + justify-content: space-between; + min-width: 0; + padding: 0.95rem 1rem; +} + +.settings-denomination-toggle.unavailable { + background: #f8f9fd; +} + +.settings-denomination-info { + align-items: center; + display: flex; + gap: 0.8rem; + min-width: 0; +} + +.settings-denomination-info > span:last-child { + display: grid; + gap: 0.2rem; + min-width: 0; +} + +.settings-denomination-info strong { + color: #080d2b; +} + +.settings-denomination-info em { + color: #6b728c; + font-size: 0.82rem; + font-style: normal; + font-weight: 800; +} + +.reset-software-section { + align-items: center; + background: #fff8f6; + border: 1px solid #ffc7bc; + border-radius: 8px; + display: flex; + gap: 1rem; + justify-content: space-between; + padding: 1rem; +} + +.reset-software-button { + flex: 0 0 auto; +} + +.currency-settings-list { + border: 1px solid #d9deeb; + border-radius: 8px; + display: grid; + overflow: hidden; +} + +.currency-setting-row, +.denomination-setting-row { + align-items: center; + background: #ffffff; + border-top: 1px solid #e3e7f0; + display: grid; + gap: 1rem; + padding: 0.9rem 1rem; +} + +.currency-setting-row { + grid-template-columns: minmax(8rem, 1fr) minmax(9rem, 12rem); +} + +.currency-setting-row-custom { + grid-template-columns: minmax(8rem, 1fr) minmax(9rem, 12rem) auto; +} + +.add-currency-button { + align-items: center; + background: #f7f8fb; + border-top: 1px solid #e3e7f0; + color: #4b5474; + display: flex; + font-size: 0.88rem; + font-weight: 600; + gap: 0.4rem; + justify-content: center; + min-height: 2.6rem; + padding: 0.6rem 1rem; + width: 100%; +} + +.add-currency-button:hover { + background: #eef0f7; + color: #080d2b; +} + +.new-currency-form { + align-items: center; + background: #f7f8fb; + border-top: 1px solid #e3e7f0; + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + padding: 0.9rem 1rem; +} + +.new-currency-input { + background: #fff; + border: 1.5px solid #d9deeb; + border-radius: 6px; + font-size: 0.9rem; + min-width: 0; + padding: 0.45rem 0.6rem; + width: 9rem; +} + +.new-currency-input:focus { + border-color: #6c7ae0; + outline: none; +} + +.new-currency-actions { + display: flex; + gap: 0.5rem; +} + +.currency-setting-row:first-child, +.denomination-setting-row:first-child { + border-top: 0; +} + +.currency-setting-row > span, +.denomination-setting-row > span { + display: grid; + gap: 0.2rem; +} + +.currency-setting-row strong, +.denomination-setting-row strong { + color: #080d2b; +} + +.currency-setting-row em, +.denomination-setting-row em { + color: #6b728c; + font-size: 0.82rem; + font-style: normal; + font-weight: 800; +} + +.denomination-settings-list { + border: 1px solid #d9deeb; + border-radius: 12px; + display: grid; + overflow: hidden; +} + +.denomination-setting-row { + grid-template-columns: minmax(0, 1fr); + min-height: 4.2rem; +} + +.denomination-setting-row:first-child { + border-top: 0; +} + +.availability-toggle input { + accent-color: #5b45c4; + cursor: pointer; + height: 1.1rem; + width: 1.1rem; +} + +.denomination-card { + background: #ffffff; + border-top: 1px solid #e8ecf6; + display: grid; +} + +.denomination-card:first-child { + border-top: 0; +} + +.denomination-card-main { + align-items: start; + display: grid; + gap: 1rem; + grid-template-columns: auto 1fr auto; + padding: 1.25rem 1.25rem 0.85rem; +} + +.denomination-preview { + align-items: center; + background: #f4f5fb; + border: 1px solid #e3e7f0; + border-radius: 10px; + display: flex; + height: 5.5rem; + justify-content: center; + width: 5.5rem; +} + +.denomination-fields-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: 1fr 1fr minmax(6rem, 0.55fr); +} + +.denomination-visual-grid { + border-top: 1px solid #f0f2f9; + display: grid; + gap: 0.75rem; + grid-template-columns: minmax(7rem, 1.2fr) minmax(5rem, 0.8fr) repeat(4, minmax(3rem, 1fr)); + padding: 0.85rem 1.25rem 1.25rem calc(1.25rem + 5.5rem + 1rem); +} + +.denomination-card-actions { + align-items: flex-end; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 1.55rem; +} + +.setting-mini-field { + display: grid; + gap: 0.35rem; + min-width: 0; +} + +.setting-mini-field span, +.availability-toggle span { + color: #6b728c; + font-size: 0.76rem; + font-weight: 900; + text-transform: uppercase; +} + +.setting-mini-field input, +.setting-mini-field select { + background: #ffffff; + border: 1px solid #d9deeb; + border-radius: 8px; + color: #080d2b; + font: inherit; + font-size: 0.94rem; + font-weight: 750; + min-height: 2.65rem; + min-width: 0; + padding: 0.55rem 0.65rem; + width: 100%; +} + +.setting-mini-field input:focus, +.setting-mini-field select:focus { + border-color: #5b45c4; + box-shadow: 0 0 0 3px rgba(91, 69, 196, 0.13); + outline: none; +} + +.color-field input[type='color'] { + border-radius: 6px; + cursor: pointer; + height: 2.65rem; + padding: 0.2rem 0.25rem; + width: 100%; +} + +.denomination-row-actions { + align-items: stretch; + display: grid; + gap: 0.65rem; + justify-items: end; +} + +.availability-toggle { + align-items: center; + background: #f7f8fc; + border: 1px solid #e3e7f0; + border-radius: 8px; + display: inline-flex; + gap: 0.5rem; + min-height: 2.65rem; + padding: 0.55rem 0.65rem; +} + +.compact-availability-toggle { + flex: 0 0 auto; + min-height: 2.35rem; + padding: 0.45rem 0.55rem; +} + +.rate-shell { + min-height: 3rem; +} + +.rate-shell input:disabled { + color: #6b728c; +} + +.settings-message { + border-radius: 8px; + font-weight: 800; + padding: 0.85rem 1rem; +} + +.settings-message.success { + background: #eafaf2; + border: 1px solid #bcebd1; + color: #118752; +} + +.settings-message.error { + background: #fff0ed; + border: 1px solid #ffc7bc; + color: #ae321f; +} + +.history-table { + border: 1px solid #d9deeb; + border-radius: 8px; + overflow: auto; +} + +.history-row { + align-items: center; + background: #ffffff; + border-top: 1px solid #e3e7f0; + display: grid; + gap: 1rem; + grid-template-columns: 7.5rem 7.5rem 7.5rem minmax(20rem, 1fr) 12rem 6rem; + min-width: 960px; + padding: 0.95rem 1.25rem; +} + +.history-row:first-child { + border-top: 0; +} + +.history-header { + background: #fbfcff; + color: #4f5875; + font-size: 0.82rem; + font-weight: 800; + padding-bottom: 0.65rem; + padding-top: 0.65rem; +} + +.history-result { + min-width: 0; +} + +.history-actions-cell { + justify-self: end; +} + +.empty-history { + background: #ffffff; + color: #6b728c; + min-width: 960px; + padding: 1rem 1.25rem; +} + +@media (max-width: 1120px) { + .app-frame { + flex-direction: column; + } + + .sidebar { + flex: none; + min-height: auto; + position: static; + } + + .sidebar-brand { + margin-bottom: 1.4rem; + } + + .side-nav { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .nav-item { + justify-content: center; + } + +} + +@media (max-width: 920px) { + .calculator-grid { + grid-template-columns: 1fr; + } + + .settings-layout { + grid-template-columns: 1fr; + max-width: none; + } + + .result-card { + min-height: auto; + } + + .top-card { + align-items: stretch; + flex-direction: column; + } + +} + +@media (max-width: 680px) { + .app-frame { + padding: 0.7rem; + } + + .sidebar { + padding: 1.2rem 0.75rem; + } + + .sidebar-brand span { + font-size: 1.35rem; + } + + .side-nav { + grid-template-columns: 1fr 1fr; + } + + .top-card, + .surface-card, + .twist-card { + padding: 1rem; + } + + .secondary-actions, + .history-head, + .history-actions { + align-items: stretch; + flex-direction: column; + } + + .file-loader, + .ghost-button, + .view-button { + width: 100%; + } + + .currency-setting-row { + grid-template-columns: 1fr; + } + + .settings-denomination-list { + grid-template-columns: 1fr; + } + + .reset-software-section { + align-items: stretch; + flex-direction: column; + } + + .reset-software-button { + width: 100%; + } + + .denomination-card-main { + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + } + + .denomination-card-actions { + flex-direction: row; + grid-column: 1 / -1; + padding-top: 0; + } + + .denomination-fields-grid { + grid-template-columns: 1fr 1fr; + } + + .denomination-visual-grid { + grid-template-columns: 1fr 1fr repeat(4, minmax(2.5rem, 1fr)); + padding-left: 1.25rem; + } + + .denomination-row { + gap: 0.6rem; + grid-template-columns: minmax(8rem, 1fr) 3.5rem 2.6rem 4.5rem; + padding: 0.8rem; + } + + .denomination-type { + gap: 0.55rem; + } + + .money-art { + display: none; + } + + .total-strip { + align-items: flex-start; + flex-direction: column; + gap: 0.5rem; + } +} diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 00000000..7f813be5 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,8 @@ +import { cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { afterEach } from 'vitest'; + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 00000000..e935337c --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 00000000..a04dcf10 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..113e832d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + }, +});