diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e77be8d5..8f2c36fd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -143,7 +144,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -355,7 +355,6 @@ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", "dev": true, - "license": "MIT", "dependencies": { "css-tree": "^3.0.0" }, @@ -451,7 +450,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -500,7 +498,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1899,7 +1896,6 @@ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.58.2" }, @@ -2671,6 +2667,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2730,6 +2727,20 @@ } } }, + "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.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2745,7 +2756,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -2842,7 +2854,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "dev": true, - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2852,7 +2863,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2862,7 +2872,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2914,7 +2923,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -3486,7 +3494,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3525,6 +3532,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -3897,7 +3905,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4434,6 +4441,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -4464,7 +4472,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -4762,7 +4771,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4941,7 +4949,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6205,16 +6212,11 @@ "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" + "bin": { + "jsesc": "bin/jsesc" }, "engines": { - "node": ">= 0.4" + "node": ">=6" } }, "node_modules/jiti": { @@ -6249,7 +6251,6 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", "dev": true, - "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^5.0.1", "@asamuzakjp/dom-selector": "^7.0.3", @@ -6704,6 +6705,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7258,7 +7260,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7320,6 +7321,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7335,6 +7337,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -7347,7 +7350,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prop-types": { "version": "15.8.1", @@ -7418,7 +7422,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7427,7 +7430,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7445,7 +7447,6 @@ "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -7507,8 +7508,7 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "peer": true + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -8344,7 +8344,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "peer": true, "engines": { "node": ">=12" }, @@ -8592,7 +8591,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8772,7 +8770,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8889,7 +8886,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9196,7 +9192,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -9297,7 +9292,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, - "peer": true, "requires": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -9485,7 +9479,6 @@ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, - "peer": true, "requires": {} }, "@csstools/css-syntax-patches-for-csstree": { @@ -9499,8 +9492,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "peer": true + "dev": true }, "@emnapi/core": { "version": "1.8.1", @@ -9810,7 +9802,7 @@ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", "dev": true, - "requires": {} + "optional": true }, "@humanfs/core": { "version": "0.19.1", @@ -10189,7 +10181,6 @@ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "devOptional": true, - "peer": true, "requires": { "playwright": "1.58.2" } @@ -10605,6 +10596,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, + "peer": true, "requires": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -10621,6 +10613,7 @@ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, + "peer": true, "requires": { "dequal": "^2.0.3" } @@ -10658,6 +10651,13 @@ "@babel/runtime": "^7.12.5" } }, + "@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, + "requires": {} + }, "@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -10672,7 +10672,8 @@ "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 + "dev": true, + "peer": true }, "@types/chai": { "version": "5.2.3", @@ -10767,7 +10768,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "dev": true, - "peer": true, "requires": { "undici-types": "~6.21.0" } @@ -10777,7 +10777,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, - "peer": true, "requires": { "csstype": "^3.2.2" } @@ -10787,7 +10786,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, - "peer": true, "requires": {} }, "@types/use-sync-external-store": { @@ -10824,7 +10822,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, - "peer": true, "requires": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -10849,6 +10846,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", "dev": true, + "optional": true, "requires": { "@typescript-eslint/types": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0" @@ -11150,8 +11148,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "peer": true + "dev": true }, "acorn-jsx": { "version": "5.3.2", @@ -11176,7 +11173,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true + "dev": true, + "peer": true }, "ansi-styles": { "version": "4.3.0", @@ -11417,7 +11415,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, - "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11457,6 +11454,10 @@ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "requires": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "function-bind": "^1.1.2" } @@ -11775,7 +11776,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true + "dev": true, + "peer": true }, "detect-libc": { "version": "2.1.2", @@ -11796,7 +11798,8 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true + "dev": true, + "peer": true }, "dunder-proto": { "version": "1.0.1", @@ -12033,7 +12036,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, - "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12076,6 +12078,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.1.tgz", "integrity": "sha512-qhabwjQZ1Mk53XzXvmogf8KQ0tG0CQXF0CZ56+2/lVhmObgmaqj7x5A1DSrWdZd3kwI7GTPGUjFne+krRxYmFg==", "dev": true, + "peer": true, "requires": { "@next/eslint-plugin-next": "16.2.1", "eslint-import-resolver-node": "^0.3.6", @@ -13296,7 +13299,8 @@ "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 + "dev": true, + "peer": true }, "magic-string": { "version": "0.30.21", @@ -13638,7 +13642,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "optional": true } } @@ -13676,6 +13679,7 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, + "peer": true, "requires": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -13686,13 +13690,15 @@ "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 + "dev": true, + "peer": true }, "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 + "dev": true, + "peer": true } } }, @@ -13743,14 +13749,12 @@ "react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "peer": true + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==" }, "react-dom": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "peer": true, "requires": { "scheduler": "^0.27.0" } @@ -13765,7 +13769,6 @@ "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "peer": true, "requires": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -13802,8 +13805,7 @@ "redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "peer": true + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, "redux-thunk": { "version": "3.1.0", @@ -14372,8 +14374,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "peer": true + "dev": true } } }, @@ -14552,8 +14553,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "peer": true + "dev": true }, "typescript-eslint": { "version": "8.56.0", @@ -14697,8 +14697,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "peer": true + "dev": true } } }, @@ -14899,8 +14898,7 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, - "peer": true + "dev": true }, "zod-validation-error": { "version": "4.0.2", diff --git a/frontend/src/components/dashboard/SubscribersTable.tsx b/frontend/src/components/dashboard/SubscribersTable.tsx index 7aa99a44..f65ba540 100644 --- a/frontend/src/components/dashboard/SubscribersTable.tsx +++ b/frontend/src/components/dashboard/SubscribersTable.tsx @@ -2,10 +2,10 @@ import React, { useState, useMemo } from 'react'; import Image from 'next/image'; -import { Search, Download, ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Search, Download } from 'lucide-react'; import Badge from '../ui/Badge'; +import DataTable, { ColumnDef, SortState } from '../ui/DataTable'; -// Mock Data type SubscriberStatus = 'Active' | 'Cancelled' | 'Past Due'; interface Subscriber { @@ -36,282 +36,144 @@ const MOCK_DATA: Subscriber[] = [ { id: '12', name: 'Laura Dern', email: 'laura@example.com', avatar: 'https://i.pravatar.cc/150?u=12', plan: 'VIP Access', tier: '$20/mo', joinDate: '2023-02-14', renewDate: '2023-03-14', status: 'Active', totalPaid: 120 }, ]; -type SortConfig = { - key: keyof Subscriber | null; - direction: 'asc' | 'desc'; -}; +type SubscriberKey = 'name' | 'plan' | 'joinDate' | 'status' | 'totalPaid'; + +const COLUMNS: ColumnDef[] = [ + { + key: 'name', + header: 'Fan', + sortable: true, + render: (sub) => ( +
+ {sub.name} +
+
{sub.name}
+
{sub.email}
+
+
+ ), + }, + { + key: 'plan', + header: 'Plan', + sortable: true, + render: (sub) => ( +
+
{sub.plan}
+
{sub.tier}
+
+ ), + }, + { + key: 'joinDate', + header: 'Dates', + sortable: true, + render: (sub) => ( +
+
Joined: {sub.joinDate}
+
Renews: {sub.renewDate}
+
+ ), + }, + { + key: 'status', + header: 'Status', + sortable: true, + render: (sub) => { + const variant = sub.status === 'Active' ? 'success' : sub.status === 'Past Due' ? 'error' : 'default'; + return {sub.status}; + }, + }, + { + key: 'totalPaid', + header: 'Total Paid', + sortable: true, + className: 'text-right font-medium text-gray-900 dark:text-white', + headerClassName: 'text-right', + render: (sub) => `$${sub.totalPaid.toFixed(2)}`, + }, +]; export default function SubscribersTable() { const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState('All'); - const [sortConfig, setSortConfig] = useState({ key: 'joinDate', direction: 'desc' }); - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 5; + const [sort, setSort] = useState>({ key: 'joinDate', direction: 'desc' }); + const [page, setPage] = useState(1); - // Filter, Sort, Paginate - const processedData = useMemo(() => { + const filtered = useMemo(() => { let result = MOCK_DATA; - - // Search if (search) { - const lowerSearch = search.toLowerCase(); - result = result.filter(sub => sub.name.toLowerCase().includes(lowerSearch) || sub.email.toLowerCase().includes(lowerSearch)); + const q = search.toLowerCase(); + result = result.filter((s) => s.name.toLowerCase().includes(q) || s.email.toLowerCase().includes(q)); } - - // Filter if (statusFilter !== 'All') { - result = result.filter(sub => sub.status === statusFilter); - } - - // Sort - if (sortConfig.key) { - result = [...result].sort((a, b) => { - if (a[sortConfig.key!] < b[sortConfig.key!]) { - return sortConfig.direction === 'asc' ? -1 : 1; - } - if (a[sortConfig.key!] > b[sortConfig.key!]) { - return sortConfig.direction === 'asc' ? 1 : -1; - } - return 0; - }); + result = result.filter((s) => s.status === statusFilter); } - return result; - }, [search, statusFilter, sortConfig]); - - // Pagination - const totalPages = Math.ceil(processedData.length / itemsPerPage); - const paginatedData = processedData.slice( - (currentPage - 1) * itemsPerPage, - currentPage * itemsPerPage - ); - - // Status Badge Helper - const getStatusBadge = (status: SubscriberStatus) => { - switch (status) { - case 'Active': return Active; - case 'Cancelled': return Cancelled; - case 'Past Due': return Past Due; - default: return {status}; - } - }; - - const handleSort = (key: keyof Subscriber) => { - let direction: 'asc' | 'desc' = 'asc'; - if (sortConfig.key === key && sortConfig.direction === 'asc') { - direction = 'desc'; - } - setSortConfig({ key, direction }); - }; + }, [search, statusFilter]); - const getSortIcon = (columnKey: keyof Subscriber) => { - if (sortConfig.key !== columnKey) return ; - return sortConfig.direction === 'asc' ? : ; - }; + // Reset page when filters change + React.useEffect(() => { setPage(1); }, [search, statusFilter]); - // Export CSV const handleExportCSV = () => { const headers = ['Name', 'Email', 'Plan', 'Tier', 'Join Date', 'Renew Date', 'Status', 'Total Paid']; - const csvContent = [ - headers.join(','), - ...processedData.map(sub => `"${sub.name}","${sub.email}","${sub.plan}","${sub.tier}","${sub.joinDate}","${sub.renewDate}","${sub.status}","${sub.totalPaid}"`) - ].join('\n'); - - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - const link = document.createElement('a'); - if (link.download !== undefined) { - const url = URL.createObjectURL(blob); - link.setAttribute('href', url); - link.setAttribute('download', 'subscribers_export.csv'); - link.style.visibility = 'hidden'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } + const rows = filtered.map((s) => `"${s.name}","${s.email}","${s.plan}","${s.tier}","${s.joinDate}","${s.renewDate}","${s.status}","${s.totalPaid}"`); + const blob = new Blob([[headers.join(','), ...rows].join('\n')], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'subscribers_export.csv'; a.click(); + URL.revokeObjectURL(url); }; - // Reset page when filters change - React.useEffect(() => { - setCurrentPage(1); - }, [search, statusFilter]); - return ( -
+
{/* Controls */} -
-
-
-
- -
- +
+
+ + setSearch(e.target.value)} - className="w-full pl-10 pr-3 py-2.5 sm:py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-colors min-h-11 sm:min-h-0" + aria-label="Search subscribers" + className="w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-colors" />
-
- -
+
-
- {/* Table / Cards */} -
- {/* Desktop Table */} -
- - - - - - - - - - - - {paginatedData.length > 0 ? paginatedData.map((sub, idx) => ( - - - - - - - - )) : ( - - - - )} - -
handleSort('name')}> -
Fan {getSortIcon('name')}
-
handleSort('plan')}> -
Plan {getSortIcon('plan')}
-
handleSort('joinDate')}> -
Dates {getSortIcon('joinDate')}
-
handleSort('status')}> -
Status {getSortIcon('status')}
-
handleSort('totalPaid')}> -
Total Paid {getSortIcon('totalPaid')}
-
-
- {sub.name} -
-
{sub.name}
-
{sub.email}
-
-
-
-
{sub.plan}
-
{sub.tier}
-
-
Joined: {sub.joinDate}
-
Renews: {sub.renewDate}
-
- {getStatusBadge(sub.status)} - - ${sub.totalPaid.toFixed(2)} -
- No subscribers found matching your criteria. -
-
- - {/* Mobile Cards */} -
- {paginatedData.length > 0 ? paginatedData.map((sub) => ( -
-
-
- {sub.name} -
-
{sub.name}
-
{sub.email}
-
-
-
- {getStatusBadge(sub.status)} -
-
- -
-
-
Plan
-
{sub.plan}
-
{sub.tier}
-
-
-
Total Paid
-
${sub.totalPaid.toFixed(2)}
-
-
-
Status
- {getStatusBadge(sub.status)} -
-
-
Joined
-
{sub.joinDate}
-
-
-
Renews
-
{sub.renewDate}
-
-
-
- )) : ( -
- No subscribers found matching your criteria. -
- )} -
- - {/* Pagination Controls */} - {totalPages > 0 && ( -
- - Showing {(currentPage - 1) * itemsPerPage + 1} to {Math.min(currentPage * itemsPerPage, processedData.length)} of {processedData.length} results - -
- - -
-
- )} -
+ + columns={COLUMNS} + data={filtered} + keyExtractor={(s) => s.id} + sort={sort} + onSortChange={setSort} + page={page} + onPageChange={setPage} + pageSize={5} + emptyMessage="No subscribers found matching your criteria." + caption="Subscribers" + />
); } diff --git a/frontend/src/components/earnings/TransactionHistory.tsx b/frontend/src/components/earnings/TransactionHistory.tsx index e55cabea..9faa9df1 100644 --- a/frontend/src/components/earnings/TransactionHistory.tsx +++ b/frontend/src/components/earnings/TransactionHistory.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; import { BaseCard } from '@/components/cards'; import { fetchTransactionHistory, type Transaction } from '@/lib/earnings-api'; +import { transactionsToCSV, downloadCSV } from '@/lib/earnings-export'; interface TransactionHistoryProps { limit?: number; @@ -29,6 +30,7 @@ export function TransactionHistoryCard({ limit = 20 }: TransactionHistoryProps) const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(0); const [total, setTotal] = useState(0); + const [exporting, setExporting] = useState(false); useEffect(() => { const load = async () => { @@ -77,14 +79,48 @@ export function TransactionHistoryCard({ limit = 20 }: TransactionHistoryProps) ); } + const handleExport = async () => { + try { + setExporting(true); + // Fetch all pages for a complete export + const allItems: Transaction[] = []; + let p = 1; + let totalPages = 1; + do { + const res = await fetchTransactionHistory(p, 100); + allItems.push(...res.items); + totalPages = res.total_pages; + p++; + } while (p <= totalPages); + + const csv = transactionsToCSV(allItems); + const date = new Date().toISOString().slice(0, 10); + downloadCSV(csv, `earnings-${date}.csv`); + } catch { + // silently fail; user can retry + } finally { + setExporting(false); + } + }; + const startItem = (page - 1) * limit + 1; const endItem = Math.min(page * limit, total); return ( -

- Transaction History -

+
+

+ Transaction History +

+ +
{transactions.map((tx) => ( diff --git a/frontend/src/components/transactions/TransactionTable.tsx b/frontend/src/components/transactions/TransactionTable.tsx index 785a3106..c48155c9 100644 --- a/frontend/src/components/transactions/TransactionTable.tsx +++ b/frontend/src/components/transactions/TransactionTable.tsx @@ -1,69 +1,67 @@ -import Link from "next/link"; +import Link from 'next/link'; +import DataTable, { ColumnDef } from '../ui/DataTable'; interface Transaction { - id: string; - type: "subscription" | "payment" | "refund"; - status: "pending" | "success" | "failed"; - amount: number; - currency: string; - txHash?: string; - createdAt: string; + id: string; + type: 'subscription' | 'payment' | 'refund'; + status: 'pending' | 'success' | 'failed'; + amount: number; + currency: string; + txHash?: string; + createdAt: string; } -export function TransactionTable({ data }: { data: Transaction[] }) { - return ( -
- - - - - - - - - - +type TxKey = 'type' | 'amount' | 'status' | 'createdAt' | 'txHash'; - - {data.map((tx) => ( - - - - - - - - ))} - -
TypeAmountStatusDateTx
{tx.type} - {tx.amount} {tx.currency} - - - {tx.status} - - - {new Date(tx.createdAt).toLocaleDateString()} - - {tx.txHash ? ( - - View - - ) : ( - "-" - )} -
-
- ); +const STATUS_CLASSES: Record = { + success: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', + failed: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', +}; + +const COLUMNS: ColumnDef[] = [ + { key: 'type', header: 'Type', sortable: true, render: (tx) => {tx.type} }, + { key: 'amount', header: 'Amount', sortable: true, render: (tx) => `${tx.amount} ${tx.currency}` }, + { + key: 'status', + header: 'Status', + sortable: true, + render: (tx) => ( + + {tx.status} + + ), + }, + { key: 'createdAt', header: 'Date', sortable: true, render: (tx) => new Date(tx.createdAt).toLocaleDateString() }, + { + key: 'txHash', + header: 'Tx', + render: (tx) => + tx.txHash ? ( + + View + + ) : ( + '–' + ), + }, +]; + +export function TransactionTable({ data, isLoading, error }: { data: Transaction[]; isLoading?: boolean; error?: string | null }) { + return ( + + columns={COLUMNS} + data={data} + keyExtractor={(tx) => tx.id} + isLoading={isLoading} + error={error} + emptyMessage="No transactions found." + caption="Transactions" + /> + ); } diff --git a/frontend/src/components/ui/DataTable.test.tsx b/frontend/src/components/ui/DataTable.test.tsx new file mode 100644 index 00000000..8801c74f --- /dev/null +++ b/frontend/src/components/ui/DataTable.test.tsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { DataTable, ColumnDef } from './DataTable'; + +interface Row { id: string; name: string; score: number } + +const COLUMNS: ColumnDef[] = [ + { key: 'name', header: 'Name', sortable: true }, + { key: 'score', header: 'Score', sortable: true }, +]; + +const DATA: Row[] = [ + { id: '1', name: 'Alice', score: 80 }, + { id: '2', name: 'Charlie', score: 50 }, + { id: '3', name: 'Bob', score: 95 }, +]; + +const keyExtractor = (r: Row) => r.id; + +describe('DataTable', () => { + // ── Rendering ────────────────────────────────────────────────────────────── + + it('renders column headers', () => { + render(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Score')).toBeInTheDocument(); + }); + + it('renders all rows', () => { + render(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('Charlie')).toBeInTheDocument(); + }); + + it('uses custom render function', () => { + const cols: ColumnDef[] = [ + { key: 'name', header: 'Name', render: (r) => {r.name}! }, + { key: 'score', header: 'Score' }, + ]; + render(); + expect(screen.getAllByTestId('custom')).toHaveLength(3); + }); + + // ── Empty / Loading / Error states ───────────────────────────────────────── + + it('shows empty message when data is empty', () => { + render(); + expect(screen.getByText('Nothing here.')).toBeInTheDocument(); + }); + + it('shows default empty message', () => { + render(); + expect(screen.getByText('No data found.')).toBeInTheDocument(); + }); + + it('shows loading skeleton when isLoading=true', () => { + const { container } = render(); + // TableSkeleton renders divs, not a table + expect(container.querySelector('table')).toBeNull(); + }); + + it('shows error alert when error is provided', () => { + render(); + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('Something went wrong.'); + }); + + it('does not render table when error is set', () => { + const { container } = render(); + expect(container.querySelector('table')).toBeNull(); + }); + + // ── Sorting ──────────────────────────────────────────────────────────────── + + it('sorts ascending on first click', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /name/i })); + const cells = screen.getAllByRole('cell').filter((_, i) => i % 2 === 0); // name column cells + expect(cells[0]).toHaveTextContent('Alice'); + expect(cells[1]).toHaveTextContent('Bob'); + expect(cells[2]).toHaveTextContent('Charlie'); + }); + + it('sorts descending on second click', () => { + render(); + const nameBtn = screen.getByRole('button', { name: /name/i }); + fireEvent.click(nameBtn); + fireEvent.click(nameBtn); + const cells = screen.getAllByRole('cell').filter((_, i) => i % 2 === 0); + expect(cells[0]).toHaveTextContent('Charlie'); + }); + + it('sets aria-sort on active column', () => { + render(); + const nameHeader = screen.getByRole('button', { name: /name/i }); + expect(nameHeader).toHaveAttribute('aria-sort', 'none'); + fireEvent.click(nameHeader); + expect(nameHeader).toHaveAttribute('aria-sort', 'ascending'); + fireEvent.click(nameHeader); + expect(nameHeader).toHaveAttribute('aria-sort', 'descending'); + }); + + it('calls onSortChange when controlled', () => { + const onSortChange = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: /score/i })); + expect(onSortChange).toHaveBeenCalledWith({ key: 'score', direction: 'asc' }); + }); + + // ── Keyboard accessibility ───────────────────────────────────────────────── + + it('triggers sort on Enter key', () => { + render(); + const nameBtn = screen.getByRole('button', { name: /name/i }); + fireEvent.keyDown(nameBtn, { key: 'Enter' }); + expect(nameBtn).toHaveAttribute('aria-sort', 'ascending'); + }); + + it('triggers sort on Space key', () => { + render(); + const nameBtn = screen.getByRole('button', { name: /name/i }); + fireEvent.keyDown(nameBtn, { key: ' ' }); + expect(nameBtn).toHaveAttribute('aria-sort', 'ascending'); + }); + + it('sortable headers have tabIndex=0', () => { + render(); + const nameBtn = screen.getByRole('button', { name: /name/i }); + expect(nameBtn).toHaveAttribute('tabindex', '0'); + }); + + it('non-sortable headers have no tabIndex', () => { + const cols: ColumnDef[] = [ + { key: 'name', header: 'Name', sortable: false }, + { key: 'score', header: 'Score' }, + ]; + render(); + const nameHeader = screen.getByText('Name').closest('th'); + expect(nameHeader).not.toHaveAttribute('tabindex'); + }); + + it('pagination buttons are keyboard accessible', () => { + const manyRows = Array.from({ length: 12 }, (_, i) => ({ id: String(i), name: `User ${i}`, score: i })); + render(); + const nav = screen.getByRole('navigation', { name: /pagination/i }); + const buttons = within(nav).getAllByRole('button'); + buttons.forEach((btn) => expect(btn).not.toHaveAttribute('tabindex', '-1')); + }); + + // ── Pagination ───────────────────────────────────────────────────────────── + + it('paginates data correctly', () => { + const manyRows = Array.from({ length: 7 }, (_, i) => ({ id: String(i), name: `User ${i}`, score: i })); + render(); + expect(screen.getByText('User 0')).toBeInTheDocument(); + expect(screen.queryByText('User 3')).not.toBeInTheDocument(); + }); + + it('navigates to next page', () => { + const manyRows = Array.from({ length: 7 }, (_, i) => ({ id: String(i), name: `User ${i}`, score: i })); + render(); + fireEvent.click(screen.getByRole('button', { name: /next page/i })); + expect(screen.getByText('User 3')).toBeInTheDocument(); + expect(screen.queryByText('User 0')).not.toBeInTheDocument(); + }); + + it('disables previous button on first page', () => { + const manyRows = Array.from({ length: 7 }, (_, i) => ({ id: String(i), name: `User ${i}`, score: i })); + render(); + expect(screen.getByRole('button', { name: /previous page/i })).toBeDisabled(); + }); + + it('disables next button on last page', () => { + const manyRows = Array.from({ length: 7 }, (_, i) => ({ id: String(i), name: `User ${i}`, score: i })); + render(); + fireEvent.click(screen.getByRole('button', { name: /page 3/i })); + expect(screen.getByRole('button', { name: /next page/i })).toBeDisabled(); + }); + + it('calls onPageChange when controlled', () => { + const onPageChange = vi.fn(); + const manyRows = Array.from({ length: 7 }, (_, i) => ({ id: String(i), name: `User ${i}`, score: i })); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: /next page/i })); + expect(onPageChange).toHaveBeenCalledWith(2); + }); + + it('shows pagination range text', () => { + const manyRows = Array.from({ length: 7 }, (_, i) => ({ id: String(i), name: `User ${i}`, score: i })); + render(); + expect(screen.getByText(/1–3/)).toBeInTheDocument(); + expect(screen.getByText(/7/)).toBeInTheDocument(); + }); + + it('does not render pagination when all data fits one page', () => { + render(); + expect(screen.queryByRole('navigation', { name: /pagination/i })).not.toBeInTheDocument(); + }); + + // ── Caption / aria-label ─────────────────────────────────────────────────── + + it('applies caption as aria-label on table', () => { + render(); + expect(screen.getByRole('table', { name: 'My Table' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx new file mode 100644 index 00000000..530457a8 --- /dev/null +++ b/frontend/src/components/ui/DataTable.tsx @@ -0,0 +1,242 @@ +'use client'; + +import React, { useState, useMemo, useCallback } from 'react'; +import { ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight } from 'lucide-react'; +import { TableSkeleton } from './states'; + +export type SortDirection = 'asc' | 'desc'; + +export interface SortState { + key: K | null; + direction: SortDirection; +} + +export interface ColumnDef { + key: K; + header: string; + sortable?: boolean; + className?: string; + headerClassName?: string; + render?: (row: T) => React.ReactNode; +} + +export interface DataTableProps { + columns: ColumnDef[]; + data: T[]; + keyExtractor: (row: T) => string; + isLoading?: boolean; + error?: string | null; + emptyMessage?: string; + pageSize?: number; + /** Controlled sort — pass to manage externally */ + sort?: SortState; + onSortChange?: (sort: SortState) => void; + /** Controlled page — pass to manage externally */ + page?: number; + onPageChange?: (page: number) => void; + /** Total items count for controlled/server-side pagination */ + totalItems?: number; + caption?: string; + className?: string; +} + +function SortIcon({ active, direction }: { active: boolean; direction: SortDirection }) { + if (!active) return ; + return direction === 'asc' + ? + : ; +} + +export function DataTable({ + columns, + data, + keyExtractor, + isLoading = false, + error = null, + emptyMessage = 'No data found.', + pageSize = 10, + sort: controlledSort, + onSortChange, + page: controlledPage, + onPageChange, + totalItems, + caption, + className = '', +}: DataTableProps) { + const [internalSort, setInternalSort] = useState>({ key: null, direction: 'asc' }); + const [internalPage, setInternalPage] = useState(1); + + const isControlledSort = controlledSort !== undefined; + const isControlledPage = controlledPage !== undefined; + + const sort = isControlledSort ? controlledSort : internalSort; + const currentPage = isControlledPage ? controlledPage : internalPage; + + const handleSort = useCallback((key: K) => { + const next: SortState = { + key, + direction: sort.key === key && sort.direction === 'asc' ? 'desc' : 'asc', + }; + if (isControlledSort) onSortChange?.(next); + else setInternalSort(next); + // Reset to page 1 on sort + if (!isControlledPage) setInternalPage(1); + else onPageChange?.(1); + }, [sort, isControlledSort, isControlledPage, onSortChange, onPageChange]); + + const handlePageChange = useCallback((page: number) => { + if (isControlledPage) onPageChange?.(page); + else setInternalPage(page); + }, [isControlledPage, onPageChange]); + + // Client-side sort (skipped when server-side — caller provides pre-sorted data) + const sortedData = useMemo(() => { + if (!sort.key || isControlledSort) return data; + const key = sort.key; + return [...data].sort((a, b) => { + const av = (a as Record)[key]; + const bv = (b as Record)[key]; + if (av == null && bv == null) return 0; + if (av == null) return 1; + if (bv == null) return -1; + const cmp = av < bv ? -1 : av > bv ? 1 : 0; + return sort.direction === 'asc' ? cmp : -cmp; + }); + }, [data, sort, isControlledSort]); + + // Client-side pagination (skipped when server-side — caller provides pre-paged data) + const total = totalItems ?? sortedData.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const pagedData = isControlledPage ? sortedData : sortedData.slice( + (currentPage - 1) * pageSize, + currentPage * pageSize, + ); + + const from = total === 0 ? 0 : (currentPage - 1) * pageSize + 1; + const to = Math.min(currentPage * pageSize, total); + + if (isLoading) { + return 8 ? 5 : pageSize} />; + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+
+ + {caption && } + + + {columns.map((col) => ( + + ))} + + + + {pagedData.length === 0 ? ( + + + + ) : ( + pagedData.map((row, idx) => ( + + {columns.map((col) => ( + + ))} + + )) + )} + +
{caption}
handleSort(col.key) : undefined} + onKeyDown={col.sortable ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleSort(col.key); } } : undefined} + tabIndex={col.sortable ? 0 : undefined} + role={col.sortable ? 'button' : undefined} + aria-sort={ + col.sortable && sort.key === col.key + ? sort.direction === 'asc' ? 'ascending' : 'descending' + : col.sortable ? 'none' : undefined + } + > + + {col.header} + {col.sortable && ( + + )} + +
+ {emptyMessage} +
+ {col.render + ? col.render(row) + : String((row as Record)[col.key] ?? '')} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + {total === 0 ? 'No results' : ( + <>Showing {from}–{to} of {total} + )} + + +
+ )} +
+ ); +} + +export default DataTable; diff --git a/frontend/src/lib/__tests__/earnings-export.test.ts b/frontend/src/lib/__tests__/earnings-export.test.ts new file mode 100644 index 00000000..98c4372c --- /dev/null +++ b/frontend/src/lib/__tests__/earnings-export.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { transactionsToCSV, downloadCSV } from '../earnings-export'; +import type { Transaction } from '../earnings-api'; + +const TX: Transaction = { + id: 'tx-1', + date: '2024-03-15T10:30:00.000Z', + type: 'subscription', + description: 'Monthly plan', + amount: '10.00', + currency: 'USDC', + status: 'completed', + tx_hash: 'abc123', +}; + +describe('transactionsToCSV', () => { + it('includes header row', () => { + const csv = transactionsToCSV([TX], 'UTC'); + const [header] = csv.split('\n'); + expect(header).toBe('ID,Date (UTC),Date (Local),Type,Description,Amount,Currency,Status,TX Hash'); + }); + + it('produces correct UTC timestamp', () => { + const csv = transactionsToCSV([TX], 'UTC'); + expect(csv).toContain('2024-03-15T10:30:00.000Z'); + }); + + it('includes timezone label in local timestamp', () => { + const csv = transactionsToCSV([TX], 'America/New_York'); + expect(csv).toContain('America/New_York'); + }); + + it('returns only header for empty array', () => { + const csv = transactionsToCSV([], 'UTC'); + expect(csv.trim()).toBe('ID,Date (UTC),Date (Local),Type,Description,Amount,Currency,Status,TX Hash'); + }); + + it('escapes commas in description', () => { + const tx: Transaction = { ...TX, description: 'Plan, premium' }; + const csv = transactionsToCSV([tx], 'UTC'); + expect(csv).toContain('"Plan, premium"'); + }); + + it('escapes double quotes in description', () => { + const tx: Transaction = { ...TX, description: 'Say "hello"' }; + const csv = transactionsToCSV([tx], 'UTC'); + expect(csv).toContain('"Say ""hello"""'); + }); + + it('handles missing tx_hash gracefully', () => { + const tx: Transaction = { ...TX, tx_hash: undefined }; + const csv = transactionsToCSV([tx], 'UTC'); + const dataRow = csv.split('\n')[1]; + expect(dataRow.endsWith(',')).toBe(true); // last cell is empty + }); + + it('generates one data row per transaction', () => { + const csv = transactionsToCSV([TX, TX], 'UTC'); + expect(csv.split('\n').length).toBe(3); // header + 2 rows + }); +}); + +describe('downloadCSV', () => { + let createObjectURL: ReturnType; + let revokeObjectURL: ReturnType; + let clickSpy: ReturnType; + + beforeEach(() => { + createObjectURL = vi.fn(() => 'blob:mock'); + revokeObjectURL = vi.fn(); + clickSpy = vi.fn(); + + global.URL.createObjectURL = createObjectURL; + global.URL.revokeObjectURL = revokeObjectURL; + + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + if (tag === 'a') { + return { href: '', download: '', click: clickSpy } as unknown as HTMLAnchorElement; + } + return document.createElement(tag); + }); + }); + + afterEach(() => vi.restoreAllMocks()); + + it('creates a blob URL and triggers click', () => { + downloadCSV('col1\nval1', 'test.csv'); + expect(createObjectURL).toHaveBeenCalledOnce(); + expect(clickSpy).toHaveBeenCalledOnce(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock'); + }); + + it('sets the correct filename', () => { + let capturedLink: { download: string } | null = null; + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + if (tag === 'a') { + capturedLink = { href: '', download: '', click: clickSpy } as unknown as HTMLAnchorElement; + return capturedLink as unknown as HTMLAnchorElement; + } + return document.createElement(tag); + }); + + downloadCSV('data', 'earnings-2024-03-15.csv'); + expect(capturedLink?.download).toBe('earnings-2024-03-15.csv'); + }); +}); diff --git a/frontend/src/lib/earnings-export.ts b/frontend/src/lib/earnings-export.ts new file mode 100644 index 00000000..e57b23ba --- /dev/null +++ b/frontend/src/lib/earnings-export.ts @@ -0,0 +1,45 @@ +import type { Transaction } from './earnings-api'; + +const CSV_HEADERS = ['ID', 'Date (UTC)', 'Date (Local)', 'Type', 'Description', 'Amount', 'Currency', 'Status', 'TX Hash']; + +function escapeCell(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +export function transactionsToCSV(transactions: Transaction[], timezone?: string): string { + const tz = timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + + const rows = transactions.map((tx) => { + const d = new Date(tx.date); + const utcTimestamp = d.toISOString(); + const localTimestamp = d.toLocaleString('en-US', { timeZone: tz, hour12: false }) + .replace(',', ''); // "MM/DD/YYYY HH:MM:SS" + + return [ + tx.id, + utcTimestamp, + `${localTimestamp} (${tz})`, + tx.type, + tx.description, + tx.amount, + tx.currency, + tx.status, + tx.tx_hash ?? '', + ].map(escapeCell).join(','); + }); + + return [CSV_HEADERS.join(','), ...rows].join('\n'); +} + +export function downloadCSV(csv: string, filename: string): void { + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +}