diff --git a/package-lock.json b/package-lock.json index 118030e..3068542 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,18 @@ { "name": "@wopr-network/platform-ui-core", - "version": "1.1.6", + "version": "1.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wopr-network/platform-ui-core", - "version": "1.1.6", + "version": "1.1.7", + "license": "UNLICENSED", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@noble/hashes": "^2.0.1", + "@scure/bip32": "^2.0.1", + "@scure/bip39": "^2.0.1", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "@tanstack/react-query": "^5.90.21", @@ -47,6 +51,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^5.1.4", + "@vitest/coverage-v8": "^4.0.18", "jsdom": "^28.0.0", "shadcn": "^3.8.4", "tailwindcss": "^4", @@ -634,6 +639,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@better-auth/core": { "version": "1.4.18", "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.4.18.tgz", @@ -2584,11 +2599,11 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@noble/hashes": { + "node_modules/@noble/curves/node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -2597,6 +2612,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4542,6 +4569,57 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -5391,18 +5469,49 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.1.tgz", + "integrity": "sha512-nZ4RWwGCoGOQRMmU/Q9wlUY540RVRxJZ9lxFsFfy0QV7Zmo5VVBhB6Sl9Xa0KIp2iIs3zWfPlo9LcY1iqbpzCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.1", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.1", + "vitest": "4.1.1" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", + "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", "devOptional": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "chai": "^6.2.2", "tinyrainbow": "^3.0.3" }, "funding": { @@ -5410,13 +5519,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", + "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", + "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -5425,7 +5534,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -5437,9 +5546,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", + "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5450,13 +5559,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", + "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.1", "pathe": "^2.0.3" }, "funding": { @@ -5464,13 +5573,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", + "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.1", + "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -5479,9 +5589,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", + "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", "devOptional": true, "license": "MIT", "funding": { @@ -5489,13 +5599,14 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", + "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.1", + "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, "funding": { @@ -5659,6 +5770,25 @@ "node": ">=4" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -5790,18 +5920,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/better-auth/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/better-call": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.1.8.tgz", @@ -6270,7 +6388,7 @@ "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, + "devOptional": true, "license": "MIT" }, "node_modules/cookie": { @@ -6792,6 +6910,19 @@ "node": ">=16" } }, + "node_modules/eciesjs/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6884,9 +7015,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "devOptional": true, "license": "MIT" }, @@ -7526,6 +7657,16 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -7637,6 +7778,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -8075,6 +8223,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", @@ -8602,6 +8789,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -11528,9 +11756,9 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "devOptional": true, "license": "MIT" }, @@ -11704,6 +11932,19 @@ } } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.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", @@ -12410,31 +12651,31 @@ } }, "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", + "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.1", + "@vitest/mocker": "4.1.1", + "@vitest/pretty-format": "4.1.1", + "@vitest/runner": "4.1.1", + "@vitest/snapshot": "4.1.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "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": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -12450,12 +12691,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@vitest/browser-playwright": "4.1.1", + "@vitest/browser-preview": "4.1.1", + "@vitest/browser-webdriverio": "4.1.1", + "@vitest/ui": "4.1.1", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -12484,6 +12726,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, diff --git a/src/__tests__/amount-selector.test.tsx b/src/__tests__/amount-selector.test.tsx new file mode 100644 index 0000000..3ec8868 --- /dev/null +++ b/src/__tests__/amount-selector.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { AmountSelector } from "@/components/billing/amount-selector"; + +describe("AmountSelector", () => { + it("renders preset amounts", () => { + render(); + expect(screen.getByText("$10")).toBeInTheDocument(); + expect(screen.getByText("$25")).toBeInTheDocument(); + expect(screen.getByText("$50")).toBeInTheDocument(); + expect(screen.getByText("$100")).toBeInTheDocument(); + }); + + it("calls onSelect with chosen amount", async () => { + const onSelect = vi.fn(); + render(); + await userEvent.click(screen.getByText("$25")); + await userEvent.click(screen.getByRole("button", { name: /continue/i })); + expect(onSelect).toHaveBeenCalledWith(25); + }); + + it("supports custom amount input", async () => { + const onSelect = vi.fn(); + render(); + const input = screen.getByPlaceholderText(/custom/i); + await userEvent.type(input, "75"); + await userEvent.click(screen.getByRole("button", { name: /continue/i })); + expect(onSelect).toHaveBeenCalledWith(75); + }); + + it("disables continue when no amount selected", () => { + render(); + expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled(); + }); +}); diff --git a/src/__tests__/confirmation-tracker.test.tsx b/src/__tests__/confirmation-tracker.test.tsx new file mode 100644 index 0000000..7362697 --- /dev/null +++ b/src/__tests__/confirmation-tracker.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ConfirmationTracker } from "@/components/billing/confirmation-tracker"; + +describe("ConfirmationTracker", () => { + it("shows confirmation progress", () => { + render( + , + ); + expect(screen.getByText(/8/)).toBeInTheDocument(); + expect(screen.getByText(/20/)).toBeInTheDocument(); + expect(screen.getByText(/25\.00 USDT/)).toBeInTheDocument(); + }); + + it("shows credited state", () => { + render( + , + ); + expect(screen.getByText(/credits applied/i)).toBeInTheDocument(); + }); + + it("renders progress bar", () => { + render( + , + ); + const bar = screen.getByRole("progressbar"); + expect(bar).toBeInTheDocument(); + }); + + it("shows tx hash when provided", () => { + render( + , + ); + expect(screen.getByText(/0xabc123/)).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/crypto-checkout.test.tsx b/src/__tests__/crypto-checkout.test.tsx new file mode 100644 index 0000000..b14c526 --- /dev/null +++ b/src/__tests__/crypto-checkout.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("framer-motion", () => ({ + motion: { + div: ({ children, ...props }: Record) =>
{children}
, + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("qrcode.react", () => ({ + QRCodeSVG: ({ value }: { value: string }) =>
{value}
, +})); + +vi.mock("@/lib/api", () => ({ + getSupportedPaymentMethods: vi.fn().mockResolvedValue([ + { id: "BTC:mainnet", type: "native", token: "BTC", chain: "bitcoin", displayName: "Bitcoin", decimals: 8, displayOrder: 0, iconUrl: "" }, + { id: "USDT:tron", type: "erc20", token: "USDT", chain: "tron", displayName: "USDT on Tron", decimals: 6, displayOrder: 1, iconUrl: "" }, + ]), + createCheckout: vi.fn().mockResolvedValue({ + depositAddress: "THwbQb1sPiRUpUYunVQxQKc6i4LCmpP1mj", + displayAmount: "32.24 TRX", + amountUsd: 10, + token: "TRX", + chain: "tron", + referenceId: "trx:test", + }), + getChargeStatus: vi.fn().mockResolvedValue({ + status: "waiting", + amountExpectedCents: 1000, + amountReceivedCents: 0, + confirmations: 0, + confirmationsRequired: 20, + credited: false, + }), + apiFetch: vi.fn(), +})); + +import { CryptoCheckout } from "@/components/billing/crypto-checkout"; + +describe("CryptoCheckout", () => { + it("renders amount selector on mount", async () => { + render(); + await waitFor(() => { + expect(screen.getByText("$25")).toBeInTheDocument(); + }); + }); + + it("advances to payment picker after selecting amount", async () => { + render(); + await waitFor(() => screen.getByText("$25")); + await userEvent.click(screen.getByText("$25")); + await userEvent.click(screen.getByRole("button", { name: /continue/i })); + await waitFor(() => { + expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/deposit-view.test.tsx b/src/__tests__/deposit-view.test.tsx new file mode 100644 index 0000000..cf84531 --- /dev/null +++ b/src/__tests__/deposit-view.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { DepositView } from "@/components/billing/deposit-view"; + +vi.mock("qrcode.react", () => ({ + QRCodeSVG: ({ value }: { value: string }) =>
, +})); + +const CHECKOUT = { + depositAddress: "THwbQb1sPiRUpUYunVQxQKc6i4LCmpP1mj", + displayAmount: "32.24 TRX", + amountUsd: 10, + token: "TRX", + chain: "tron", + referenceId: "trx:test123", +}; + +describe("DepositView", () => { + it("shows deposit address and amount", () => { + render(); + expect(screen.getByText(/32\.24 TRX/)).toBeInTheDocument(); + expect(screen.getByText(/THwbQb1s/)).toBeInTheDocument(); + }); + + it("shows waiting status", () => { + render(); + expect(screen.getByText(/waiting for payment/i)).toBeInTheDocument(); + }); + + it("renders QR code", () => { + render(); + expect(screen.getByTestId("qr")).toBeInTheDocument(); + }); + + it("copies address to clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + render(); + await userEvent.click(screen.getByRole("button", { name: /copy/i })); + expect(writeText).toHaveBeenCalledWith(CHECKOUT.depositAddress); + }); +}); diff --git a/src/__tests__/payment-method-picker.test.tsx b/src/__tests__/payment-method-picker.test.tsx new file mode 100644 index 0000000..dcc7342 --- /dev/null +++ b/src/__tests__/payment-method-picker.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { PaymentMethodPicker } from "@/components/billing/payment-method-picker"; +import type { SupportedPaymentMethod } from "@/lib/api"; + +const METHODS: SupportedPaymentMethod[] = [ + { + id: "BTC:mainnet", + type: "native", + token: "BTC", + chain: "bitcoin", + displayName: "Bitcoin", + decimals: 8, + displayOrder: 0, + iconUrl: "", + }, + { + id: "USDT:tron", + type: "erc20", + token: "USDT", + chain: "tron", + displayName: "USDT on Tron", + decimals: 6, + displayOrder: 1, + iconUrl: "", + }, + { + id: "ETH:base", + type: "native", + token: "ETH", + chain: "base", + displayName: "ETH on Base", + decimals: 18, + displayOrder: 2, + iconUrl: "", + }, + { + id: "USDC:polygon", + type: "erc20", + token: "USDC", + chain: "polygon", + displayName: "USDC on Polygon", + decimals: 6, + displayOrder: 3, + iconUrl: "", + }, + { + id: "DOGE:dogecoin", + type: "native", + token: "DOGE", + chain: "dogecoin", + displayName: "Dogecoin", + decimals: 8, + displayOrder: 4, + iconUrl: "", + }, +]; + +describe("PaymentMethodPicker", () => { + it("renders all methods", () => { + render(); + expect(screen.getByText("Bitcoin")).toBeInTheDocument(); + expect(screen.getByText("USDT on Tron")).toBeInTheDocument(); + }); + + it("filters by search text", async () => { + render(); + await userEvent.type(screen.getByPlaceholderText(/search/i), "tron"); + expect(screen.getByText("USDT on Tron")).toBeInTheDocument(); + expect(screen.queryByText("Bitcoin")).not.toBeInTheDocument(); + }); + + it("filters by Stablecoins pill", async () => { + render(); + await userEvent.click(screen.getByText("Stablecoins")); + expect(screen.getByText("USDT on Tron")).toBeInTheDocument(); + expect(screen.getByText("USDC on Polygon")).toBeInTheDocument(); + expect(screen.queryByText("Bitcoin")).not.toBeInTheDocument(); + }); + + it("calls onSelect when a method is clicked", async () => { + const onSelect = vi.fn(); + render(); + await userEvent.click(screen.getByText("Bitcoin")); + expect(onSelect).toHaveBeenCalledWith(METHODS[0]); + }); +}); diff --git a/src/app/admin/products/error.tsx b/src/app/admin/products/error.tsx new file mode 100644 index 0000000..b93c0a0 --- /dev/null +++ b/src/app/admin/products/error.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; + +export default function ProductsError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("Admin products page error:", error); + }, [error]); + + return ( +
+

Failed to load product configuration.

+

{error.message}

+ +
+ ); +} diff --git a/src/app/admin/products/page.tsx b/src/app/admin/products/page.tsx new file mode 100644 index 0000000..0d564a8 --- /dev/null +++ b/src/app/admin/products/page.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { BillingForm } from "@/components/admin/products/billing-form"; +import { BrandForm } from "@/components/admin/products/brand-form"; +import { FeaturesForm } from "@/components/admin/products/features-form"; +import { FleetForm } from "@/components/admin/products/fleet-form"; +import { NavEditor } from "@/components/admin/products/nav-editor"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { PLATFORM_BASE_URL } from "@/lib/api-config"; +import { toUserMessage } from "@/lib/errors"; +import { getActiveTenantId } from "@/lib/tenant-context"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ProductConfig { + product: { + id: string; + slug: string; + brandName: string; + productName: string; + tagline: string; + domain: string; + appDomain: string; + cookieDomain: string; + companyLegal: string; + priceLabel: string; + defaultImage: string; + emailSupport: string; + emailPrivacy: string; + emailLegal: string; + fromEmail: string; + homePath: string; + storagePrefix: string; + }; + navItems: Array<{ + id: string; + label: string; + href: string; + icon: string | null; + sortOrder: number; + requiresRole: string | null; + enabled: boolean; + }>; + features: { + chatEnabled: boolean; + onboardingEnabled: boolean; + onboardingDefaultModel: string | null; + onboardingMaxCredits: number; + onboardingWelcomeMsg: string | null; + sharedModuleBilling: boolean; + sharedModuleMonitoring: boolean; + sharedModuleAnalytics: boolean; + } | null; + fleet: { + containerImage: string; + containerPort: number; + lifecycle: string; + billingModel: string; + maxInstances: number; + dockerNetwork: string; + placementStrategy: string; + fleetDataDir: string; + } | null; + billing: { + stripePublishableKey: string | null; + creditPrices: Record; + affiliateBaseUrl: string | null; + affiliateMatchRate: string; + affiliateMaxCap: number; + dividendRate: string; + } | null; +} + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- + +async function adminFetch(path: string, init?: RequestInit): Promise { + const tenantId = getActiveTenantId(); + const headers: Record = { + "Content-Type": "application/json", + ...(tenantId ? { "x-tenant-id": tenantId } : {}), + }; + return fetch(`${PLATFORM_BASE_URL}/trpc/${path}`, { + credentials: "include", + ...init, + headers: { ...headers, ...(init?.headers as Record | undefined) }, + }); +} + +async function fetchProductConfig(): Promise { + const res = await adminFetch("product.admin.get"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = (await res.json()) as { result: { data: ProductConfig } }; + return json.result.data; +} + +async function mutateProductConfig(endpoint: string, input: unknown): Promise { + const res = await adminFetch(`product.admin.${endpoint}`, { + method: "POST", + body: JSON.stringify(input), + }); + if (!res.ok) { + const text = await res.text().catch(() => "Unknown error"); + throw new Error(text || `HTTP ${res.status}`); + } + const json = (await res.json()) as { error?: { message?: string } }; + if (json.error) { + throw new Error(json.error.message ?? "Mutation failed"); + } +} + +// --------------------------------------------------------------------------- +// Default values for missing optional sections +// --------------------------------------------------------------------------- + +const DEFAULT_FEATURES: NonNullable = { + chatEnabled: false, + onboardingEnabled: false, + onboardingDefaultModel: null, + onboardingMaxCredits: 0, + onboardingWelcomeMsg: null, + sharedModuleBilling: false, + sharedModuleMonitoring: false, + sharedModuleAnalytics: false, +}; + +const DEFAULT_FLEET: NonNullable = { + containerImage: "", + containerPort: 8080, + lifecycle: "managed", + billingModel: "none", + maxInstances: 10, + dockerNetwork: "platform", + placementStrategy: "round_robin", + fleetDataDir: "/data/fleet", +}; + +const DEFAULT_BILLING: NonNullable = { + stripePublishableKey: null, + creditPrices: {}, + affiliateBaseUrl: null, + affiliateMatchRate: "0.10", + affiliateMaxCap: 0, + dividendRate: "0.05", +}; + +// --------------------------------------------------------------------------- +// Page component +// --------------------------------------------------------------------------- + +export default function AdminProductsPage() { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setLoadError(null); + try { + const data = await fetchProductConfig(); + setConfig(data); + } catch (err) { + setLoadError(toUserMessage(err, "Failed to load product configuration")); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (loadError || !config) { + return ( +
+

{loadError ?? "No configuration found."}

+
+ ); + } + + return ( +
+

Product Configuration

+ + + + Brand + Navigation + Features + Fleet + Billing + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/src/components/admin/products/billing-form.tsx b/src/components/admin/products/billing-form.tsx new file mode 100644 index 0000000..e8155f6 --- /dev/null +++ b/src/components/admin/products/billing-form.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toUserMessage } from "@/lib/errors"; + +interface BillingConfig { + stripePublishableKey: string | null; + creditPrices: Record; + affiliateBaseUrl: string | null; + affiliateMatchRate: string; + affiliateMaxCap: number; + dividendRate: string; +} + +interface BillingFormProps { + initial: BillingConfig; + onSave: (endpoint: string, data: unknown) => Promise; +} + +export function BillingForm({ initial, onSave }: BillingFormProps) { + const [form, setForm] = useState(initial); + const [saving, setSaving] = useState(false); + + function setStr(key: keyof BillingConfig, value: string) { + setForm((prev) => ({ ...prev, [key]: value || null })); + } + + function setRate(key: "affiliateMatchRate" | "dividendRate", value: string) { + setForm((prev) => ({ ...prev, [key]: value })); + } + + function setNum(key: "affiliateMaxCap", value: string) { + setForm((prev) => ({ ...prev, [key]: value === "" ? 0 : Number.parseInt(value, 10) })); + } + + function setCreditPrice(tier: string, value: string) { + const n = Number.parseFloat(value); + setForm((prev) => ({ + ...prev, + creditPrices: { ...prev.creditPrices, [tier]: Number.isNaN(n) ? 0 : n }, + })); + } + + async function handleSave() { + setSaving(true); + try { + await onSave("updateBilling", form); + toast.success("Billing settings saved."); + } catch (err) { + toast.error(toUserMessage(err, "Failed to save billing settings")); + } finally { + setSaving(false); + } + } + + const creditTiers = Object.keys(form.creditPrices); + + return ( + + + Billing Configuration + + +
+ + setStr("stripePublishableKey", e.target.value)} + placeholder="pk_live_..." + /> +

Publishable key only — never the secret.

+
+ + {creditTiers.length > 0 && ( +
+

Credit Price Tiers

+
+ {creditTiers.map((tier) => ( +
+ + setCreditPrice(tier, e.target.value)} + min={0} + /> +
+ ))} +
+
+ )} + +
+

Affiliate & Dividends

+
+
+ + setStr("affiliateBaseUrl", e.target.value)} + placeholder="https://wopr.bot/ref" + /> +
+
+ + setRate("affiliateMatchRate", e.target.value)} + placeholder="0.10" + /> +

Decimal, e.g. 0.10 = 10%

+
+
+ + setNum("affiliateMaxCap", e.target.value)} + min={0} + /> +
+
+ + setRate("dividendRate", e.target.value)} + placeholder="0.05" + /> +

Decimal, e.g. 0.05 = 5%

+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/src/components/admin/products/brand-form.tsx b/src/components/admin/products/brand-form.tsx new file mode 100644 index 0000000..bd61d88 --- /dev/null +++ b/src/components/admin/products/brand-form.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toUserMessage } from "@/lib/errors"; + +interface BrandConfig { + id: string; + slug: string; + brandName: string; + productName: string; + tagline: string; + domain: string; + appDomain: string; + cookieDomain: string; + companyLegal: string; + priceLabel: string; + defaultImage: string; + emailSupport: string; + emailPrivacy: string; + emailLegal: string; + fromEmail: string; + homePath: string; + storagePrefix: string; +} + +interface BrandFormProps { + initial: BrandConfig; + onSave: (endpoint: string, data: unknown) => Promise; +} + +export function BrandForm({ initial, onSave }: BrandFormProps) { + const [form, setForm] = useState(initial); + const [saving, setSaving] = useState(false); + + function set(key: keyof BrandConfig, value: string) { + setForm((prev) => ({ ...prev, [key]: value })); + } + + async function handleSave() { + setSaving(true); + try { + await onSave("updateBrand", form); + toast.success("Brand settings saved."); + } catch (err) { + toast.error(toUserMessage(err, "Failed to save brand settings")); + } finally { + setSaving(false); + } + } + + const fields: Array<{ key: keyof BrandConfig; label: string; placeholder?: string }> = [ + { key: "brandName", label: "Brand Name", placeholder: "WOPR" }, + { key: "productName", label: "Product Name", placeholder: "WOPR Platform" }, + { key: "tagline", label: "Tagline", placeholder: "Your AI platform" }, + { key: "slug", label: "Slug", placeholder: "wopr" }, + { key: "domain", label: "Domain", placeholder: "wopr.bot" }, + { key: "appDomain", label: "App Domain", placeholder: "app.wopr.bot" }, + { key: "cookieDomain", label: "Cookie Domain", placeholder: ".wopr.bot" }, + { key: "companyLegal", label: "Company Legal Name", placeholder: "WOPR Inc." }, + { key: "priceLabel", label: "Price Label", placeholder: "credits" }, + { key: "defaultImage", label: "Default Image URL", placeholder: "/og-image.png" }, + { key: "emailSupport", label: "Support Email", placeholder: "support@wopr.bot" }, + { key: "emailPrivacy", label: "Privacy Email", placeholder: "privacy@wopr.bot" }, + { key: "emailLegal", label: "Legal Email", placeholder: "legal@wopr.bot" }, + { key: "fromEmail", label: "From Email", placeholder: "noreply@wopr.bot" }, + { key: "homePath", label: "Home Path", placeholder: "/dashboard" }, + { key: "storagePrefix", label: "Storage Prefix", placeholder: "wopr" }, + ]; + + return ( + + + Brand Configuration + + +
+ {fields.map(({ key, label, placeholder }) => ( +
+ + set(key, e.target.value)} + placeholder={placeholder} + /> +
+ ))} +
+
+ +
+
+
+ ); +} diff --git a/src/components/admin/products/features-form.tsx b/src/components/admin/products/features-form.tsx new file mode 100644 index 0000000..c8f0e4b --- /dev/null +++ b/src/components/admin/products/features-form.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toUserMessage } from "@/lib/errors"; + +interface FeaturesConfig { + chatEnabled: boolean; + onboardingEnabled: boolean; + onboardingDefaultModel: string | null; + onboardingMaxCredits: number; + onboardingWelcomeMsg: string | null; + sharedModuleBilling: boolean; + sharedModuleMonitoring: boolean; + sharedModuleAnalytics: boolean; +} + +interface FeaturesFormProps { + initial: FeaturesConfig; + onSave: (endpoint: string, data: unknown) => Promise; +} + +export function FeaturesForm({ initial, onSave }: FeaturesFormProps) { + const [form, setForm] = useState(initial); + const [saving, setSaving] = useState(false); + + function setBool(key: keyof FeaturesConfig, value: boolean) { + setForm((prev) => ({ ...prev, [key]: value })); + } + + function setStr(key: keyof FeaturesConfig, value: string) { + setForm((prev) => ({ ...prev, [key]: value || null })); + } + + function setNum(key: keyof FeaturesConfig, value: string) { + const n = Number.parseInt(value, 10); + if (!Number.isNaN(n)) setForm((prev) => ({ ...prev, [key]: n })); + } + + async function handleSave() { + setSaving(true); + try { + await onSave("updateFeatures", form); + toast.success("Feature settings saved."); + } catch (err) { + toast.error(toUserMessage(err, "Failed to save feature settings")); + } finally { + setSaving(false); + } + } + + const boolFields: Array<{ key: keyof FeaturesConfig; label: string }> = [ + { key: "chatEnabled", label: "Chat Enabled" }, + { key: "onboardingEnabled", label: "Onboarding Enabled" }, + { key: "sharedModuleBilling", label: "Shared Module: Billing" }, + { key: "sharedModuleMonitoring", label: "Shared Module: Monitoring" }, + { key: "sharedModuleAnalytics", label: "Shared Module: Analytics" }, + ]; + + return ( + + + Feature Flags + + +
+ {boolFields.map(({ key, label }) => ( +
+ setBool(key, Boolean(checked))} + /> + +
+ ))} +
+ +
+

Onboarding

+
+
+ + setStr("onboardingDefaultModel", e.target.value)} + placeholder="claude-3-5-sonnet" + /> +
+
+ + setNum("onboardingMaxCredits", e.target.value)} + min={0} + /> +
+
+ + setStr("onboardingWelcomeMsg", e.target.value)} + placeholder="Welcome to the platform!" + /> +
+
+
+ +
+ +
+
+
+ ); +} diff --git a/src/components/admin/products/fleet-form.tsx b/src/components/admin/products/fleet-form.tsx new file mode 100644 index 0000000..be5fcaf --- /dev/null +++ b/src/components/admin/products/fleet-form.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toUserMessage } from "@/lib/errors"; + +interface FleetConfig { + containerImage: string; + containerPort: number; + lifecycle: string; + billingModel: string; + maxInstances: number; + dockerNetwork: string; + placementStrategy: string; + fleetDataDir: string; +} + +interface FleetFormProps { + initial: FleetConfig; + onSave: (endpoint: string, data: unknown) => Promise; +} + +export function FleetForm({ initial, onSave }: FleetFormProps) { + const [form, setForm] = useState(initial); + const [saving, setSaving] = useState(false); + + function setStr(key: keyof FleetConfig, value: string) { + setForm((prev) => ({ ...prev, [key]: value })); + } + + function setNum(key: keyof FleetConfig, value: string) { + setForm((prev) => ({ ...prev, [key]: value === "" ? 0 : Number.parseInt(value, 10) })); + } + + async function handleSave() { + setSaving(true); + try { + await onSave("updateFleet", form); + toast.success("Fleet settings saved."); + } catch (err) { + toast.error(toUserMessage(err, "Failed to save fleet settings")); + } finally { + setSaving(false); + } + } + + return ( + + + Fleet Configuration + + +
+
+ + setStr("containerImage", e.target.value)} + placeholder="ghcr.io/wopr-network/agent:latest" + /> +
+ +
+ + setNum("containerPort", e.target.value)} + min={1} + max={65535} + /> +
+ +
+ + setNum("maxInstances", e.target.value)} + min={1} + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + setStr("dockerNetwork", e.target.value)} + placeholder="platform" + /> +
+ +
+ + setStr("fleetDataDir", e.target.value)} + placeholder="/data/fleet" + /> +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/components/admin/products/nav-editor.tsx b/src/components/admin/products/nav-editor.tsx new file mode 100644 index 0000000..4be1bac --- /dev/null +++ b/src/components/admin/products/nav-editor.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toUserMessage } from "@/lib/errors"; + +interface NavItem { + id: string; + label: string; + href: string; + icon: string | null; + sortOrder: number; + requiresRole: string | null; + enabled: boolean; +} + +interface NavEditorProps { + initial: NavItem[]; + onSave: (endpoint: string, data: unknown) => Promise; +} + +function newItem(sortOrder: number): NavItem { + return { + id: crypto.randomUUID(), + label: "", + href: "", + icon: null, + sortOrder, + requiresRole: null, + enabled: true, + }; +} + +export function NavEditor({ initial, onSave }: NavEditorProps) { + const [items, setItems] = useState( + [...initial].sort((a, b) => a.sortOrder - b.sortOrder), + ); + const [saving, setSaving] = useState(false); + + function update(id: string, patch: Partial) { + setItems((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item))); + } + + function remove(id: string) { + setItems((prev) => prev.filter((item) => item.id !== id)); + } + + function move(index: number, direction: "up" | "down") { + setItems((prev) => { + const next = [...prev]; + const target = direction === "up" ? index - 1 : index + 1; + if (target < 0 || target >= next.length) return prev; + [next[index], next[target]] = [next[target], next[index]]; + return next.map((item, i) => ({ ...item, sortOrder: i })); + }); + } + + function addItem() { + setItems((prev) => [...prev, newItem(prev.length)]); + } + + async function handleSave() { + setSaving(true); + try { + const normalized = items.map((item, i) => ({ ...item, sortOrder: i })); + await onSave("updateNavItems", normalized); + toast.success("Navigation saved."); + } catch (err) { + toast.error(toUserMessage(err, "Failed to save navigation")); + } finally { + setSaving(false); + } + } + + return ( + + + Navigation Items + + + {items.length === 0 && ( +

No navigation items. Add one below.

+ )} + {items.map((item, index) => ( +
+
+ + +
+
+
+ + update(item.id, { label: e.target.value })} + placeholder="Dashboard" + className="h-8" + /> +
+
+ + update(item.id, { href: e.target.value })} + placeholder="/dashboard" + className="h-8" + /> +
+
+ + update(item.id, { icon: e.target.value || null })} + placeholder="LayoutDashboard" + className="h-8" + /> +
+
+ + update(item.id, { requiresRole: e.target.value || null })} + placeholder="platform_admin" + className="h-8" + /> +
+
+
+ update(item.id, { enabled: Boolean(checked) })} + /> + +
+ +
+ ))} +
+ + +
+
+
+ ); +} diff --git a/src/components/billing/amount-selector.tsx b/src/components/billing/amount-selector.tsx new file mode 100644 index 0000000..38e785c --- /dev/null +++ b/src/components/billing/amount-selector.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +const PRESETS = [10, 25, 50, 100]; + +interface AmountSelectorProps { + onSelect: (amount: number) => void; +} + +export function AmountSelector({ onSelect }: AmountSelectorProps) { + const [selected, setSelected] = useState(null); + const [custom, setCustom] = useState(""); + + const activeAmount = custom ? Number(custom) : selected; + const isValid = activeAmount !== null && activeAmount >= 10 && Number.isFinite(activeAmount); + + return ( +
+
+ {PRESETS.map((amt) => ( + + ))} +
+ { + setCustom(e.target.value); + setSelected(null); + }} + /> + +
+ ); +} diff --git a/src/components/billing/buy-crypto-credits-panel.tsx b/src/components/billing/buy-crypto-credits-panel.tsx index 83ab95c..4af204e 100644 --- a/src/components/billing/buy-crypto-credits-panel.tsx +++ b/src/components/billing/buy-crypto-credits-panel.tsx @@ -1,304 +1,3 @@ "use client"; -import { motion } from "framer-motion"; -import { Check, CircleDollarSign, Copy } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - type CheckoutResult, - createCheckout, - getChargeStatus, - getSupportedPaymentMethods, - type SupportedPaymentMethod, -} from "@/lib/api"; -import { cn } from "@/lib/utils"; - -type PaymentProgress = { - status: "waiting" | "partial" | "confirming" | "credited" | "expired" | "failed"; - amountExpectedCents: number; - amountReceivedCents: number; - confirmations: number; - confirmationsRequired: number; -}; - -const AMOUNTS = [ - { value: 10, label: "$10" }, - { value: 25, label: "$25" }, - { value: 50, label: "$50" }, - { value: 100, label: "$100" }, -]; - -function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [text]); - - return ( - - ); -} - -export function BuyCryptoCreditPanel() { - const [methods, setMethods] = useState([]); - const [selectedMethod, setSelectedMethod] = useState(null); - const [selectedAmount, setSelectedAmount] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [checkout, setCheckout] = useState(null); - const [paymentProgress, setPaymentProgress] = useState(null); - - // Poll charge status after checkout - useEffect(() => { - if (!checkout?.referenceId) { - setPaymentProgress(null); - return; - } - setPaymentProgress({ - status: "waiting", - amountExpectedCents: 0, - amountReceivedCents: 0, - confirmations: 0, - confirmationsRequired: 0, - }); - const interval = setInterval(async () => { - try { - const res = await getChargeStatus(checkout.referenceId); - let status: PaymentProgress["status"] = "waiting"; - if (res.credited) { - status = "credited"; - } else if (res.status === "expired" || res.status === "failed") { - status = res.status; - } else if ( - res.amountReceivedCents >= res.amountExpectedCents && - res.amountReceivedCents > 0 - ) { - status = "confirming"; - } else if (res.amountReceivedCents > 0) { - status = "partial"; - } - setPaymentProgress({ - status, - amountExpectedCents: res.amountExpectedCents, - amountReceivedCents: res.amountReceivedCents, - confirmations: res.confirmations, - confirmationsRequired: res.confirmationsRequired, - }); - if (status === "credited" || status === "expired" || status === "failed") { - clearInterval(interval); - } - } catch { - // Ignore poll errors - } - }, 5000); - return () => clearInterval(interval); - }, [checkout?.referenceId]); - - // Fetch available payment methods from backend on mount - useEffect(() => { - getSupportedPaymentMethods() - .then((m) => { - if (m.length > 0) { - setMethods(m); - setSelectedMethod(m[0]); - } - }) - .catch(() => { - // Backend unavailable — panel stays empty - }); - }, []); - - async function handleCheckout() { - if (selectedAmount === null || !selectedMethod) return; - setLoading(true); - setError(null); - try { - const result = await createCheckout(selectedMethod.id, selectedAmount); - setCheckout(result); - } catch { - setError("Checkout failed. Please try again."); - } finally { - setLoading(false); - } - } - - function handleReset() { - setCheckout(null); - setSelectedAmount(null); - setError(null); - } - - if (methods.length === 0) return null; - - return ( - - - - - - Pay with Crypto - -
- {methods.map((m) => ( - - ))} -
-
- - {checkout ? ( - -
-
-

- Send{" "} - - {checkout.displayAmount} - {" "} - to: -

- - {checkout.token} on {checkout.chain} - -
-
- - {checkout.depositAddress} - - -
- {checkout.priceCents && ( -

- Price at checkout: ${(checkout.priceCents / 100).toFixed(2)} per{" "} - {checkout.token} -

- )} -

- Only send {checkout.token} on the {checkout.chain} network. -

- {paymentProgress?.status === "waiting" && ( -

Waiting for payment...

- )} - {paymentProgress?.status === "partial" && ( -

- Received ${(paymentProgress.amountReceivedCents / 100).toFixed(2)} of $ - {(paymentProgress.amountExpectedCents / 100).toFixed(2)} — send $ - {( - (paymentProgress.amountExpectedCents - paymentProgress.amountReceivedCents) / - 100 - ).toFixed(2)}{" "} - more -

- )} - {paymentProgress?.status === "confirming" && ( -

- Payment received. Confirming ({paymentProgress.confirmations} of{" "} - {paymentProgress.confirmationsRequired})... -

- )} - {paymentProgress?.status === "credited" && ( -

- Payment confirmed! Credits added. -

- )} - {paymentProgress?.status === "expired" && ( -

Payment expired.

- )} - {paymentProgress?.status === "failed" && ( -

Payment failed.

- )} -
- {paymentProgress?.status === "credited" ? ( - - ) : paymentProgress?.status === "expired" || paymentProgress?.status === "failed" ? ( -
- -
- ) : ( - - )} -
- ) : ( - <> -
- {AMOUNTS.map((amt) => ( - setSelectedAmount(amt.value)} - whileHover={{ scale: 1.03 }} - whileTap={{ scale: 0.98 }} - transition={{ type: "spring", stiffness: 400, damping: 25 }} - className={cn( - "flex flex-col items-center gap-1 rounded-md border p-3 text-sm font-medium transition-colors hover:bg-accent", - selectedAmount === amt.value - ? "border-primary bg-primary/5 ring-1 ring-primary shadow-[0_0_15px_rgba(0,255,65,0.3)]" - : "border-border", - )} - > - {amt.label} - - ))} -
- - {error &&

{error}

} - - - - )} -
-
-
- ); -} +export { CryptoCheckout as BuyCryptoCreditPanel } from "./crypto-checkout"; diff --git a/src/components/billing/confirmation-tracker.tsx b/src/components/billing/confirmation-tracker.tsx new file mode 100644 index 0000000..3b06fee --- /dev/null +++ b/src/components/billing/confirmation-tracker.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { Check } from "lucide-react"; + +interface ConfirmationTrackerProps { + confirmations: number; + confirmationsRequired: number; + displayAmount: string; + credited: boolean; + txHash?: string; +} + +export function ConfirmationTracker({ + confirmations, + confirmationsRequired, + displayAmount, + credited, + txHash, +}: ConfirmationTrackerProps) { + const pct = + confirmationsRequired > 0 + ? Math.min(100, Math.round((confirmations / confirmationsRequired) * 100)) + : 0; + const detected = confirmations > 0 || credited; + + return ( +
+

+ {credited ? "Payment complete!" : "Payment received!"} +

+

{displayAmount}

+ +
+
+ Confirmations + + {confirmations} / {confirmationsRequired} + +
+
+
+
+
+ +
+
+
+ {detected && } +
+ + Payment detected + +
+
+
+ {credited ? ( + + ) : detected ? ( + · + ) : null} +
+ + {credited ? "Confirmed" : "Confirming on chain"} + +
+
+
+ {credited && } +
+ + Credits applied + +
+
+ + {txHash && ( +

+ tx: {txHash} +

+ )} +
+ ); +} diff --git a/src/components/billing/crypto-checkout.tsx b/src/components/billing/crypto-checkout.tsx new file mode 100644 index 0000000..0617d76 --- /dev/null +++ b/src/components/billing/crypto-checkout.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { CircleDollarSign } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + type CheckoutResult, + type SupportedPaymentMethod, + createCheckout, + getChargeStatus, + getSupportedPaymentMethods, +} from "@/lib/api"; +import { AmountSelector } from "./amount-selector"; +import { ConfirmationTracker } from "./confirmation-tracker"; +import { DepositView } from "./deposit-view"; +import { PaymentMethodPicker } from "./payment-method-picker"; + +type Step = "amount" | "method" | "deposit" | "confirming"; +type PaymentStatus = "waiting" | "partial" | "confirming" | "credited" | "expired" | "failed"; + +export function CryptoCheckout() { + const [step, setStep] = useState("amount"); + const [methods, setMethods] = useState([]); + const [amountUsd, setAmountUsd] = useState(0); + const [checkout, setCheckout] = useState(null); + const [status, setStatus] = useState("waiting"); + const [confirmations, setConfirmations] = useState(0); + const [confirmationsRequired, setConfirmationsRequired] = useState(0); + const [loading, setLoading] = useState(false); + + useEffect(() => { + getSupportedPaymentMethods().then(setMethods).catch(() => {}); + }, []); + + useEffect(() => { + if (!checkout?.referenceId) return; + const interval = setInterval(async () => { + try { + const res = await getChargeStatus(checkout.referenceId); + setConfirmations(res.confirmations); + setConfirmationsRequired(res.confirmationsRequired); + if (res.credited) { + setStatus("credited"); + setStep("confirming"); + clearInterval(interval); + } else if (res.status === "expired" || res.status === "failed") { + setStatus(res.status as PaymentStatus); + clearInterval(interval); + } else if (res.amountReceivedCents > 0 && res.amountReceivedCents >= res.amountExpectedCents) { + setStatus("confirming"); + setStep("confirming"); + } else if (res.amountReceivedCents > 0) { + setStatus("partial"); + } + } catch { + /* ignore poll errors */ + } + }, 5000); + return () => clearInterval(interval); + }, [checkout?.referenceId]); + + const handleAmount = useCallback((amt: number) => { + setAmountUsd(amt); + setStep("method"); + }, []); + + const handleMethod = useCallback( + async (method: SupportedPaymentMethod) => { + setLoading(true); + try { + const result = await createCheckout(method.id, amountUsd); + setCheckout(result); + setStatus("waiting"); + setStep("deposit"); + } catch { + // Stay on method step + } finally { + setLoading(false); + } + }, + [amountUsd], + ); + + const handleReset = useCallback(() => { + setStep("amount"); + setCheckout(null); + setStatus("waiting"); + setAmountUsd(0); + setConfirmations(0); + }, []); + + if (methods.length === 0) return null; + + return ( + + + + + + Pay with Crypto + + + + + {step === "amount" && ( + + + + )} + {step === "method" && ( + + setStep("amount")} + /> + {loading && ( +

Creating checkout...

+ )} +
+ )} + {step === "deposit" && checkout && ( + + setStep("method")} /> + + )} + {step === "confirming" && checkout && ( + + + {status === "credited" && ( + + )} + + )} +
+
+
+
+ ); +} diff --git a/src/components/billing/deposit-view.tsx b/src/components/billing/deposit-view.tsx new file mode 100644 index 0000000..a33307e --- /dev/null +++ b/src/components/billing/deposit-view.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { Check, Copy } from "lucide-react"; +import { QRCodeSVG } from "qrcode.react"; +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import type { CheckoutResult } from "@/lib/api"; + +interface DepositViewProps { + checkout: CheckoutResult; + status: "waiting" | "partial" | "confirming" | "credited" | "expired" | "failed"; + onBack: () => void; +} + +export function DepositView({ checkout, status, onBack }: DepositViewProps) { + const [copied, setCopied] = useState(false); + const [timeLeft, setTimeLeft] = useState(30 * 60); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(checkout.depositAddress); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [checkout.depositAddress]); + + useEffect(() => { + if (status !== "waiting") return; + const timer = setInterval(() => setTimeLeft((t) => Math.max(0, t - 1)), 1000); + return () => clearInterval(timer); + }, [status]); + + const mins = Math.floor(timeLeft / 60); + const secs = timeLeft % 60; + + return ( +
+ +

Send exactly

+

{checkout.displayAmount}

+

+ on {checkout.chain} · ${checkout.amountUsd.toFixed(2)} USD +

+ +
+ {checkout.depositAddress} + +
+
+ {status === "waiting" && ( + <> + + Waiting for payment... + + · {mins}:{secs.toString().padStart(2, "0")} + + + )} + {status === "partial" && ( + <> + + Partial payment received + + )} + {status === "expired" && Payment expired} + {status === "failed" && Payment failed} +
+
+ ); +} diff --git a/src/components/billing/payment-method-picker.tsx b/src/components/billing/payment-method-picker.tsx new file mode 100644 index 0000000..4f24304 --- /dev/null +++ b/src/components/billing/payment-method-picker.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Input } from "@/components/ui/input"; +import type { SupportedPaymentMethod } from "@/lib/api"; +import { cn } from "@/lib/utils"; + +type Filter = "popular" | "stablecoins" | "l2" | "native"; + +const FILTERS: { key: Filter; label: string }[] = [ + { key: "popular", label: "Popular" }, + { key: "stablecoins", label: "Stablecoins" }, + { key: "l2", label: "L2s" }, + { key: "native", label: "Native" }, +]; + +const STABLECOIN_TOKENS = new Set(["USDC", "USDT", "DAI"]); +const L2_CHAINS = new Set(["base", "arbitrum", "optimism", "polygon"]); +const POPULAR_COUNT = 6; + +interface PaymentMethodPickerProps { + methods: SupportedPaymentMethod[]; + onSelect: (method: SupportedPaymentMethod) => void; + onBack?: () => void; +} + +export function PaymentMethodPicker({ + methods, + onSelect, + onBack, +}: PaymentMethodPickerProps) { + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("popular"); + + const filtered = useMemo(() => { + if (search) { + const q = search.toLowerCase(); + return methods.filter( + (m) => + m.token.toLowerCase().includes(q) || + m.chain.toLowerCase().includes(q) || + m.displayName.toLowerCase().includes(q), + ); + } + + switch (filter) { + case "popular": + return methods.slice(0, POPULAR_COUNT); + case "stablecoins": + return methods.filter((m) => STABLECOIN_TOKENS.has(m.token)); + case "l2": + return methods.filter((m) => L2_CHAINS.has(m.chain)); + case "native": + return methods.filter((m) => m.type === "native" && !L2_CHAINS.has(m.chain)); + default: + return methods; + } + }, [methods, search, filter]); + + return ( +
+ {onBack && ( + + )} + setSearch(e.target.value)} + /> +
+ {FILTERS.map((f) => ( + + ))} +
+
+ {filtered.map((m) => ( + + ))} + {filtered.length === 0 && ( +

No payment methods found

+ )} +
+
+ ); +} diff --git a/src/lib/brand-config.ts b/src/lib/brand-config.ts index 1d3c21f..a56399c 100644 --- a/src/lib/brand-config.ts +++ b/src/lib/brand-config.ts @@ -247,3 +247,33 @@ export function eventName(event: string): string { export function envKey(suffix: string): string { return `${_config.envVarPrefix}_${suffix}`; } + +/** + * Fetch brand config from the platform API and apply it. + * Call once in root layout server component. + * Falls back to env var defaults if API unavailable. + * + * @param apiBaseUrl - The platform API base URL (e.g. from NEXT_PUBLIC_API_URL) + */ +export async function initBrandConfig(apiBaseUrl: string): Promise { + try { + const res = await fetch(`${apiBaseUrl}/trpc/product.getBrandConfig`, { + credentials: "include", + next: { revalidate: 60 }, + }); + if (!res.ok) return; + const text = await res.text(); + let json: unknown; + try { + json = JSON.parse(text); + } catch { + return; // Non-JSON response (proxy error, HTML page, etc.) + } + const data = (json as { result?: { data?: unknown } })?.result?.data; + if (data) { + setBrandConfig(data as Partial); + } + } catch { + // API unavailable — env var defaults remain active + } +}