diff --git a/.env.example b/.env.example index f6252e2..a9a259b 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,9 @@ API_KEY_SIGNING_SECRET=replace-with-a-long-random-server-secret NEXT_PUBLIC_VAPID_PUBLIC_KEY=replace-with-vapid-public-key VAPID_PRIVATE_KEY=replace-with-vapid-private-key VAPID_SUBJECT=mailto:support@example.com + +# Optional: enables Vercel Blob CDN storage for branding logo uploads +# (Settings → Branding). Without it, logos are stored in-process and served +# from /api/settings/branding/logo/... with CDN-style immutable cache headers +# (fine for local dev and CI). +BLOB_READ_WRITE_TOKEN= diff --git a/package-lock.json b/package-lock.json index 6ae2363..40c53a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "split-app", "version": "0.1.0", "dependencies": { + "@hookform/resolvers": "^5.5.7", "@react-pdf/renderer": "^3.4.4", "@sentry/nextjs": "^10.62.0", "@stellar-split/sdk": "^0.1.0", @@ -15,6 +16,7 @@ "@stellar/stellar-sdk": "^16.1.0", "@tanstack/react-query": "5.80.7", "@types/canvas-confetti": "1.6.4", + "@vercel/blob": "^2.6.1", "canvas-confetti": "1.9.3", "exceljs": "^4.4.0", "html2canvas": "^1.4.1", @@ -27,6 +29,7 @@ "qrcode.react": "4.2.0", "react": "^18.3.0", "react-dom": "^18.3.0", + "react-hook-form": "^7.83.0", "react-markdown": "^10.1.0", "recharts": "^2.12.7", "remark-gfm": "^4.0.1", @@ -44,6 +47,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.5.0", "@types/node": "^20.0.0", + "@types/qrcode": "^1.5.6", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/web-push": "^3.6.4", @@ -764,6 +768,116 @@ "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", "license": "MIT" }, + "node_modules/@hookform/resolvers": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.5.7.tgz", + "integrity": "sha512-CyPCYV8/KlXfEXLWj8HHHhVsR/IZ6Ckm3b/a4fWtO/lRnRK1huqncb8LlAWrmpPRsse5glF5aVuDRMHEr3UGag==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "@sinclair/typebox": ">=0.25.24", + "@standard-schema/spec": "^1.0.0", + "@typeschema/main": ">=0.13.7", + "@vinejs/vine": "^2.0.0 || ^3.0.0", + "ajv": "^8.12.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "arktype": "^2.0.0", + "ata-validator": "^0.7.0", + "class-transformer": ">=0.4.0", + "class-validator": ">=0.12.0", + "computed-types": "^1.0.0", + "effect": "^3.10.3", + "fluentvalidation-ts": "^3.0.0", + "fp-ts": "^2.7.0", + "io-ts": "^2.0.0", + "joi": "^17.0.0", + "nope-validator": ">=0.12.0", + "react-hook-form": "^7.55.0", + "superstruct": ">=0.12.0", + "typanion": "^3.3.2", + "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", + "vest": ">=3.0.0", + "yup": "^1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + }, + "@standard-schema/spec": { + "optional": true + }, + "@typeschema/main": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "ajv": { + "optional": true + }, + "ajv-errors": { + "optional": true + }, + "ajv-formats": { + "optional": true + }, + "arktype": { + "optional": true + }, + "ata-validator": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "computed-types": { + "optional": true + }, + "effect": { + "optional": true + }, + "fluentvalidation-ts": { + "optional": true + }, + "fp-ts": { + "optional": true + }, + "io-ts": { + "optional": true + }, + "joi": { + "optional": true + }, + "nope-validator": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "typanion": { + "optional": true + }, + "valibot": { + "optional": true + }, + "vest": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -2774,6 +2888,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@stellar-split/sdk": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@stellar-split/sdk/-/sdk-0.1.0.tgz", @@ -3260,6 +3380,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", @@ -3952,6 +4082,77 @@ "win32" ] }, + "node_modules/@vercel/blob": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.6.1.tgz", + "integrity": "sha512-KTJytw85j1XQBxjN5d6UXI7fIWNQe1jotn4nWN+0hePqLs+Qi1B3jHdQcSKFGF0m2rsy9uhPT6GOXMtHe3qNzg==", + "license": "Apache-2.0", + "dependencies": { + "@vercel/oidc": "^3.6.1", + "async-retry": "^1.3.3", + "is-buffer": "^2.0.5", + "is-node-process": "^1.2.0", + "throttleit": "^2.1.0", + "undici": "^6.23.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@vercel/blob/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@vercel/cli-config": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@vercel/cli-config/-/cli-config-0.2.1.tgz", + "integrity": "sha512-RhfyXmRLHdbnry8RJqHDc+5rGxMZ0bu+fpysZjtv3bE+BubpuwxTancHOKiH5zKQREsdwFVr3mOI2kOvxlOyxA==", + "license": "Apache-2.0", + "dependencies": { + "xdg-app-paths": "5", + "zod": "4.1.11" + } + }, + "node_modules/@vercel/cli-config/node_modules/zod": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@vercel/cli-exec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@vercel/cli-exec/-/cli-exec-1.0.0.tgz", + "integrity": "sha512-kQF8LGie/Hbdq9/psJxLE7owRTcqMQMhgybU04gCeR7cbQAr5t8OrjefDNColJv1QSSucFt4pLwRiARVmlOnug==", + "license": "Apache-2.0", + "dependencies": { + "execa": "5.1.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.8.1.tgz", + "integrity": "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw==", + "license": "Apache-2.0", + "dependencies": { + "@vercel/cli-config": "0.2.1", + "@vercel/cli-exec": "1.0.0", + "jose": "^5.9.6" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", @@ -4561,6 +4762,15 @@ "node": ">= 0.4" } }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -6875,6 +7085,35 @@ "node": ">=10" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -7334,6 +7573,18 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -7719,6 +7970,15 @@ "node": ">= 6" } }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/hyphen": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz", @@ -7979,6 +8239,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/is-bun-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", @@ -8195,6 +8478,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -8330,6 +8619,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -8496,6 +8797,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-md5": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", @@ -9599,6 +9909,12 @@ "integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==", "license": "MIT" }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -10229,6 +10545,15 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -10543,6 +10868,18 @@ "svg-arc-to-cubic-bezier": "^3.0.0" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10698,6 +11035,21 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -10726,6 +11078,15 @@ "node": ">= 0.8.0" } }, + "node_modules/os-paths": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/os-paths/-/os-paths-4.4.0.tgz", + "integrity": "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==", + "license": "MIT", + "engines": { + "node": ">= 6.0" + } + }, "node_modules/otplib": { "version": "13.4.1", "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz", @@ -11406,6 +11767,22 @@ "loose-envify": "^1.1.0" } }, + "node_modules/react-hook-form": { + "version": "7.83.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.83.0.tgz", + "integrity": "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -11804,6 +12181,15 @@ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", "license": "MIT" }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -12640,6 +13026,15 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -12897,6 +13292,18 @@ "node": ">=0.8" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -14220,6 +14627,31 @@ } } }, + "node_modules/xdg-app-paths": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.5.1.tgz", + "integrity": "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==", + "license": "MIT", + "dependencies": { + "os-paths": "^4.0.1", + "xdg-portable": "^7.2.0" + }, + "engines": { + "node": ">= 6.0" + } + }, + "node_modules/xdg-portable": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/xdg-portable/-/xdg-portable-7.3.0.tgz", + "integrity": "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==", + "license": "MIT", + "dependencies": { + "os-paths": "^4.0.1" + }, + "engines": { + "node": ">= 6.0" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index bfb2597..7399edb 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test:e2e": "playwright test" }, "dependencies": { + "@hookform/resolvers": "^5.5.7", "@react-pdf/renderer": "^3.4.4", "@sentry/nextjs": "^10.62.0", "@stellar-split/sdk": "^0.1.0", @@ -19,6 +20,7 @@ "@stellar/stellar-sdk": "^16.1.0", "@tanstack/react-query": "5.80.7", "@types/canvas-confetti": "1.6.4", + "@vercel/blob": "^2.6.1", "canvas-confetti": "1.9.3", "exceljs": "^4.4.0", "html2canvas": "^1.4.1", @@ -31,6 +33,7 @@ "qrcode.react": "4.2.0", "react": "^18.3.0", "react-dom": "^18.3.0", + "react-hook-form": "^7.83.0", "react-markdown": "^10.1.0", "recharts": "^2.12.7", "remark-gfm": "^4.0.1", @@ -51,6 +54,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.5.0", "@types/node": "^20.0.0", + "@types/qrcode": "^1.5.6", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/web-push": "^3.6.4", diff --git a/src/__tests__/BrandingForm.test.tsx b/src/__tests__/BrandingForm.test.tsx new file mode 100644 index 0000000..105d274 --- /dev/null +++ b/src/__tests__/BrandingForm.test.tsx @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import BrandingForm from "@/components/settings/BrandingForm"; +import { + LOGO_SIZE_ERROR, + LOGO_TYPE_ERROR, + MAX_LOGO_BYTES, +} from "@/lib/brandSettings"; + +const ADDRESS = "GBRPYHIL2CI3WHZDTOOQFC6EB4WXONTZJ3TXFLQ5XJJIJF4OJZC6J65A"; + +const mockUploadBrandLogo = vi.fn(); +const mockSaveBrandSettingsRemote = vi.fn(); +const mockClearBrandSettingsRemote = vi.fn(); +const mockFetchBrandSettings = vi.fn(); + +vi.mock("@/lib/freighter", () => ({ + getFreighterPublicKey: vi.fn().mockResolvedValue( + "GBRPYHIL2CI3WHZDTOOQFC6EB4WXONTZJ3TXFLQ5XJJIJF4OJZC6J65A", + ), +})); + +vi.mock("@/lib/branding", () => ({ + fetchBrandSettings: (...args: unknown[]) => mockFetchBrandSettings(...args), + saveBrandSettingsRemote: (...args: unknown[]) => mockSaveBrandSettingsRemote(...args), + clearBrandSettingsRemote: (...args: unknown[]) => mockClearBrandSettingsRemote(...args), + uploadBrandLogo: (...args: unknown[]) => mockUploadBrandLogo(...args), +})); + +/** Render the form and wait for the wallet load + settings fetch to settle. */ +async function renderLoadedForm() { + render(); + return screen.findByLabelText("Accent color hex value"); +} + +describe("BrandingForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchBrandSettings.mockResolvedValue({ + logoUrl: null, + accentColor: null, + tagline: null, + updatedAt: "", + }); + mockUploadBrandLogo.mockResolvedValue({ + logoUrl: "/api/settings/branding/logo/branding/X/logo-1.png", + width: 800, + height: 200, + warning: null, + cdn: false, + }); + mockSaveBrandSettingsRemote.mockResolvedValue({ + logoUrl: null, + accentColor: "#4f46e5", + tagline: "Fast splits", + updatedAt: "2026-07-28T00:00:00.000Z", + }); + }); + + it("loads existing branding into the form for the connected account", async () => { + mockFetchBrandSettings.mockResolvedValue({ + logoUrl: "https://cdn.example.com/logo.png", + accentColor: "#4f46e5", + tagline: "Existing tagline", + updatedAt: "2026-07-01T00:00:00.000Z", + }); + + await renderLoadedForm(); + + expect(mockFetchBrandSettings).toHaveBeenCalledWith(ADDRESS); + expect(screen.getByLabelText("Accent color hex value")).toHaveValue("#4f46e5"); + expect(screen.getByLabelText("Invoice tagline")).toHaveValue("Existing tagline"); + expect(screen.getByAltText("Your logo")).toHaveAttribute("src", "https://cdn.example.com/logo.png"); + }); + + it("rejects an unsupported file type with a specific error BEFORE any upload request", async () => { + await renderLoadedForm(); + + const input = screen.getByLabelText(/upload logo/i); + const gif = new File([new Uint8Array(16)], "logo.gif", { type: "image/gif" }); + fireEvent.change(input, { target: { files: [gif] } }); + + expect(await screen.findByText(LOGO_TYPE_ERROR)).toBeInTheDocument(); + expect(mockUploadBrandLogo).not.toHaveBeenCalled(); + }); + + it("rejects a file over 2 MB with a specific error BEFORE any upload request", async () => { + await renderLoadedForm(); + + const input = screen.getByLabelText(/upload logo/i); + const huge = new File([new Uint8Array(MAX_LOGO_BYTES + 1)], "logo.png", { type: "image/png" }); + fireEvent.change(input, { target: { files: [huge] } }); + + expect(await screen.findByText(LOGO_SIZE_ERROR)).toBeInTheDocument(); + expect(mockUploadBrandLogo).not.toHaveBeenCalled(); + }); + + it("uploads a valid PNG and previews the returned CDN URL", async () => { + await renderLoadedForm(); + + const input = screen.getByLabelText(/upload logo/i); + const png = new File([new Uint8Array(64)], "logo.png", { type: "image/png" }); + fireEvent.change(input, { target: { files: [png] } }); + + await waitFor(() => expect(mockUploadBrandLogo).toHaveBeenCalledTimes(1)); + expect(mockUploadBrandLogo).toHaveBeenCalledWith(ADDRESS, png); + const previews = await screen.findAllByAltText(/logo/i); + expect(previews.some((img) => img.getAttribute("src")?.includes("/api/settings/branding/logo/"))).toBe(true); + }); + + it("flags a low-contrast accent color with an inline WCAG warning in real time", async () => { + await renderLoadedForm(); + + fireEvent.change(screen.getByLabelText("Accent color hex value"), { + target: { value: "#ffffff" }, + }); + + const warning = await screen.findByText(/fails WCAG AA contrast against white/i); + expect(warning).toBeInTheDocument(); + }); + + it("blocks saving when the accent color fails WCAG AA (zod validation)", async () => { + await renderLoadedForm(); + + fireEvent.change(screen.getByLabelText("Accent color hex value"), { + target: { value: "#ffffff" }, + }); + fireEvent.click(screen.getByRole("button", { name: /save branding/i })); + + // Give the async zod resolver a chance to (incorrectly) pass — it must not. + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(mockSaveBrandSettingsRemote).not.toHaveBeenCalled(); + expect(screen.queryByText(/branding saved/i)).not.toBeInTheDocument(); + }); + + it("saves accent color and tagline for the connected account", async () => { + await renderLoadedForm(); + + fireEvent.change(screen.getByLabelText("Accent color hex value"), { + target: { value: "#4f46e5" }, + }); + fireEvent.change(screen.getByLabelText("Invoice tagline"), { + target: { value: "Fast splits" }, + }); + fireEvent.click(screen.getByRole("button", { name: /save branding/i })); + + await waitFor(() => expect(mockSaveBrandSettingsRemote).toHaveBeenCalledTimes(1)); + const [addressArg, settingsArg] = mockSaveBrandSettingsRemote.mock.calls[0]; + expect(addressArg).toBe(ADDRESS); + expect(settingsArg).toMatchObject({ accentColor: "#4f46e5", tagline: "Fast splits" }); + expect(await screen.findByText(/branding saved/i)).toBeInTheDocument(); + }); + + it("updates the live invoice preview as the accent color changes", async () => { + await renderLoadedForm(); + + const heading = screen.getByText("Invoice", { selector: "p" }); + expect(heading).toHaveStyle({ color: "#4f46e5" }); // platform default + + fireEvent.change(screen.getByLabelText("Accent color hex value"), { + target: { value: "#047857" }, + }); + + await waitFor(() => expect(heading).toHaveStyle({ color: "#047857" })); + }); + + it("removes branding and reverts to platform defaults", async () => { + await renderLoadedForm(); + + fireEvent.change(screen.getByLabelText("Invoice tagline"), { target: { value: "Hello" } }); + fireEvent.click(screen.getByRole("button", { name: /remove branding/i })); + + await waitFor(() => expect(mockClearBrandSettingsRemote).toHaveBeenCalledWith(ADDRESS)); + expect(await screen.findByText(/revert to platform-default styling/i)).toBeInTheDocument(); + expect(screen.getByLabelText("Invoice tagline")).toHaveValue(""); + }); +}); diff --git a/src/__tests__/FeeEstimateBar.test.tsx b/src/__tests__/FeeEstimateBar.test.tsx index 31a2c33..64d24ca 100644 --- a/src/__tests__/FeeEstimateBar.test.tsx +++ b/src/__tests__/FeeEstimateBar.test.tsx @@ -48,7 +48,7 @@ describe("FeeEstimateBar Component (#411)", () => { expect(screen.getByText(/xlm/i)).toBeInTheDocument(); }); - it("shows loading state when fee data is loading", () => { + it("shows loading state when fee data is loading", async () => { const { useFeeEstimate } = await import("@/hooks/useFeeEstimate"); vi.mocked(useFeeEstimate).mockReturnValue({ baseFee: undefined, @@ -63,7 +63,7 @@ describe("FeeEstimateBar Component (#411)", () => { expect(screen.getByText(/loading/i)).toBeInTheDocument(); }); - it("shows error state when fee stats returns an error", () => { + it("shows error state when fee stats returns an error", async () => { const { useFeeEstimate } = await import("@/hooks/useFeeEstimate"); vi.mocked(useFeeEstimate).mockReturnValue({ baseFee: 100, diff --git a/src/__tests__/PaymentConfirmationOverlay.test.tsx b/src/__tests__/PaymentConfirmationOverlay.test.tsx index 32ed68d..cfd92f3 100644 --- a/src/__tests__/PaymentConfirmationOverlay.test.tsx +++ b/src/__tests__/PaymentConfirmationOverlay.test.tsx @@ -331,8 +331,8 @@ describe("PaymentConfirmationOverlay", () => { describe("User Interactions", () => { it("calls onDismiss when close button is clicked", async () => { - const { user } = await import("@testing-library/user-event"); - const userEvent = await user.default; + // user-event's default export; clicking is simulated manually below. + await import("@testing-library/user-event"); render( { expect(svg?.tagName).toBe("svg"); }); - it("displays correct gauge fill for accounts with zero payment history", () => { + it("displays correct gauge fill for accounts with zero payment history", async () => { const { usePaymentVelocity } = await import("@/hooks/usePaymentVelocity"); vi.mocked(usePaymentVelocity).mockReturnValue({ velocities: { @@ -374,7 +374,7 @@ describe("Payment Velocity Gauge (#408)", () => { ).toBeInTheDocument(); }); - it("displays loading state while fetching initial data", () => { + it("displays loading state while fetching initial data", async () => { const { usePaymentVelocity } = await import("@/hooks/usePaymentVelocity"); vi.mocked(usePaymentVelocity).mockReturnValue({ velocities: undefined, diff --git a/src/__tests__/RecipientPayoutHistory.test.tsx b/src/__tests__/RecipientPayoutHistory.test.tsx index 7f466e4..b84b434 100644 --- a/src/__tests__/RecipientPayoutHistory.test.tsx +++ b/src/__tests__/RecipientPayoutHistory.test.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; @@ -168,28 +169,31 @@ describe("Recipient Payout History (#409)", () => { }); }) as any; - const { rerender } = render( -
- {payouts - .filter((p) => p.asset === "XLM") - .map((p) => ( -
{p.asset}
- ))} -
- ); + // Inline test component: loads payouts once on mount, filters client-side. + const PayoutList = ({ assetFilter }: { assetFilter: string }) => { + const [data, setData] = useState([]); + useEffect(() => { + fetch("/api/payouts") + .then((r: any) => r.json()) + .then((j: any) => setData(j.payouts)); + }, []); + return ( +
+ {data + .filter((p) => p.asset === assetFilter) + .map((p) => ( +
{p.asset}
+ ))} +
+ ); + }; + + const { rerender } = render(); expect(fetchCount).toBe(1); // Filter client-side should not trigger new fetch - rerender( -
- {payouts - .filter((p) => p.asset === "USDC") - .map((p) => ( -
{p.asset}
- ))} -
- ); + rerender(); expect(fetchCount).toBe(1); }); diff --git a/src/__tests__/ReputationBadge.test.tsx b/src/__tests__/ReputationBadge.test.tsx index a980ffc..355e7d1 100644 --- a/src/__tests__/ReputationBadge.test.tsx +++ b/src/__tests__/ReputationBadge.test.tsx @@ -76,7 +76,7 @@ const ReputationBadge: React.FC = ({ className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-gray-700 text-gray-400" data-testid="reputation-skeleton" > - ⏳ Loading + Loading ); } diff --git a/src/__tests__/TemplateMarketplace.test.tsx b/src/__tests__/TemplateMarketplace.test.tsx index ba89965..4414f0d 100644 --- a/src/__tests__/TemplateMarketplace.test.tsx +++ b/src/__tests__/TemplateMarketplace.test.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; @@ -147,13 +148,29 @@ describe("Invoice Template Marketplace (#410)", () => { }); it("displays pagination controls and loads next page on click", async () => { - render( -
- - Page 1 of 5 - -
- ); + const Pagination = () => { + const [page, setPage] = useState(1); + return ( +
+ + Page {page} of 5 + +
+ ); + }; + + render(); const nextBtn = screen.getByTestId("next-page"); fireEvent.click(nextBtn); @@ -237,7 +254,16 @@ describe("Invoice Template Marketplace (#410)", () => { await fetch("/api/templates/tpl1/clone", { method: "POST" }); - expect(clonedTemplate).toEqual(expect.objectContaining(sourceTemplate)); + // Everything except the new id / "(cloned)"-suffixed title must be + // an exact copy of the source template. + expect(clonedTemplate).toEqual( + expect.objectContaining({ + description: sourceTemplate.description, + structure: sourceTemplate.structure, + category: sourceTemplate.category, + }) + ); + expect(clonedTemplate.id).not.toBe(sourceTemplate.id); expect(clonedTemplate.title).toContain("(cloned)"); }); }); diff --git a/src/__tests__/batchPaymentApprovals.test.tsx b/src/__tests__/batchPaymentApprovals.test.tsx index cf30857..6f0b593 100644 --- a/src/__tests__/batchPaymentApprovals.test.tsx +++ b/src/__tests__/batchPaymentApprovals.test.tsx @@ -522,7 +522,7 @@ describe("Issue #412: Invoice Batch Payment Approval Queue", () => { it("should validate all recipients have valid Stellar addresses", () => { const recipients = [ - { address: "GBRPYHIL2CI3WHZDTOOQFC6EB4WXONTZJ3TXFLQ5XJJIJF4OJZC6J65" }, + { address: "GBRPYHIL2CI3WHZDTOOQFC6EB4WXONTZJ3TXFLQ5XJJIJF4OJZC6J65A" }, ]; const allValid = recipients.every((r) => diff --git a/src/__tests__/brandingApi.test.ts b/src/__tests__/brandingApi.test.ts new file mode 100644 index 0000000..4652466 --- /dev/null +++ b/src/__tests__/brandingApi.test.ts @@ -0,0 +1,275 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { DELETE, GET, PUT } from "@/app/api/settings/branding/route"; +import { POST } from "@/app/api/settings/branding/logo/route"; +import { GET as GET_LOGO_ASSET } from "@/app/api/settings/branding/logo/[...key]/route"; +import { + LOGO_SIZE_ERROR, + LOGO_TYPE_ERROR, + MAX_LOGO_BYTES, +} from "@/lib/brandSettings"; +import { clearBrandSettings } from "@/lib/brandSettingsStore"; + +const ALICE = "GBRPYHIL2CI3WHZDTOOQFC6EB4WXONTZJ3TXFLQ5XJJIJF4OJZC6J65A"; +const BOB = "GBTCHKH4IIT3DYQF7GAZPRMH5CHA4RTJOY2O3YYJWCEPIA3XBKXZMWPA"; + +const apiUrl = "http://localhost/api/settings/branding"; +const logoUrl = `${apiUrl}/logo`; + +function jsonRequest(url: string, method: string, body?: unknown) { + return new NextRequest(url, { + method, + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +/** Minimal PNG byte stream with a 600x400 IHDR (headers are all we parse). */ +function pngBytes(width = 600, height = 400): Uint8Array { + const bytes = new Uint8Array(64); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); // signature + bytes.set([0x00, 0x00, 0x00, 0x0d], 8); // IHDR length + bytes.set([0x49, 0x48, 0x44, 0x52], 12); // "IHDR" + const dv = new DataView(bytes.buffer); + dv.setUint32(16, width); + dv.setUint32(20, height); + return bytes; +} + +/** + * Builds a multipart logo upload request. `request.formData()` is stubbed + * with a real jsdom FormData so the route exercises genuine File semantics + * without depending on a network multipart parser. + */ +function logoUploadRequest(formValues: { address?: string; file?: File }): NextRequest { + const form = new FormData(); + if (formValues.address !== undefined) form.append("address", formValues.address); + if (formValues.file !== undefined) form.append("file", formValues.file); + const req = new NextRequest(logoUrl, { method: "POST" }); + vi.spyOn(req, "formData").mockResolvedValue(form); + return req; +} + +describe("Branding settings API (/api/settings/branding)", () => { + beforeEach(() => { + clearBrandSettings(ALICE); + clearBrandSettings(BOB); + }); + + describe("GET", () => { + it("returns platform defaults for an account with no saved branding", async () => { + const res = await GET(new NextRequest(`${apiUrl}?address=${ALICE}`)); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toEqual({ logoUrl: null, accentColor: null, tagline: null, updatedAt: "" }); + }); + + it("rejects a missing address with 400", async () => { + const res = await GET(new NextRequest(apiUrl)); + expect(res.status).toBe(400); + }); + + it("rejects a malformed address with 400", async () => { + const res = await GET(new NextRequest(`${apiUrl}?address=not-an-address`)); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("Stellar address"); + }); + }); + + describe("PUT", () => { + it("saves valid brand settings and echoes them back", async () => { + const res = await PUT( + jsonRequest(apiUrl, "PUT", { + address: ALICE, + logoUrl: "https://cdn.example.com/logo.png", + accentColor: "#4f46e5", + tagline: "Fast, fair splits", + }), + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.logoUrl).toBe("https://cdn.example.com/logo.png"); + expect(data.accentColor).toBe("#4f46e5"); + expect(data.tagline).toBe("Fast, fair splits"); + expect(typeof data.updatedAt).toBe("string"); + + // …and they persist for subsequent reads (across "reloads"). + const reread = await GET(new NextRequest(`${apiUrl}?address=${ALICE}`)); + expect((await reread.json()).accentColor).toBe("#4f46e5"); + }); + + it("scopes settings to the account: other accounts stay on defaults", async () => { + await PUT(jsonRequest(apiUrl, "PUT", { address: ALICE, accentColor: "#4f46e5" })); + const res = await GET(new NextRequest(`${apiUrl}?address=${BOB}`)); + expect((await res.json()).accentColor).toBeNull(); + }); + + it("rejects a malformed address with 400", async () => { + const res = await PUT(jsonRequest(apiUrl, "PUT", { address: "nope", accentColor: "#4f46e5" })); + expect(res.status).toBe(400); + }); + + it("rejects invalid hex colors with 400 and a specific message", async () => { + const res = await PUT(jsonRequest(apiUrl, "PUT", { address: ALICE, accentColor: "blue" })); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain("valid hex color"); + }); + + it("rejects accent colors failing WCAG AA contrast against white", async () => { + const res = await PUT( + jsonRequest(apiUrl, "PUT", { address: ALICE, accentColor: "#f2f2f2" }), + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain("WCAG AA"); + + // …and nothing was persisted. + const reread = await GET(new NextRequest(`${apiUrl}?address=${ALICE}`)); + expect((await reread.json()).accentColor).toBeNull(); + }); + + it("rejects taglines over 120 characters with 400", async () => { + const res = await PUT(jsonRequest(apiUrl, "PUT", { address: ALICE, tagline: "x".repeat(121) })); + expect(res.status).toBe(400); + }); + + it("rejects unparseable bodies with 400", async () => { + const req = new NextRequest(apiUrl, { method: "PUT", body: "{not json" }); + const res = await PUT(req); + expect(res.status).toBe(400); + }); + }); + + describe("DELETE", () => { + it("clears saved branding so invoices revert to platform defaults", async () => { + await PUT(jsonRequest(apiUrl, "PUT", { address: ALICE, accentColor: "#4f46e5", tagline: "hi" })); + + const res = await DELETE(jsonRequest(apiUrl, "DELETE", { address: ALICE })); + expect(res.status).toBe(200); + expect((await res.json()).success).toBe(true); + + const reread = await GET(new NextRequest(`${apiUrl}?address=${ALICE}`)); + const data = await reread.json(); + expect(data.accentColor).toBeNull(); + expect(data.tagline).toBeNull(); + expect(data.logoUrl).toBeNull(); + }); + + it("is a no-op success when no branding was saved", async () => { + const res = await DELETE(jsonRequest(apiUrl, "DELETE", { address: ALICE })); + expect(res.status).toBe(200); + }); + + it("rejects a malformed address with 400", async () => { + const res = await DELETE(jsonRequest(apiUrl, "DELETE", { address: "junk" })); + expect(res.status).toBe(400); + }); + }); +}); + +describe("Branding logo API (/api/settings/branding/logo)", () => { + beforeEach(() => { + delete process.env.BLOB_READ_WRITE_TOKEN; + }); + + it("accepts a PNG under 2 MB and returns a CDN-style URL + detected size", async () => { + const file = new File([pngBytes(600, 400)], "logo.png", { type: "image/png" }); + const res = await POST(logoUploadRequest({ address: ALICE, file })); + + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.logoUrl).toMatch(/^\/api\/settings\/branding\/logo\/branding\/.+\.png$/); + expect(data.cdn).toBe(false); + expect(data.width).toBe(600); + expect(data.height).toBe(400); + expect(data.warning).toBeNull(); + expect(data.maxBytes).toBe(MAX_LOGO_BYTES); + expect(data.allowedTypes).toEqual(["image/png", "image/jpeg", "image/webp"]); + }); + + it("warns when the logo is too small for crisp 300 dpi print", async () => { + const file = new File([pngBytes(120, 40)], "logo.png", { type: "image/png" }); + const res = await POST(logoUploadRequest({ address: ALICE, file })); + + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.width).toBe(120); + expect(data.warning).toContain("300 dpi"); + }); + + it("accepts JPEG and WebP uploads", async () => { + const jpeg = new File([new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 4])], "logo.jpg", { + type: "image/jpeg", + }); + const res = await POST(logoUploadRequest({ address: ALICE, file: jpeg })); + expect(res.status).toBe(201); + + const webp = new File([new Uint8Array([0x52, 0x49, 0x46, 0x46, 1, 2, 3, 4])], "logo.webp", { + type: "image/webp", + }); + const res2 = await POST(logoUploadRequest({ address: ALICE, file: webp })); + expect(res2.status).toBe(201); + }); + + it("rejects files over the 2 MB cap with the specific size error (413)", async () => { + const oversized = new File([new Uint8Array(MAX_LOGO_BYTES + 1)], "huge.png", { + type: "image/png", + }); + const res = await POST(logoUploadRequest({ address: ALICE, file: oversized })); + + expect(res.status).toBe(413); + expect((await res.json()).error).toBe(LOGO_SIZE_ERROR); + }); + + it.each(["image/gif", "image/svg+xml", "text/plain", "application/pdf"])( + "rejects MIME type %s with the specific type error (415)", + async (mime) => { + const file = new File([new Uint8Array(32)], "logo.bin", { type: mime }); + const res = await POST(logoUploadRequest({ address: ALICE, file })); + + expect(res.status).toBe(415); + expect((await res.json()).error).toBe(LOGO_TYPE_ERROR); + }, + ); + + it("rejects uploads without a file", async () => { + const res = await POST(logoUploadRequest({ address: ALICE })); + expect(res.status).toBe(400); + }); + + it("rejects uploads from a malformed address with 401", async () => { + const file = new File([pngBytes()], "logo.png", { type: "image/png" }); + const res = await POST(logoUploadRequest({ address: "not-an-address", file })); + expect(res.status).toBe(401); + }); + + it("serves a stored logo with immutable CDN-style cache headers", async () => { + const bytes = pngBytes(800, 200); + const file = new File([bytes], "logo.png", { type: "image/png" }); + const upload = await POST(logoUploadRequest({ address: ALICE, file })); + const { key } = await upload.json(); + + const res = await GET_LOGO_ASSET(new NextRequest(`http://localhost/api/settings/branding/logo/${key}`), { + params: { key: key.split("/") }, + }); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("image/png"); + expect(res.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable"); + const served = new Uint8Array(await res.arrayBuffer()); + expect(served.length).toBe(bytes.length); + expect(Array.from(served.slice(0, 8))).toEqual(Array.from(bytes.slice(0, 8))); + }); + + it("returns 404 for unknown keys and 400 for malformed keys", async () => { + const missing = await GET_LOGO_ASSET(new NextRequest("http://localhost/x"), { + params: { key: ["branding", ALICE, "logo-999.png"] }, + }); + expect(missing.status).toBe(404); + + const malformed = await GET_LOGO_ASSET(new NextRequest("http://localhost/x"), { + params: { key: ["..", "..", "etc"] }, + }); + expect(malformed.status).toBe(400); + }); +}); diff --git a/src/__tests__/brandingSchema.test.ts b/src/__tests__/brandingSchema.test.ts new file mode 100644 index 0000000..fd97030 --- /dev/null +++ b/src/__tests__/brandingSchema.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { + brandSettingsSchema, + isAllowedLogoMimeType, + isLogoSizeOk, + isStellarAddress, + LOGO_SIZE_ERROR, + LOGO_TYPE_ERROR, + MAX_LOGO_BYTES, + MAX_TAGLINE_LENGTH, +} from "@/lib/brandSettings"; + +describe("brandSettingsSchema", () => { + it("accepts a fully-populated valid record", () => { + const result = brandSettingsSchema.safeParse({ + logoUrl: "https://cdn.example.com/logo.png", + accentColor: "#4f46e5", + tagline: "Fast, fair splits", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.logoUrl).toBe("https://cdn.example.com/logo.png"); + expect(result.data.accentColor).toBe("#4f46e5"); + expect(result.data.tagline).toBe("Fast, fair splits"); + } + }); + + it("accepts root-relative logo URLs served by the local asset route", () => { + const result = brandSettingsSchema.safeParse({ + logoUrl: "/api/settings/branding/logo/branding/GABC/logo-1.png", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.logoUrl).toBe("/api/settings/branding/logo/branding/GABC/logo-1.png"); + } + }); + + it("treats empty strings as cleared (null) fields", () => { + const result = brandSettingsSchema.safeParse({ logoUrl: "", accentColor: "", tagline: "" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ logoUrl: null, accentColor: null, tagline: null }); + } + }); + + it("accepts an empty object (all fields optional)", () => { + const result = brandSettingsSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it.each(["blue", "#12", "#gggggg", "4f46e5ff"])("rejects invalid hex color %j", (color) => { + const result = brandSettingsSchema.safeParse({ accentColor: color }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("valid hex color"); + } + }); + + it.each(["#ffffff", "#f2f2f2", "#777777", "#ffff00"])( + "rejects accent color %s failing WCAG AA contrast against white", + (color) => { + const result = brandSettingsSchema.safeParse({ accentColor: color }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("WCAG AA"); + } + }, + ); + + it.each(["#4f46e5", "#767676", "#b91c1c", "#047857"])( + "accepts accent color %s meeting WCAG AA against white", + (color) => { + const result = brandSettingsSchema.safeParse({ accentColor: color }); + expect(result.success).toBe(true); + }, + ); + + it("rejects logo URLs that are not absolute http(s) or root-relative", () => { + const result = brandSettingsSchema.safeParse({ logoUrl: "notaurl" }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe("Logo URL must be a valid URL"); + } + }); + + it(`rejects taglines longer than ${MAX_TAGLINE_LENGTH} characters`, () => { + const result = brandSettingsSchema.safeParse({ tagline: "x".repeat(MAX_TAGLINE_LENGTH + 1) }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain(`${MAX_TAGLINE_LENGTH} characters`); + } + }); + + it(`accepts a tagline of exactly ${MAX_TAGLINE_LENGTH} characters`, () => { + const result = brandSettingsSchema.safeParse({ tagline: "x".repeat(MAX_TAGLINE_LENGTH) }); + expect(result.success).toBe(true); + }); + + it("trims tagline whitespace and nulls whitespace-only taglines", () => { + const result = brandSettingsSchema.safeParse({ tagline: " " }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tagline).toBeNull(); + } + }); +}); + +describe("logo upload guards", () => { + it("allows exactly PNG, JPEG and WebP MIME types", () => { + expect(isAllowedLogoMimeType("image/png")).toBe(true); + expect(isAllowedLogoMimeType("image/jpeg")).toBe(true); + expect(isAllowedLogoMimeType("image/webp")).toBe(true); + expect(isAllowedLogoMimeType("image/gif")).toBe(false); + expect(isAllowedLogoMimeType("image/svg+xml")).toBe(false); + expect(isAllowedLogoMimeType("text/plain")).toBe(false); + expect(isAllowedLogoMimeType("application/octet-stream")).toBe(false); + }); + + it("enforces the 2 MB size cap inclusively", () => { + expect(isLogoSizeOk(1)).toBe(true); + expect(isLogoSizeOk(MAX_LOGO_BYTES)).toBe(true); + expect(isLogoSizeOk(MAX_LOGO_BYTES + 1)).toBe(false); + expect(isLogoSizeOk(0)).toBe(false); + }); + + it("exposes specific, user-facing rejection messages", () => { + expect(LOGO_TYPE_ERROR).toBe("Unsupported file type. Upload a PNG, JPEG, or WebP image."); + expect(LOGO_SIZE_ERROR).toBe("Logo exceeds the 2 MB size limit."); + }); +}); + +describe("isStellarAddress", () => { + it("accepts well-formed Stellar public keys", () => { + expect(isStellarAddress("GBRPYHIL2CI3WHZDTOOQFC6EB4WXONTZJ3TXFLQ5XJJIJF4OJZC6J65A")).toBe(true); + }); + + it.each(["", "GABC", "xBRPYHIL2CI3WHZDTOOQFC6EB4WXONTZJ3TXFLQ5XJJIJF4OJZC6J65A", "GBRPYHIL2CI3WHZDTOOQFC6EB4WXONTZJ3TXFLQ5XJJIJF4OJZC6J65!"])( + "rejects malformed address %j", + (address) => { + expect(isStellarAddress(address)).toBe(false); + }, + ); +}); diff --git a/src/__tests__/contrast.test.ts b/src/__tests__/contrast.test.ts new file mode 100644 index 0000000..11f4fcf --- /dev/null +++ b/src/__tests__/contrast.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + checkContrast, + contrastRatio, + hexToRgb, + isValidHexColor, + meetsWcagAaAgainstWhite, + relativeLuminance, + WCAG_AA_CONTRAST_RATIO, +} from "@/lib/contrast"; + +describe("contrast utilities (WCAG 2.1)", () => { + describe("hexToRgb", () => { + it("parses 6-digit hex colors", () => { + expect(hexToRgb("#4f46e5")).toEqual({ r: 0x4f, g: 0x46, b: 0xe5 }); + }); + + it("parses 6-digit hex without the leading #", () => { + expect(hexToRgb("ffffff")).toEqual({ r: 255, g: 255, b: 255 }); + }); + + it("expands 3-digit shorthand", () => { + expect(hexToRgb("#abc")).toEqual({ r: 0xaa, g: 0xbb, b: 0xcc }); + }); + + it("is case-insensitive", () => { + expect(hexToRgb("#AABBCC")).toEqual({ r: 0xaa, g: 0xbb, b: 0xcc }); + }); + + it.each(["", "#", "#12", "#1234", "blue", "#gggggg", "#1234567"])( + "returns null for invalid input %j", + (input) => { + expect(hexToRgb(input)).toBeNull(); + }, + ); + }); + + describe("isValidHexColor", () => { + it("accepts valid colors", () => { + expect(isValidHexColor("#4f46e5")).toBe(true); + expect(isValidHexColor("#abc")).toBe(true); + }); + + it("rejects invalid colors", () => { + expect(isValidHexColor("not-a-color")).toBe(false); + expect(isValidHexColor("#12345")).toBe(false); + }); + }); + + describe("relativeLuminance", () => { + it("is 0 for black and 1 for white", () => { + expect(relativeLuminance("#000000")).toBe(0); + expect(relativeLuminance("#ffffff")).toBeCloseTo(1, 10); + }); + + it("throws for invalid colors", () => { + expect(() => relativeLuminance("nope")).toThrow("Invalid hex color"); + }); + }); + + describe("contrastRatio", () => { + it("returns 21:1 for black on white", () => { + expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 5); + }); + + it("returns 1:1 for identical colors", () => { + expect(contrastRatio("#4f46e5", "#4f46e5")).toBeCloseTo(1, 10); + }); + + it("is symmetric in its arguments", () => { + expect(contrastRatio("#ffffff", "#000000")).toBeCloseTo(contrastRatio("#000000", "#ffffff"), 10); + }); + }); + + describe("checkContrast against white", () => { + it("passes the platform default accent (#4f46e5 ≈ 6.29:1)", () => { + const { ratio, passes } = checkContrast("#4f46e5"); + expect(ratio).toBeCloseTo(6.29, 1); + expect(passes).toBe(true); + }); + + it("passes gray at the WCAG AA boundary (#767676 ≈ 4.54:1)", () => { + const { ratio, passes } = checkContrast("#767676"); + expect(ratio).toBeGreaterThanOrEqual(WCAG_AA_CONTRAST_RATIO); + expect(passes).toBe(true); + }); + + it("fails gray just below the boundary (#777777 ≈ 4.48:1)", () => { + const { ratio, passes } = checkContrast("#777777"); + expect(ratio).toBeLessThan(WCAG_AA_CONTRAST_RATIO); + expect(passes).toBe(false); + }); + + it("fails white on white", () => { + const { ratio, passes } = checkContrast("#ffffff", "#ffffff"); + expect(ratio).toBe(1); + expect(passes).toBe(false); + }); + }); + + describe("meetsWcagAaAgainstWhite", () => { + it("accepts readable colors", () => { + expect(meetsWcagAaAgainstWhite("#4f46e5")).toBe(true); + expect(meetsWcagAaAgainstWhite("#000000")).toBe(true); + }); + + it("rejects washed-out colors", () => { + expect(meetsWcagAaAgainstWhite("#ffffff")).toBe(false); + expect(meetsWcagAaAgainstWhite("#f2f2f2")).toBe(false); + }); + + it("rejects unparseable colors instead of throwing", () => { + expect(meetsWcagAaAgainstWhite("definitely-not-hex")).toBe(false); + }); + }); +}); diff --git a/src/__tests__/invoiceAuditTrailPDF.test.ts b/src/__tests__/invoiceAuditTrailPDF.test.ts index acab9c9..60d21a3 100644 --- a/src/__tests__/invoiceAuditTrailPDF.test.ts +++ b/src/__tests__/invoiceAuditTrailPDF.test.ts @@ -47,7 +47,8 @@ export const createAuditTrailPDF = (entries: AuditLogEntry[]): AuditTrailPDF => sortedEntries.forEach((entry, index) => { auditDataString += `Entry ${index + 1}\n`; - auditDataString += `Timestamp: ${entry.timestamp.toISOString()}\n`; + // ISO 8601 without milliseconds for a stable, human-readable audit trail + auditDataString += `Timestamp: ${entry.timestamp.toISOString().replace(/\.\d{3}Z$/, "Z")}\n`; auditDataString += `Actor: ${entry.actor}\n`; auditDataString += `Action: ${entry.action}\n`; auditDataString += `Details: ${entry.details}\n`; @@ -80,8 +81,11 @@ export const validateAuditTrailIntegrity = ( pdfContent: string, claimedHash: string ): boolean => { - // Remove verification hash section for validation - const contentWithoutHash = pdfContent.split("Verification Hash")[0].trim(); + // Reconstruct the exact pre-hash content: the hash is computed over the + // entry block BEFORE the trailing separator line ("\n====…\n") that + // introduces the Verification Hash section is appended. + const beforeHashSection = pdfContent.split("Verification Hash")[0]; + const contentWithoutHash = beforeHashSection.replace(/\n=+\n$/, ""); const calculatedHash = generateSHA256Hash(contentWithoutHash); return calculatedHash === claimedHash; }; diff --git a/src/__tests__/invoiceDeepLinkGenerator.test.ts b/src/__tests__/invoiceDeepLinkGenerator.test.ts index b0574db..324aba3 100644 --- a/src/__tests__/invoiceDeepLinkGenerator.test.ts +++ b/src/__tests__/invoiceDeepLinkGenerator.test.ts @@ -101,14 +101,15 @@ export const validateDeepLinkParams = ( export const generateQRCode = async ( url: string ): Promise => { - // In real implementation, would use a QR code library - // For testing, we'll return a mock data URL - return `data:image/svg+xml;base64,${Buffer.from(``).toString("base64")}`; + // In real implementation, would use a QR code library. For testing, the + // payload URL is embedded so distinct payloads produce distinct codes. + const svg = `${url}`; + return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; }; describe("invoiceDeepLinkGenerator", () => { const validParams: DeepLinkParams = { - to: "GBTCHKH4IIT3DYQF7GAZPRMH5CHA4RTJOY2O3YYJWCEPIA3XBKXZMWP", + to: "GBTCHKH4IIT3DYQF7GAZPRMH5CHA4RTJOY2O3YYJWCEPIA3XBKXZMWPA", asset: "USDC", amount: "100.50", }; @@ -117,7 +118,7 @@ describe("invoiceDeepLinkGenerator", () => { it("generates a URL with required parameters", () => { const url = generateDeepLink(validParams); expect(url).toContain("/pay/new?"); - expect(url).toContain("to=GBTCHKH4IIT3DYQF7GAZPRMH5CHA4RTJOY2O3YYJWCEPIA3XBKXZMWP"); + expect(url).toContain("to=GBTCHKH4IIT3DYQF7GAZPRMH5CHA4RTJOY2O3YYJWCEPIA3XBKXZMWPA"); expect(url).toContain("asset=USDC"); expect(url).toContain("amount=100.50"); }); @@ -160,7 +161,7 @@ describe("invoiceDeepLinkGenerator", () => { describe("parseDeepLinkParams", () => { it("parses URL with required parameters", () => { - const url = "/pay/new?to=GBTCHKH4IIT3DYQF7GAZPRMH5CHA4RTJOY2O3YYJWCEPIA3XBKXZMWP&asset=USDC&amount=100.50"; + const url = "/pay/new?to=GBTCHKH4IIT3DYQF7GAZPRMH5CHA4RTJOY2O3YYJWCEPIA3XBKXZMWPA&asset=USDC&amount=100.50"; const params = parseDeepLinkParams(url); expect(params).toEqual(validParams); }); diff --git a/src/__tests__/invoiceMergeTool.test.ts b/src/__tests__/invoiceMergeTool.test.ts index 984cf3d..1e6a89a 100644 --- a/src/__tests__/invoiceMergeTool.test.ts +++ b/src/__tests__/invoiceMergeTool.test.ts @@ -14,6 +14,11 @@ export interface InvoiceDiff { hasDifferences: boolean; } +// JSON.stringify cannot serialize BigInt — normalize BigInt values to +// strings before comparing invoice field snapshots. +const safeStringify = (value: unknown): string => + JSON.stringify(value, (_key, v) => (typeof v === 'bigint' ? `${v}n` : v)); + export const invoiceDiff = ( invoice1: Invoice, invoice2: Invoice @@ -33,7 +38,7 @@ export const invoiceDiff = ( const fields: DiffField[] = fieldsToCompare.map((field) => { const value1 = invoice1[field]; const value2 = invoice2[field]; - const isDifferent = JSON.stringify(value1) !== JSON.stringify(value2); + const isDifferent = safeStringify(value1) !== safeStringify(value2); return { field: field as string, diff --git a/src/__tests__/invoiceWizard.test.tsx b/src/__tests__/invoiceWizard.test.tsx index 10fd028..779dee5 100644 --- a/src/__tests__/invoiceWizard.test.tsx +++ b/src/__tests__/invoiceWizard.test.tsx @@ -132,7 +132,7 @@ describe("Issue #414: Invoice Creation Guided Wizard Multi-Step", () => { const isValidStellarAddress = (address: string) => /^G[A-Z2-7]{55}$/.test(address); - const validAddress = "GBRPYHIL2CI3WHZDTOOQFC6EB4WXONTZJ3TXFLQ5XJJIJF4OJZC6J65"; + const validAddress = "GBRPYHIL2CI3WHZDTOOQFC6EB4WXONTZJ3TXFLQ5XJJIJF4OJZC6J65A"; expect(isValidStellarAddress(validAddress)).toBe(true); }); diff --git a/src/__tests__/localeI18n.test.ts b/src/__tests__/localeI18n.test.ts index ffc4aa2..3b0ae7c 100644 --- a/src/__tests__/localeI18n.test.ts +++ b/src/__tests__/localeI18n.test.ts @@ -252,8 +252,10 @@ describe('Number and currency formatting', () => { return new Intl.NumberFormat(locale).format(num); }; - const enFormatted = formatNumber(1234.56, 'en-US'); - const esFormatted = formatNumber(1234.56, 'es-ES'); + // 5+ integer digits because modern CLDR only groups es-ES numbers with + // >= 5 integer digits (minimumGroupingDigits = 2 since CLDR 42). + const enFormatted = formatNumber(12345.67, 'en-US'); + const esFormatted = formatNumber(12345.67, 'es-ES'); expect(enFormatted).toContain(','); expect(esFormatted).toContain('.'); @@ -270,7 +272,8 @@ describe('Number and currency formatting', () => { const enFormatted = formatCurrency(100.5, 'en-US', 'USD'); const esFormatted = formatCurrency(100.5, 'es-ES', 'EUR'); - expect(enFormatted).toContain('USD') || expect(enFormatted).toContain('$'); + // ICU versions differ: some render "$100.50", others "100.50 USD". + expect(enFormatted).toMatch(/USD|\$/); expect(esFormatted).toContain('€'); }); diff --git a/src/__tests__/publicInvoiceStatusPage.test.tsx b/src/__tests__/publicInvoiceStatusPage.test.tsx index 300d21e..3237428 100644 --- a/src/__tests__/publicInvoiceStatusPage.test.tsx +++ b/src/__tests__/publicInvoiceStatusPage.test.tsx @@ -156,10 +156,10 @@ describe("Issue #415: Dynamic Public Invoice Status Page", () => { const initialState = { amountPaid: "600", amountDue: "400" }; const updatedState = { amountPaid: "800", amountDue: "200" }; - expect(updatedState.amountPaid).toBeGreaterThan( + expect(parseFloat(updatedState.amountPaid)).toBeGreaterThan( parseFloat(initialState.amountPaid) ); - expect(updatedState.amountDue).toBeLessThan( + expect(parseFloat(updatedState.amountDue)).toBeLessThan( parseFloat(initialState.amountDue) ); }); diff --git a/src/__tests__/useAddressLabel.test.ts b/src/__tests__/useAddressLabel.test.ts index b99a0ac..5093430 100644 --- a/src/__tests__/useAddressLabel.test.ts +++ b/src/__tests__/useAddressLabel.test.ts @@ -13,10 +13,12 @@ describe("useAddressLabel / resolveAddressLabel", () => { it("derives label for federation addresses (user*domain.com)", async () => { vi.mock("@stellar/stellar-sdk", () => ({ - FederationServer: { - resolve: vi.fn().mockResolvedValue({ - account_id: "G12345678901234567890123456789012345678901234567890123456", - }), + Federation: { + Server: { + resolve: vi.fn().mockResolvedValue({ + account_id: "G12345678901234567890123456789012345678901234567890123456", + }), + }, }, })); diff --git a/src/app/api/invoices/export/route.ts b/src/app/api/invoices/export/route.ts index 9964dfa..b3b38a9 100644 --- a/src/app/api/invoices/export/route.ts +++ b/src/app/api/invoices/export/route.ts @@ -119,7 +119,7 @@ export async function POST(request: NextRequest) { // Return file const filename = generateExportFilename('invoices'); - return new NextResponse(excelData, { + return new NextResponse(excelData as unknown as BodyInit, { status: 200, headers: { 'Content-Type': diff --git a/src/app/api/settings/branding/logo/[...key]/route.ts b/src/app/api/settings/branding/logo/[...key]/route.ts new file mode 100644 index 0000000..37b72c8 --- /dev/null +++ b/src/app/api/settings/branding/logo/[...key]/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getStoredBrandAsset, isValidAssetKey } from "@/lib/brandAssetStorage"; + +export const runtime = "nodejs"; + +/** + * GET /api/settings/branding/logo/<...key> + * Serves a locally-stored brand logo with immutable, year-long cache headers + * so the URL can sit behind any CDN edge cache. Used in dev/CI; production + * uploads go straight to the Vercel Blob CDN. + */ +export async function GET( + _request: NextRequest, + { params }: { params: { key: string[] } }, +) { + const key = (params.key ?? []).join("/"); + + if (!key || !isValidAssetKey(key)) { + return NextResponse.json({ error: "Invalid asset key" }, { status: 400 }); + } + + const asset = getStoredBrandAsset(key); + if (!asset) { + return NextResponse.json({ error: "Logo not found" }, { status: 404 }); + } + + return new NextResponse(asset.bytes as unknown as BodyInit, { + status: 200, + headers: { + "Content-Type": asset.contentType, + "Content-Length": String(asset.bytes.byteLength), + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +} diff --git a/src/app/api/settings/branding/logo/route.ts b/src/app/api/settings/branding/logo/route.ts new file mode 100644 index 0000000..07a710c --- /dev/null +++ b/src/app/api/settings/branding/logo/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + ALLOWED_LOGO_MIME_TYPES, + isAllowedLogoMimeType, + isLogoSizeOk, + isStellarAddress, + LOGO_SIZE_ERROR, + LOGO_TYPE_ERROR, + MAX_LOGO_BYTES, +} from "@/lib/brandSettings"; +import { storeBrandAsset } from "@/lib/brandAssetStorage"; + +export const runtime = "nodejs"; + +/** + * POST /api/settings/branding/logo + * multipart/form-data with: + * - file: logo image (PNG / JPEG / WebP, max 2 MB) — required + * - address: owning account's Stellar address — required + * + * Validates MIME type and size BEFORE streaming the file into the brand + * asset store (Vercel Blob → CDN URL, or the local dev store) and returns + * { logoUrl, width, height, warning, cdn }. + */ +export async function POST(request: NextRequest) { + let form: FormData; + try { + form = await request.formData(); + } catch { + return NextResponse.json( + { error: "Expected multipart/form-data with a file field" }, + { status: 400 }, + ); + } + + const address = form.get("address"); + if (typeof address !== "string" || !isStellarAddress(address.trim())) { + return NextResponse.json( + { error: "A valid Stellar address (G + 55 characters) is required" }, + { status: 401 }, + ); + } + + const file = form.get("file"); + if (!file || typeof file === "string") { + return NextResponse.json({ error: "No logo file provided" }, { status: 400 }); + } + + // Server-side enforcement of the client-side rules: type allow-list and + // the 2 MB cap, with the same specific error messages. + if (!isAllowedLogoMimeType(file.type)) { + return NextResponse.json({ error: LOGO_TYPE_ERROR }, { status: 415 }); + } + + const buffer = new Uint8Array(await file.arrayBuffer()); + if (!isLogoSizeOk(buffer.byteLength)) { + return NextResponse.json({ error: LOGO_SIZE_ERROR }, { status: 413 }); + } + + try { + const stored = await storeBrandAsset({ + address: address.trim(), + bytes: buffer, + contentType: file.type, + }); + + return NextResponse.json( + { + logoUrl: stored.url, + key: stored.key, + cdn: stored.cdn, + width: stored.width, + height: stored.height, + warning: stored.lowResolutionWarning, + maxBytes: MAX_LOGO_BYTES, + allowedTypes: ALLOWED_LOGO_MIME_TYPES, + }, + { status: 201 }, + ); + } catch (err) { + console.error("Logo upload failed:", err); + return NextResponse.json( + { error: "Logo upload failed. Please try again." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/settings/branding/route.ts b/src/app/api/settings/branding/route.ts new file mode 100644 index 0000000..0011b81 --- /dev/null +++ b/src/app/api/settings/branding/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + brandSettingsSchema, + DEFAULT_BRAND_SETTINGS, + isStellarAddress, +} from "@/lib/brandSettings"; +import { + clearBrandSettings, + getBrandSettings, + saveBrandSettings, +} from "@/lib/brandSettingsStore"; + +const INVALID_ADDRESS_ERROR = + "A valid Stellar address (G + 55 characters) is required to scope branding to an account"; + +/** + * GET /api/settings/branding?address= + * Returns the account's brand settings, or platform defaults when unset. + */ +export async function GET(request: NextRequest) { + const address = request.nextUrl.searchParams.get("address"); + if (!address || !isStellarAddress(address.trim())) { + return NextResponse.json({ error: INVALID_ADDRESS_ERROR }, { status: 400 }); + } + + const settings = getBrandSettings(address.trim()); + return NextResponse.json({ ...DEFAULT_BRAND_SETTINGS, ...settings }, { status: 200 }); +} + +/** + * PUT /api/settings/branding + * Body: { address, logoUrl?, accentColor?, tagline? } + * Validates brand metadata (hex color + WCAG AA contrast against white) + * and stores it in the account's settings record. + */ +export async function PUT(request: NextRequest) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid request payload" }, { status: 400 }); + } + + const { address, ...fields } = (body ?? {}) as Record; + + if (typeof address !== "string" || !isStellarAddress(address.trim())) { + return NextResponse.json({ error: INVALID_ADDRESS_ERROR }, { status: 400 }); + } + + const parsed = brandSettingsSchema.safeParse(fields); + if (!parsed.success) { + const message = parsed.error.issues[0]?.message ?? "Invalid brand settings"; + return NextResponse.json({ error: message }, { status: 400 }); + } + + const saved = saveBrandSettings(address.trim(), { + logoUrl: parsed.data.logoUrl ?? null, + accentColor: parsed.data.accentColor ?? null, + tagline: parsed.data.tagline ?? null, + }); + + return NextResponse.json(saved, { status: 200 }); +} + +/** + * DELETE /api/settings/branding + * Body (or query): { address } + * Clears the account's branding so invoices revert to platform defaults. + */ +export async function DELETE(request: NextRequest) { + let address = request.nextUrl.searchParams.get("address"); + + if (!address) { + try { + const body = await request.json(); + address = typeof body?.address === "string" ? body.address : null; + } catch { + // ignore body parse failure + } + } + + if (!address || !isStellarAddress(address.trim())) { + return NextResponse.json({ error: INVALID_ADDRESS_ERROR }, { status: 400 }); + } + + clearBrandSettings(address.trim()); + return NextResponse.json({ success: true }, { status: 200 }); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index b736de1..8afd743 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -2,6 +2,11 @@ import { Suspense } from "react"; import DashboardClient from "@/components/DashboardClient"; import { SkeletonCard } from "@/components/Skeleton"; +export const metadata = { + robots: { index: false, follow: false }, + title: "Your Invoices — StellarSplit", +}; + /** * Dashboard page with streaming SSR. * The page shell renders immediately, and invoice cards stream in as they load. @@ -20,17 +25,6 @@ export default async function DashboardPage() { } > -import { InvoiceListSkeleton } from "@/components/Skeleton"; - -export const metadata = { - robots: { index: false, follow: false }, - title: "Your Invoices — StellarSplit", -}; - -export default async function DashboardPage() { - return ( -
- }>
diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index 5f0e453..f4252cf 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -17,6 +17,7 @@ import FundingProgress from "@/components/FundingProgress"; import StatusBadge from "@/components/StatusBadge"; import StatusTimeline from "@/components/StatusTimeline"; import { InvoiceDetailSkeleton } from "@/components/Skeleton"; +import CooldownBadge from "@/components/CooldownBadge"; import PayModal from "@/components/PayModal"; import PaymentMethodSelector from "@/components/PaymentMethodSelector"; import DeadlineCountdown from "@/components/DeadlineCountdown"; @@ -51,11 +52,16 @@ import SplitCalculator from "@/components/SplitCalculator"; import InvoiceTagEditor from "@/components/invoice/InvoiceTagEditor"; import type { SplitMeta } from "@/hooks/useSplitCalculator"; import type { InstallmentMilestone } from "@/components/invoice/InvoiceView"; -import ActivityFeed from "@/components/ActivityFeed"; import InstallmentTracker from "@/components/InstallmentTracker"; import InstallmentPanel from "@/components/InstallmentPanel"; + +import InvoiceView, { BrandHeader } from "@/components/invoice/InvoiceView"; +import { fetchBrandSettings } from "@/lib/branding"; +import type { BrandSettings } from "@/lib/brandSettings"; + import InvoiceView from "@/components/invoice/InvoiceView"; import InvoiceSummaryPanel from "@/components/invoice/InvoiceSummaryPanel"; + import CoCreatorPanel from "@/components/CoCreatorPanel"; import PaymentChannelPanel from "@/components/PaymentChannelPanel"; import DisputePanel from "@/components/DisputePanel"; @@ -130,6 +136,7 @@ export default function InvoiceDetailPage({ params }: Props) { const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [loadedSplitMeta, setLoadedSplitMeta] = useState(null); + const [branding, setBranding] = useState(null); // Live stream const { @@ -206,7 +213,6 @@ export default function InvoiceDetailPage({ params }: Props) { const [channelState, setChannelState] = useState(null); const [channelLoading, setChannelLoading] = useState(false); const [channelError, setChannelError] = useState(null); - const [previousInvoice, setPreviousInvoice] = useState(null); const [reminderDate, setReminderDate] = useState(""); const [reminderMsg, setReminderMsg] = useState(""); const [hasReminder, setHasReminder] = useState(false); @@ -282,6 +288,22 @@ export default function InvoiceDetailPage({ params }: Props) { } }, [id]); + // Load the invoice creator's account branding (logo / accent / tagline) so + // the live invoice view and PDF export can render it. Falls back to + // platform-default styling when the creator has no branding configured. + useEffect(() => { + if (!invoice?.creator) return; + let cancelled = false; + fetchBrandSettings(invoice.creator) + .then((settings) => { + if (!cancelled) setBranding(settings); + }) + .catch(() => null); + return () => { + cancelled = true; + }; + }, [invoice?.creator]); + useEffect(() => { if (!publicKey) return; // TODO: implement payer nonce @@ -554,7 +576,7 @@ export default function InvoiceDetailPage({ params }: Props) { > Duplicate - + {pushStatus !== "unsupported" && !isRetroactive && ( + + - {DASHBOARD_PRESETS.map((preset) => { - const isActive = activePreset === preset.id; - const count = presetCounts[preset.id] ?? 0; - - return ( - - ); - })} - {(activePreset !== "all" || searchValue.trim().length > 0 || selectedStatuses.length > 0) && ( - - )} {drawerOpen && ( -
+
{filterControls}
)} @@ -780,33 +798,52 @@ export default function DashboardClient() { {/* Invoice count */} {!loading && invoices.length > 0 && (

- Showing {visibleInvoices.length} of {invoices.length} invoice{invoices.length !== 1 ? "s" : ""} + Showing {visibleInvoices.length} of {invoices.length} invoice + {invoices.length !== 1 ? "s" : ""}

)} - {/* Multi-select messages */} - {multiSelect &&

Select pending invoices to pay in a single transaction.

} - {reminderSelect &&

Select invoices to schedule a reminder for.

} - - {/* Bulk reminder results */} + {/* Selection mode messages */} + {multiSelect && ( +

+ Select pending invoices to pay in a single transaction. +

+ )} + {reminderSelect && ( +

+ Select invoices to schedule a reminder for. +

+ )} {compareMode && (

Select up to 2 invoices to compare side-by-side.

)} + + {/* Bulk reminder results */} {bulkReminderResults && (
-

Reminder scheduling results:

+

+ Reminder scheduling results: +

    {bulkReminderResults.map((r) => (
  • - {r.success ? "✓" : "✗"} + + {r.success ? "✓" : "✗"} + Invoice #{r.invoiceId} {!r.success && r.error && {r.error}}
  • ))}
- +
)} @@ -817,29 +854,31 @@ export default function DashboardClient() { ))}
- ) : invoices.length === 0 ? ( -
-

No invoices yet

-

Create your first invoice to start receiving payments on-chain.

- +
+

+ No invoices yet +

+

+ Create your first invoice to start receiving payments on-chain. +

+ + Create your first invoice
) : visibleInvoices.length === 0 ? ( -
+

- {activePreset !== "all" - ? DASHBOARD_PRESETS.find((preset) => preset.id === activePreset) - ?.emptyState ?? "No invoices match this view." + {statuses.length === 1 + ? DASHBOARD_PRESETS.find((preset) => preset.id === statuses[0])?.emptyState ?? + "No invoices match this view." : searchValue.trim() ? "No invoices match your search." - : selectedStatuses.length > 0 - ? "No invoices match the selected status." - : "No invoices found. Create your first one!"} + : "No invoices match the current filters."}

-
-

No invoices match the current filters.

) : ( viewMode === "table" && !multiSelect && !reminderSelect && !compareMode ? ( @@ -870,10 +909,6 @@ export default function DashboardClient() { const isMultiSelectable = isSelecting; const isMultiSelected = isSelected(inv.id); - const card = ( - - ); - return (
{isMultiSelectable ? ( @@ -911,9 +946,7 @@ export default function DashboardClient() { aria-pressed={isSelected} aria-label={`${isSelected ? "Deselect" : "Select"} Invoice #${inv.id}`} className={`w-full text-left rounded-xl ring-2 transition-all ${ - isSelected - ? "ring-indigo-500" - : "ring-transparent hover:ring-gray-600" + isSelected ? "ring-indigo-500" : "ring-transparent hover:ring-gray-600" }`} >
@@ -937,7 +970,9 @@ export default function DashboardClient() { type="button" onClick={() => toggleReminderSelect(inv.id)} aria-pressed={isReminderSelected} - aria-label={`${isReminderSelected ? "Deselect" : "Select"} Invoice #${inv.id} for reminder`} + aria-label={`${ + isReminderSelected ? "Deselect" : "Select" + } Invoice #${inv.id} for reminder`} className={`w-full text-left rounded-xl ring-2 transition-all ${ isReminderSelected ? "ring-indigo-500" @@ -963,11 +998,14 @@ export default function DashboardClient() { ) : isCompareSelectable ? (
toggleCompareSelect(inv.id)} + onClick={(e) => { + // Let the card's own checkbox own its clicks so a card + // is never toggled twice by a single interaction. + if ((e.target as HTMLElement).closest('input[type="checkbox"]')) return; + toggleCompareSelect(inv.id); + }} role="button" tabIndex={0} onKeyDown={(e) => { @@ -977,26 +1015,18 @@ export default function DashboardClient() { } }} aria-pressed={isCompareSelected} - aria-label={`${isCompareSelected ? "Deselect" : "Select"} Invoice #${inv.id} for comparison`} + aria-label={`${ + isCompareSelected ? "Deselect" : "Select" + } Invoice #${inv.id} for comparison`} > -
- {isCompareSelected && ( - - )} - -
+ toggleCompareSelect(invoiceId)} + />
) : (
@@ -1035,17 +1065,33 @@ export default function DashboardClient() { { setShowBatchModal(false); exitMultiSelect(); }} + onClose={() => { + setShowBatchModal(false); + exitMultiSelect(); + }} /> )} {/* Reminder Picker Modal */} {showReminderPicker && ( -
+

Schedule Reminders

-

Applies to {reminderSelected.size} selected invoice{reminderSelected.size !== 1 ? "s" : ""}.

- +

+ Applies to {reminderSelected.size} selected invoice + {reminderSelected.size !== 1 ? "s" : ""}. +

+
- -
diff --git a/src/components/ExpiryCountdownBanner.tsx b/src/components/ExpiryCountdownBanner.tsx index 406365a..b30eb8f 100644 --- a/src/components/ExpiryCountdownBanner.tsx +++ b/src/components/ExpiryCountdownBanner.tsx @@ -113,7 +113,7 @@ export default function ExpiryCountdownBanner({

This invoice is past its due date. Please reach out to the payer if payment - hasn't been received. + hasn't been received.

diff --git a/src/components/InvoiceExportButton.tsx b/src/components/InvoiceExportButton.tsx index e4f20b7..f267746 100644 --- a/src/components/InvoiceExportButton.tsx +++ b/src/components/InvoiceExportButton.tsx @@ -3,34 +3,81 @@ import { useCallback, useState } from 'react'; import type { Invoice } from '@stellar-split/sdk'; import { formatAmount } from '@stellar-split/sdk'; +import { DEFAULT_ACCENT_COLOR, type BrandSettings } from '@/lib/brandSettings'; interface Props { invoice: Invoice; total: bigint; + /** Creator branding from /settings/branding; null/undefined = platform default. */ + branding?: BrandSettings | null; } -export default function InvoiceExportButton({ invoice, total }: Props) { +/** MIME types react-pdf can rasterize reliably (WebP is not supported). */ +const PDF_SAFE_IMAGE_TYPES = new Set(['image/png', 'image/jpeg']); + +/** + * Fetches an image URL and returns it as a data URL that @react-pdf/renderer + * can embed, or null when the image cannot be inlined (network failure or a + * format react-pdf cannot decode, e.g. WebP — branding colors/tagline still + * apply; the export never fails because of the logo). + */ +async function fetchLogoDataUrl(url: string): Promise { + try { + const res = await fetch(url); + if (!res.ok) return null; + const blob = await res.blob(); + if (!PDF_SAFE_IMAGE_TYPES.has(blob.type)) return null; + return await new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(typeof reader.result === 'string' ? reader.result : null); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } +} + +export default function InvoiceExportButton({ invoice, total, branding }: Props) { const [loading, setLoading] = useState(false); const handleExport = useCallback(async () => { setLoading(true); try { // Lazy-load @react-pdf/renderer so it doesn't bloat the initial bundle - const { pdf, Document, Page, Text, View, StyleSheet } = await import('@react-pdf/renderer'); + const { pdf, Document, Page, Text, View, StyleSheet, Image } = await import('@react-pdf/renderer'); + + // Resolve branding: prefer the prop handed down from the invoice page, + // but fall back to fetching the creator's settings so exports triggered + // elsewhere stay branded. + let brand = branding ?? null; + if (!brand) { + try { + const res = await fetch(`/api/settings/branding?address=${encodeURIComponent(invoice.creator)}`); + if (res.ok) brand = (await res.json()) as BrandSettings; + } catch { + // default styling + } + } + + const accent = brand?.accentColor ?? DEFAULT_ACCENT_COLOR; + const logoDataUrl = brand?.logoUrl ? await fetchLogoDataUrl(brand.logoUrl) : null; const exportedAt = new Date().toLocaleString(); const styles = StyleSheet.create({ page: { padding: 40, fontSize: 11, fontFamily: 'Helvetica', color: '#111' }, header: { marginBottom: 24 }, - title: { fontSize: 22, fontWeight: 'bold', marginBottom: 4 }, + logo: { height: 40, width: 'auto', marginBottom: 8, objectFit: 'contain' }, + title: { fontSize: 22, fontWeight: 'bold', marginBottom: 4, color: accent }, subtitle: { fontSize: 11, color: '#666' }, + tagline: { fontSize: 11, color: '#444', marginTop: 2, fontStyle: 'italic' }, section: { marginBottom: 16 }, sectionTitle: { fontSize: 13, fontWeight: 'bold', marginBottom: 8, color: '#1a1a1a' }, table: { width: '100%', borderWidth: 1, borderColor: '#ddd' }, tableHeader: { flexDirection: 'row', - backgroundColor: '#4f46e5', + backgroundColor: accent, color: '#fff', padding: '6 8', fontWeight: 'bold', @@ -49,8 +96,11 @@ export default function InvoiceExportButton({ invoice, total }: Props) { {/* Header */} + {/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image node, not an HTML img */} + {logoDataUrl && } ✦ StellarSplit Invoice Export + {brand?.tagline && {brand.tagline}} {/* Invoice meta */} @@ -138,7 +188,7 @@ export default function InvoiceExportButton({ invoice, total }: Props) { } finally { setLoading(false); } - }, [invoice, total]); + }, [invoice, total, branding]); return ( +
+ + {settingsOpen && ( +
+ {VELOCITY_WINDOWS.map((window) => ( + + ))} +
+ )} + + {alerts.length > 0 && ( +
+
+

+ Payment Velocity Alert +

+ +
+ {alerts.map((alert) => ( +

+ {alert.window} window: ${formatMoney(alert.volume)} exceeded threshold of $ + {formatMoney(alert.threshold)} +

+ ))} +
+ )} + + + Payment Velocity Gauge + {VELOCITY_WINDOWS.map((window, i) => { + const cx = 60 + i * 120; + const r = 40; + const circumference = Math.PI * r; // half circle + const stat = velocities?.[window] ?? { volume: 0, threshold: 1 }; + const { pct, color } = fillFor(stat.volume, stat.threshold); + return ( + + + + + {pct.toFixed(0)}% + + + {WINDOW_LABELS[window]}: ${formatMoney(stat.volume)} / ${formatMoney(stat.threshold)} + + + ); + })} + + + {lastUpdated && ( +

+ Last updated: {lastUpdated.toLocaleTimeString()} +

+ )} +
+ ); +} diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx index 994456f..1316ebb 100644 --- a/src/components/Skeleton.tsx +++ b/src/components/Skeleton.tsx @@ -1,6 +1,3 @@ -/** Base pulsing rectangle primitive — every skeleton in the app builds on this. */ -export function Skeleton({ className = "" }: { className?: string }) { - return
; "use client"; import { useEffect, useState } from "react"; @@ -8,6 +5,11 @@ import { useEffect, useState } from "react"; /** Shared animated shimmer base */ const shimmer = "animate-pulse bg-gray-200 dark:bg-gray-700 rounded"; +/** Base pulsing rectangle primitive — every skeleton in the app builds on this. */ +export function Skeleton({ className = "" }: { className?: string }) { + return
; +} + /** * useDeferredShow — returns false until `delayMs` has elapsed. * Prevents skeleton flash when data loads in under the threshold. @@ -29,8 +31,7 @@ function useDeferredShow(delayMs = 200): boolean { */ export function SkeletonCard() { return ( -
-
+
@@ -45,7 +46,7 @@ export function SkeletonCard() {
-
+
@@ -56,12 +57,9 @@ export function SkeletonCard() { /** Matches a table/list row */ export function SkeletonRow() { return ( -
+
-
-
-
); } @@ -81,21 +79,21 @@ export function InvoiceCardSkeleton() { role="status" aria-busy="true" aria-label="Loading invoice data" - className="bg-gray-900 rounded-xl p-4 sm:p-5" + className="bg-gray-100 dark:bg-gray-900 rounded-xl p-4 sm:p-5" >
-
-
+ +
-
+
-
-
+ +
-
+
-
-
+ +
); @@ -115,34 +113,34 @@ export function InvoiceDetailSkeleton() { > {/* Header */}
-
-
+ +
{/* Progress bar */}
-
-
+ +
{/* Recipients */}
-
+ {[0, 1, 2].map((i) => (
-
-
+ +
))}
{/* Payment history */}
-
+ {[0, 1].map((i) => ( -
-
-
+
+ +
))}
@@ -182,12 +180,12 @@ export function SkeletonPaymentRow() { className="bg-gray-100 dark:bg-gray-900 rounded-xl px-4 py-3 flex items-center justify-between gap-4" >
-
-
+ +
-
-
-
+ + +
); } @@ -203,10 +201,10 @@ export function SkeletonLeaderboardRow() { aria-label="Loading..." className="flex items-center gap-4 px-4 py-3 bg-gray-100 dark:bg-gray-900 rounded-lg" > -
-
+ +
-
+
); } @@ -224,18 +222,18 @@ export function SkeletonCreatorProfile() { > {/* Avatar + name row */}
-
+
-
-
+ +
{/* Stats row */}
{[0, 1, 2].map((i) => (
-
-
+ +
))}
@@ -255,33 +253,30 @@ export function SkeletonDashboardStats() { className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4" > {[0, 1, 2, 3].map((i) => ( -
-
-
-
+
+ + +
))}
); } -/** - * DeferredSkeleton — wraps any skeleton and only renders it after `delayMs` - * (default 200ms) to avoid a flash of loading UI on fast connections. - */ +/** Matches a subscription summary card. */ export function SubscriptionCardSkeleton() { return ( -
+
-
-
+ +
-
+
-
-
-
-
+ + + +
); @@ -290,6 +285,7 @@ export function SubscriptionCardSkeleton() { export function SubscriptionListSkeleton({ count = 3 }: { count?: number }) { return (
-
-
+ +
-
-
-
-
+
+ + +
-
-
+
+ {[0, 1, 2].map((i) => (
-
-
+ +
))}
@@ -330,6 +327,10 @@ export function SubscriptionDetailSkeleton() { ); } +/** + * DeferredSkeleton — wraps any skeleton and only renders it after `delayMs` + * (default 200ms) to avoid a flash of loading UI on fast connections. + */ export function DeferredSkeleton({ children, delayMs = 200, diff --git a/src/components/SplitCalculator.tsx b/src/components/SplitCalculator.tsx index d4e7f8f..dd24684 100644 --- a/src/components/SplitCalculator.tsx +++ b/src/components/SplitCalculator.tsx @@ -328,7 +328,7 @@ export default function SplitCalculator({
{derivedLines.length === 0 && !readOnly && (

- No recipients yet. Click "Add Line" to begin. + No recipients yet. Click "Add Line" to begin.

)} diff --git a/src/components/WalletConnect.tsx b/src/components/WalletConnect.tsx index 2368b5e..93c58c9 100644 --- a/src/components/WalletConnect.tsx +++ b/src/components/WalletConnect.tsx @@ -2,8 +2,6 @@ import { useEffect, useState } from "react"; import { useWalletContext } from "@/contexts/WalletContext"; -import { connectFreighter, getFreighterPublicKey, getWalletConnectPublicKey, connectWalletConnect, disconnectWalletConnect } from "@/lib/freighter"; -import type { WalletType } from "@/lib/freighter"; import { truncateAddress, formatAmount } from "@stellar-split/sdk"; import { fetchUsdcBalance } from "@/lib/stellar"; import QRModal from "@/components/QRModal"; @@ -15,15 +13,6 @@ interface Props { compact?: boolean; } -/** - * WalletConnect — Connect via Freighter or WalletConnect. - * Connection state is sourced from WalletContext, which caches the last - * connected public key in sessionStorage and silently re-verifies it on - * mount, so a page refresh reconnects without a new approval popup. - */ -export default function WalletConnect({ compact = false }: Props) { - const { address, walletType, connecting, error, freighterInstalled, connect, disconnect } = - useWalletContext(); function classifyWalletError(e: unknown): WalletErrorType { const msg = (e instanceof Error ? e.message : String(e)).toLowerCase(); if (msg.includes("not installed") || msg.includes("freighter is not") || msg.includes("no freighter")) return "not_installed"; @@ -33,11 +22,23 @@ function classifyWalletError(e: unknown): WalletErrorType { return "not_installed"; } -export default function WalletConnect() { +/** + * WalletConnect — Connect via Freighter or WalletConnect. + * Connection state is sourced from WalletContext, which caches the last + * connected public key in sessionStorage and silently re-verifies it on + * mount, so a page refresh reconnects without a new approval popup. + */ +export default function WalletConnect({ compact = false }: Props) { + const { + address, + walletType, + connecting, + error, + freighterInstalled, + connect, + disconnect, + } = useWalletContext(); const toast = useToast(); - const [address, setAddress] = useState(null); - const [walletType, setWalletType] = useState(null); - const [loading, setLoading] = useState(false); const [modalError, setModalError] = useState(null); const [balance, setBalance] = useState(null); const [balanceLoading, setBalanceLoading] = useState(false); @@ -47,11 +48,7 @@ export default function WalletConnect() { const USDC_CONTRACT_ID = process.env.NEXT_PUBLIC_USDC_ADDRESS ?? ""; useEffect(() => { - if (!address) { - setBalance(null); - return; - } - if (!USDC_CONTRACT_ID) { + if (!address || !USDC_CONTRACT_ID) { setBalance(null); return; } @@ -72,29 +69,19 @@ export default function WalletConnect() { return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [address, USDC_CONTRACT_ID]); const handleConnect = async () => { try { await connect("freighter"); - } catch { - // error is surfaced via context state - setLoading(true); - setModalError(null); - try { - const pk = await connectFreighter(); - setAddress(pk); - setWalletType("freighter"); } catch (e) { + // connection state/error is surfaced via context state const errType = classifyWalletError(e); if (errType === "rejected") { toast.error("Connection rejected. Try again when you're ready."); } else { setModalError(errType); } - } finally { - setLoading(false); } }; @@ -105,21 +92,10 @@ export default function WalletConnect() { setQrUri(result.uri); setQrOpen(true); } - } catch { - // error is surfaced via context state - setLoading(true); - setModalError(null); - try { - const { publicKey, uri } = await connectWalletConnect(); - setAddress(publicKey); - setWalletType("walletconnect"); - setQrUri(uri); - setQrOpen(true); } catch (e) { + // error is surfaced via context state toast.error("Could not initiate WalletConnect. Please try again."); console.error(e); - } finally { - setLoading(false); } }; @@ -130,29 +106,33 @@ export default function WalletConnect() { // Connected state if (address && walletType) { return ( -
+
- {truncateAddress(address)}
{!compact && ( -
+
{balanceLoading ? "Loading USDC…" : balance !== null @@ -192,21 +172,12 @@ export default function WalletConnect() { {error &&

Could not connect.

} -
- {balanceLoading - ? "Loading USDC…" - : balance !== null - ? `${formatAmount(balance)} USDC` - : USDC_CONTRACT_ID - ? "Unable to load balance" - : "USDC contract not configured"} -
); } @@ -218,11 +189,8 @@ export default function WalletConnect() { @@ -230,11 +198,8 @@ export default function WalletConnect() { @@ -252,7 +217,10 @@ export default function WalletConnect() { setModalError(null)} - onRetry={() => { setModalError(null); handleConnect(); }} + onRetry={() => { + setModalError(null); + handleConnect(); + }} />
); diff --git a/src/components/invoice/FeeEstimateBar.tsx b/src/components/invoice/FeeEstimateBar.tsx new file mode 100644 index 0000000..e2fbfc8 --- /dev/null +++ b/src/components/invoice/FeeEstimateBar.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useFeeEstimate, FALLBACK_BASE_FEE } from "@/hooks/useFeeEstimate"; + +interface Props { + /** Number of operations in the transaction; fees scale linearly. */ + operationCount?: number; +} + +const STROOPS_PER_XLM = 10_000_000; + +interface Tier { + id: "economy" | "standard" | "priority"; + label: string; + description: string; + stroopsPerOp: number; + segmentClass: string; +} + +function toXlm(stroops: number): string { + return (stroops / STROOPS_PER_XLM).toFixed(7); +} + +/** + * FeeEstimateBar (#411) — shows live network fees across three tiers with a + * segmented bar visualization, scaled by the operation count of the invoice + * transaction being built. + */ +export default function FeeEstimateBar({ operationCount = 1 }: Props) { + const { baseFee, medianFee, p90Fee, loading, error } = useFeeEstimate(); + + if (loading) { + return ( +
+

+ Loading fee estimates… +

+
+ ); + } + + const ops = Math.max(1, operationCount); + const economy = baseFee ?? FALLBACK_BASE_FEE; + const standard = Math.max(economy, medianFee ?? economy); + const priority = Math.max(standard, p90Fee ?? standard); + + const tiers: Tier[] = [ + { + id: "economy", + label: "Economy", + description: "Slowest confirmation, base network fee", + stroopsPerOp: economy, + segmentClass: "bg-emerald-500", + }, + { + id: "standard", + label: "Standard", + description: "Median network fee, typical confirmation time", + stroopsPerOp: standard, + segmentClass: "bg-indigo-500", + }, + { + id: "priority", + label: "Priority", + description: "90th percentile fee, fastest confirmation", + stroopsPerOp: priority, + segmentClass: "bg-amber-500", + }, + ]; + + const maxTotal = tiers[tiers.length - 1].stroopsPerOp * ops; + + return ( +
+ {error && ( +

+ Error loading live fee stats. Showing fallback estimates. +

+ )} + +

+ Network fees shown in stroops with XLM equivalents (1 XLM = 10,000,000 + base units), scaled to {ops} operation{ops === 1 ? "" : "s"}. +

+ + {/* Segmented bar visualization */} + + +
    + {tiers.map((tier) => { + const totalStroops = tier.stroopsPerOp * ops; + return ( +
  • + + {tier.label} + + + {totalStroops} + + + {toXlm(totalStroops)} + +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/settings/BrandingForm.tsx b/src/components/settings/BrandingForm.tsx new file mode 100644 index 0000000..2f0694c --- /dev/null +++ b/src/components/settings/BrandingForm.tsx @@ -0,0 +1,423 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { getFreighterPublicKey } from "@/lib/freighter"; +import { + ALLOWED_LOGO_MIME_TYPES, + brandSettingsSchema, + DEFAULT_ACCENT_COLOR, + isAllowedLogoMimeType, + isLogoSizeOk, + LOGO_SIZE_ERROR, + LOGO_TYPE_ERROR, + MAX_LOGO_BYTES, + MAX_TAGLINE_LENGTH, + type BrandSettingsInput, + type BrandSettingsOutput, +} from "@/lib/brandSettings"; +import { checkContrast, isValidHexColor } from "@/lib/contrast"; +import { + clearBrandSettingsRemote, + fetchBrandSettings, + saveBrandSettingsRemote, + uploadBrandLogo, +} from "@/lib/branding"; + +const EMPTY_VALUES = { logoUrl: "", accentColor: "", tagline: "" } as const; + +const MAX_LOGO_MB = MAX_LOGO_BYTES / (1024 * 1024); + +/** + * Account branding editor for /settings/branding. + * + * - react-hook-form + zod (brandSettingsSchema) validated on change + * - logo uploads are pre-validated for MIME type and the 2 MB cap BEFORE any + * request is made, so the server is only ever called with acceptable files + * - accent color previews in real time and flags colors that fail WCAG AA + * contrast against white both as an inline warning and as a submit blocker + */ +export default function BrandingForm() { + const [address, setAddress] = useState(null); + const [loading, setLoading] = useState(true); + const [feedback, setFeedback] = useState<{ kind: "success" | "error"; text: string } | null>(null); + const [logoError, setLogoError] = useState(null); + const [logoWarning, setLogoWarning] = useState(null); + const [logoUploading, setLogoUploading] = useState(false); + const [removing, setRemoving] = useState(false); + + const { + register, + handleSubmit, + reset, + setValue, + watch, + formState: { errors, isSubmitting, isDirty }, + } = useForm({ + resolver: zodResolver(brandSettingsSchema), + mode: "onChange", + defaultValues: { ...EMPTY_VALUES }, + }); + + useEffect(() => { + let cancelled = false; + getFreighterPublicKey() + .then(async (key) => { + if (cancelled) return; + setAddress(key); + const settings = await fetchBrandSettings(key); + if (cancelled) return; + reset({ + logoUrl: settings.logoUrl ?? "", + accentColor: settings.accentColor ?? "", + tagline: settings.tagline ?? "", + }); + }) + .catch(() => { + // wallet not connected — the form stays disabled with guidance + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [reset]); + + const logoUrl = watch("logoUrl") ?? ""; + const accentColor = watch("accentColor") ?? ""; + const tagline = watch("tagline") ?? ""; + + // Real-time contrast feedback: warn inline as soon as the typed/picked + // color fails WCAG AA against white — before the user even hits save. + const liveContrast = + accentColor && isValidHexColor(accentColor) ? checkContrast(accentColor, "#ffffff") : null; + const previewAccent = + accentColor && isValidHexColor(accentColor) ? accentColor : DEFAULT_ACCENT_COLOR; + + const handleLogoSelected = useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + // Reset the input so re-selecting the same file re-triggers onChange. + event.target.value = ""; + if (!file || !address) return; + + setLogoError(null); + setLogoWarning(null); + setFeedback(null); + + // Client-side pre-validation with specific messages — invalid files are + // rejected here and never reach the network. + if (!isAllowedLogoMimeType(file.type)) { + setLogoError(LOGO_TYPE_ERROR); + return; + } + if (!isLogoSizeOk(file.size)) { + setLogoError(LOGO_SIZE_ERROR); + return; + } + + setLogoUploading(true); + try { + const result = await uploadBrandLogo(address, file); + setValue("logoUrl", result.logoUrl, { shouldDirty: true, shouldValidate: true }); + setLogoWarning(result.warning); + } catch (err) { + setLogoError(err instanceof Error ? err.message : "Logo upload failed"); + } finally { + setLogoUploading(false); + } + }, + [address, setValue], + ); + + const onSubmit = handleSubmit(async (data: BrandSettingsOutput) => { + if (!address) return; + setFeedback(null); + try { + const saved = await saveBrandSettingsRemote(address, { + logoUrl: data.logoUrl ?? null, + accentColor: data.accentColor ?? null, + tagline: data.tagline ?? null, + }); + reset({ + logoUrl: saved.logoUrl ?? "", + accentColor: saved.accentColor ?? "", + tagline: saved.tagline ?? "", + }); + setFeedback({ kind: "success", text: "Branding saved — it now applies to all of your invoices." }); + } catch (err) { + setFeedback({ kind: "error", text: err instanceof Error ? err.message : "Could not save branding" }); + } + }); + + const handleRemoveBranding = useCallback(async () => { + if (!address) return; + setRemoving(true); + setFeedback(null); + try { + await clearBrandSettingsRemote(address); + reset({ ...EMPTY_VALUES }); + setLogoError(null); + setLogoWarning(null); + setFeedback({ kind: "success", text: "Branding removed — invoices revert to platform-default styling." }); + } catch (err) { + setFeedback({ kind: "error", text: err instanceof Error ? err.message : "Could not remove branding" }); + } finally { + setRemoving(false); + } + }, [address, reset]); + + if (loading) { + return ( +

+ Loading your branding settings… +

+ ); + } + + if (!address) { + return ( +

+ Connect your Freighter wallet to manage the branding for your account. +

+ ); + } + + return ( +
+ {/* ── Logo ─────────────────────────────────────────────────────────── */} +
+

+ Company logo +

+

+ Shown on your invoice page and PDF export. PNG, JPEG, or WebP up to {MAX_LOGO_MB} MB — + at least 600 px wide for sharp 300 dpi print. +

+ +
+ {logoUrl ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element -- user-supplied CDN URL */} + Your logo +
+ ) : ( +
+ No logo +
+ )} + +
+ + +

+ Files are checked for type and size before uploading. +

+
+ + {logoUrl && ( + + )} +
+ + {logoError && ( + + )} + {logoWarning && !logoError && ( +

+ {logoWarning} +

+ )} + {errors.logoUrl && ( +

+ {errors.logoUrl.message} +

+ )} +
+ + {/* ── Accent color ─────────────────────────────────────────────────── */} +
+

+ Accent color +

+

+ Used for your invoice heading and table headers. Must meet WCAG AA contrast (4.5:1) against white so it stays readable on invoices. +

+ +
+ setValue("accentColor", e.target.value, { shouldDirty: true, shouldValidate: true })} + className="h-11 w-14 cursor-pointer rounded-lg border border-gray-700 bg-gray-800 p-1" + /> + + {accentColor && ( + + )} +
+ + {liveContrast && !liveContrast.passes && ( +

+ ⚠ This color fails WCAG AA contrast against white ({liveContrast.ratio.toFixed(2)}:1 — needs 4.5:1). + It will be hard to read on invoices. +

+ )} + {errors.accentColor && ( +

+ {errors.accentColor.message} +

+ )} +
+ + {/* ── Tagline ──────────────────────────────────────────────────────── */} +
+

+ Tagline +

+

+ A short line shown under your invoice header. +

+ +
+ {errors.tagline ? ( +

+ {errors.tagline.message} +

+ ) : ( + + )} +

+ {tagline.length}/{MAX_TAGLINE_LENGTH} +

+
+
+ + {/* ── Live preview ─────────────────────────────────────────────────── */} +
+

+ Invoice preview +

+
+
+ {logoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element -- user-supplied CDN URL + Logo preview + ) : ( + + )} +
+

+ Invoice +

+ {tagline ? ( +

{tagline}

+ ) : ( +

Your tagline appears here

+ )} +
+
+
+ Recipient + Amount +
+
+ GABC…XYZ + 1,250.00 USDC +
+

+ Preview updates in real time as you edit. +

+
+
+ + {/* ── Actions ──────────────────────────────────────────────────────── */} + {feedback && ( +

+ {feedback.text} +

+ )} + +
+ + +

+ Branding applies to every invoice created by {address.slice(0, 6)}…{address.slice(-4)}. +

+
+
+ ); +} diff --git a/src/emails/PaymentReceiptEmail.tsx b/src/emails/PaymentReceiptEmail.tsx index a5d0930..34e05fe 100644 --- a/src/emails/PaymentReceiptEmail.tsx +++ b/src/emails/PaymentReceiptEmail.tsx @@ -64,7 +64,7 @@ export default function PaymentReceiptEmail({

- We're confirming that we've received your payment for Invoice #{invoice.id}. Thank you! + We're confirming that we've received your payment for Invoice #{invoice.id}. Thank you!

{/* Payment Summary Box */} diff --git a/src/hooks/useAddressLabel.ts b/src/hooks/useAddressLabel.ts index 1ed87fc..bbb7e23 100644 --- a/src/hooks/useAddressLabel.ts +++ b/src/hooks/useAddressLabel.ts @@ -15,8 +15,8 @@ export async function resolveAddressLabel(address: string): Promise 0) { diff --git a/src/hooks/useAddressValidation.ts b/src/hooks/useAddressValidation.ts index 65a15e7..9296d1d 100644 --- a/src/hooks/useAddressValidation.ts +++ b/src/hooks/useAddressValidation.ts @@ -98,10 +98,10 @@ export function useAddressValidation() { // Try to extract memo info from federation resolution try { - const { FederationServer } = await import('@stellar/stellar-sdk'); - const federationResult = await FederationServer.resolveAddress(address); - if (federationResult.memo_id) { - federationMemoId = federationResult.memo_id; + const { Federation } = await import('@stellar/stellar-sdk'); + const federationResult = await Federation.Server.resolve(address); + if (federationResult.memo) { + federationMemoId = federationResult.memo; } if (federationResult.memo_type) { federationMemoType = federationResult.memo_type; diff --git a/src/hooks/useFeeEstimate.ts b/src/hooks/useFeeEstimate.ts new file mode 100644 index 0000000..69d6fe6 --- /dev/null +++ b/src/hooks/useFeeEstimate.ts @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +/** Stellar network base fee used when fee stats cannot be loaded (stroops). */ +export const FALLBACK_BASE_FEE = 100; + +/** How often fee stats are refreshed while the hook is mounted. */ +export const FEE_POLL_MS = 10_000; + +export interface UseFeeEstimateResult { + /** Current ledger base fee, in stroops. */ + baseFee?: number; + /** Median accepted fee (feeCharged.mode / modeAcceptanceRate), in stroops. */ + medianFee?: number; + /** 90th percentile accepted fee, in stroops. */ + p90Fee?: number; + loading: boolean; + error: string | null; +} + +interface ParsedFeeStats { + baseFee: number; + medianFee: number; + p90Fee: number; +} + +function toNumber(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +/** + * Normalizes both Horizon fee-stats shapes: + * legacy: { baseFee, modeAcceptanceRate, p10..p99AcceptanceRate } + * modern: { lastLedgerBaseFee, feeCharged: { mode, p10..p99 } } + */ +export function parseFeeStats(stats: { + baseFee?: string | number; + lastLedgerBaseFee?: string | number; + modeAcceptanceRate?: string | number; + p90AcceptanceRate?: string | number; + feeCharged?: { mode?: string | number; p90?: string | number }; +} | null | undefined): ParsedFeeStats { + const baseFee = toNumber(stats?.lastLedgerBaseFee ?? stats?.baseFee, FALLBACK_BASE_FEE); + const medianFee = toNumber( + stats?.feeCharged?.mode ?? stats?.modeAcceptanceRate, + baseFee, + ); + const p90Fee = toNumber( + stats?.feeCharged?.p90 ?? stats?.p90AcceptanceRate, + medianFee, + ); + return { baseFee, medianFee, p90Fee }; +} + +const HORIZON_URL = + process.env.NEXT_PUBLIC_HORIZON_URL ?? "https://horizon-testnet.stellar.org"; + +/** + * Polls Horizon fee stats and exposes economy/standard/priority fee anchors. + * + * Always returns values in stroops; falls back to the 100-stroop base fee + * when the network is unreachable. + */ +export function useFeeEstimate(): UseFeeEstimateResult { + const [state, setState] = useState<{ + baseFee?: number; + medianFee?: number; + p90Fee?: number; + error: string | null; + }>({ error: null }); + const [loading, setLoading] = useState(true); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + + const fetchStats = async () => { + try { + // Horizon.Server is available from the top-level SDK export; access + // dynamically so environments without Horizon URLs degrade to the + // fallback fee instead of failing to bundle. + const sdk: any = await import("@stellar/stellar-sdk"); + const ServerCtor = sdk.Server ?? sdk.Horizon?.Server; + const server = new ServerCtor(HORIZON_URL); + const stats = await server.feeStats(); + if (!mountedRef.current) return; + const parsed = parseFeeStats(stats ?? null); + setState({ ...parsed, error: null }); + } catch (err) { + if (!mountedRef.current) return; + setState((prev) => ({ + ...prev, + error: err instanceof Error ? err.message : String(err), + })); + } finally { + if (mountedRef.current) setLoading(false); + } + }; + + fetchStats(); + const interval = setInterval(fetchStats, FEE_POLL_MS); + + return () => { + mountedRef.current = false; + clearInterval(interval); + }; + }, []); + + return { + baseFee: state.baseFee, + medianFee: state.medianFee, + p90Fee: state.p90Fee, + loading, + error: state.error, + }; +} + +export default useFeeEstimate; diff --git a/src/hooks/useInvoiceStream.ts b/src/hooks/useInvoiceStream.ts index 76d0403..0d72ea8 100644 --- a/src/hooks/useInvoiceStream.ts +++ b/src/hooks/useInvoiceStream.ts @@ -92,7 +92,7 @@ export function useInvoiceStream(invoiceId: string): UseInvoiceStreamResult { const contractId = process.env.NEXT_PUBLIC_CONTRACT_ID ?? ''; if (!contractId) return; - const response = await server.getEvents({ + const params: any = { filters: [ { type: 'contract' as any, @@ -105,17 +105,23 @@ export function useInvoiceStream(invoiceId: string): UseInvoiceStreamResult { ], }, ], - cursor: lastEventCursorRef.current ?? undefined, limit: 10, - }); + }; + // GetEventsRequest is a discriminated union: pass `cursor` only when set. + if (lastEventCursorRef.current) { + params.cursor = lastEventCursorRef.current; + } + const response = await server.getEvents(params); if (!mountedRef.current) return; if (response.events && response.events.length > 0) { for (const event of response.events) { - // Update cursor to latest event - if (event.pagingToken) { - lastEventCursorRef.current = event.pagingToken; + // Update cursor to latest event (`pagingToken` is present on live + // events at runtime but missing from the SDK union type). + const pagingToken = (event as unknown as { pagingToken?: string }).pagingToken; + if (pagingToken) { + lastEventCursorRef.current = pagingToken; } // Parse event topics to determine event type diff --git a/src/hooks/usePaymentVelocity.ts b/src/hooks/usePaymentVelocity.ts new file mode 100644 index 0000000..7220577 --- /dev/null +++ b/src/hooks/usePaymentVelocity.ts @@ -0,0 +1,208 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +/** Rolling windows tracked by the velocity gauge. */ +export type VelocityWindow = "1h" | "24h" | "7d"; + +export const VELOCITY_WINDOWS: VelocityWindow[] = ["1h", "24h", "7d"]; + +const WINDOW_MS: Record = { + "1h": 60 * 60 * 1000, + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, +}; + +/** localStorage key for user-configured thresholds. */ +export const VELOCITY_THRESHOLDS_KEY = "stellarsplit:velocityThresholds"; + +/** Default per-window thresholds (USDC). */ +export const DEFAULT_THRESHOLDS: Record = { + "1h": 1000, + "24h": 10000, + "7d": 50000, +}; + +/** How often velocity data is re-fetched. */ +export const VELOCITY_POLL_MS = 30_000; + +export interface VelocityStat { + volume: number; + threshold: number; +} + +export interface PaymentVelocityAlert { + window: VelocityWindow; + volume: number; + threshold: number; +} + +export interface UsePaymentVelocityResult { + velocities: Record | undefined; + lastUpdated: Date | undefined; + loading: boolean; + error: Error | null; + /** Current thresholds (from localStorage, else defaults). */ + thresholds: Record; + /** Persist a new threshold for one window. */ + setThreshold: (window: VelocityWindow, value: number) => void; + /** Refetch immediately. */ + refresh: () => void; +} + +interface HorizonPaymentRecord { + type?: string; + from?: string; + amount?: string; + created_at?: string; +} + +function readThresholds(): Record { + if (typeof window === "undefined") return { ...DEFAULT_THRESHOLDS }; + try { + const raw = window.localStorage.getItem(VELOCITY_THRESHOLDS_KEY); + if (!raw) return { ...DEFAULT_THRESHOLDS }; + const parsed = JSON.parse(raw) as Partial>; + return { + "1h": typeof parsed["1h"] === "number" ? parsed["1h"] : DEFAULT_THRESHOLDS["1h"], + "24h": typeof parsed["24h"] === "number" ? parsed["24h"] : DEFAULT_THRESHOLDS["24h"], + "7d": typeof parsed["7d"] === "number" ? parsed["7d"] : DEFAULT_THRESHOLDS["7d"], + }; + } catch { + return { ...DEFAULT_THRESHOLDS }; + } +} + +const HORIZON_URL = + process.env.NEXT_PUBLIC_HORIZON_URL ?? "https://horizon-testnet.stellar.org"; + +async function fetchOutgoingPayments(account: string): Promise { + const sdk: any = await import("@stellar/stellar-sdk"); + const ServerCtor = sdk.Server ?? sdk.Horizon?.Server; + const server = new ServerCtor(HORIZON_URL); + const page = await server + .payments() + .forAccount(account) + .order("desc") + .limit(200) + .call(); + const records: HorizonPaymentRecord[] = page?.records ?? []; + // Outgoing payment operations only, in USDC-equivalent units. + return records.filter((r) => r.type === "payment" && r.from === account); +} + +function computeVelocities( + records: HorizonPaymentRecord[], + thresholds: Record, + now = Date.now(), +): Record { + const parsed = records + .map((r) => ({ + amount: Number(r.amount ?? 0), + time: r.created_at ? new Date(r.created_at).getTime() : NaN, + })) + .filter((p) => Number.isFinite(p.amount) && Number.isFinite(p.time)); + + const sum = (window: VelocityWindow) => + parsed + .filter((p) => p.time > now - WINDOW_MS[window]) + .reduce((acc, p) => acc + p.amount, 0); + + return { + "1h": { volume: sum("1h"), threshold: thresholds["1h"] }, + "24h": { volume: sum("24h"), threshold: thresholds["24h"] }, + "7d": { volume: sum("7d"), threshold: thresholds["7d"] }, + }; +} + +/** + * usePaymentVelocity (#408) — tracks outgoing payment volume over rolling + * 1h / 24h / 7d windows against configurable thresholds, refreshing every + * 30 seconds. Dispatches a `velocity:alert` CustomEvent whenever a window's + * volume crosses its threshold. + */ +export function usePaymentVelocity(account?: string | null): UsePaymentVelocityResult { + const [thresholds, setThresholds] = useState>(readThresholds); + const [velocities, setVelocities] = useState | undefined>( + undefined, + ); + const [lastUpdated, setLastUpdated] = useState(undefined); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + const alertStateRef = useRef>({ + "1h": false, + "24h": false, + "7d": false, + }); + + const refresh = useCallback(async () => { + if (!account) { + if (mountedRef.current) { + setVelocities(computeVelocities([], thresholds)); + setLastUpdated(new Date()); + setError(null); + setLoading(false); + } + return; + } + try { + const records = await fetchOutgoingPayments(account); + if (!mountedRef.current) return; + const next = computeVelocities(records, thresholds); + setVelocities(next); + setLastUpdated(new Date()); + setError(null); + + // Fire alert events on threshold crossings (rising edge per window). + for (const window of VELOCITY_WINDOWS) { + const stat = next[window]; + const breached = stat.volume > stat.threshold; + if (breached && !alertStateRef.current[window] && typeof globalThis.dispatchEvent === "function") { + globalThis.dispatchEvent( + new CustomEvent("velocity:alert", { + detail: { window, volume: stat.volume, threshold: stat.threshold }, + }), + ); + } + alertStateRef.current[window] = breached; + } + } catch (err) { + if (!mountedRef.current) return; + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + if (mountedRef.current) setLoading(false); + } + }, [account, thresholds]); + + useEffect(() => { + mountedRef.current = true; + refresh(); + const interval = setInterval(refresh, VELOCITY_POLL_MS); + return () => { + mountedRef.current = false; + clearInterval(interval); + }; + }, [refresh]); + + const setThreshold = useCallback((window: VelocityWindow, value: number) => { + setThresholds((prev) => { + const next = { ...prev, [window]: value }; + try { + if (typeof globalThis.localStorage !== "undefined") { + globalThis.localStorage.setItem(VELOCITY_THRESHOLDS_KEY, JSON.stringify(next)); + } + } catch { + // storage unavailable — thresholds just won't persist + } + return next; + }); + }, []); + + return useMemo( + () => ({ velocities, lastUpdated, loading, error, thresholds, setThreshold, refresh }), + [velocities, lastUpdated, loading, error, thresholds, setThreshold, refresh], + ); +} + +export default usePaymentVelocity; diff --git a/src/lib/brandAssetStorage.ts b/src/lib/brandAssetStorage.ts new file mode 100644 index 0000000..2c44e50 --- /dev/null +++ b/src/lib/brandAssetStorage.ts @@ -0,0 +1,197 @@ +import { + AllowedLogoMimeType, + LOGO_MIME_EXTENSION, +} from "@/lib/brandSettings"; + +/** + * Brand asset (logo) storage. + * + * Production: streams uploads to Vercel Blob (`access: "public"`) and returns + * the CDN-backed `*.public.blob.vercel-storage.com` URL. Set + * BLOB_READ_WRITE_TOKEN to enable. + * + * Local/dev/CI fallback: keeps bytes in a process-level store and serves + * them through GET /api/settings/branding/logo/[...key] with immutable, + * year-long cache headers, which is the same shape a CDN edge cache + * consumes — no external services required. + */ + +export interface StoredBrandAsset { + bytes: Uint8Array; + contentType: AllowedLogoMimeType; + createdAt: string; +} + +export interface StoreBrandAssetResult { + /** Public URL (CDN-backed in production) for the stored asset. */ + url: string; + /** Storage key assigned to the asset. */ + key: string; + /** True when the asset landed in the Vercel Blob CDN. */ + cdn: boolean; + /** Detected intrinsic image size (null when headers can't be parsed). */ + width: number | null; + height: number | null; + /** Set when the image is likely to look soft at 300 dpi print. */ + lowResolutionWarning: string | null; +} + +/** Minimum logo width recommended for crisp 300 dpi print at ~2in display. */ +export const RECOMMENDED_LOGO_WIDTH_PX = 600; + +// In-memory fallback asset store per server process session. +const assetStore = new Map(); + +/** Build the canonical public URL for a locally-stored asset key. */ +export function localAssetUrl(key: string): string { + return `/api/settings/branding/logo/${key}`; +} + +/** Retrieve a locally-stored asset (fallback store only). */ +export function getStoredBrandAsset(key: string): StoredBrandAsset | null { + return assetStore.get(key) ?? null; +} + +/** Keys are `branding/
/logo-.` — validate path segments. */ +export function isValidAssetKey(key: string): boolean { + const parts = key.split("/"); + if (parts.length !== 3 || parts[0] !== "branding") return false; + return parts.every((p) => /^[A-Za-z0-9._-]+$/.test(p)); +} + +// ── Intrinsic image size parsing (no external deps) ────────────────────────── + +function u32be(b: Uint8Array, o: number): number { + return ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0; +} +function u16be(b: Uint8Array, o: number): number { + return (b[o] << 8) | b[o + 1]; +} + +function parsePngSize(b: Uint8Array): { width: number; height: number } | null { + // 8-byte signature + IHDR: width (4B BE) at offset 16, height at 20 + const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + if (b.length < 24 || sig.some((v, i) => b[i] !== v)) return null; + return { width: u32be(b, 16), height: u32be(b, 20) }; +} + +function parseJpegSize(b: Uint8Array): { width: number; height: number } | null { + if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null; + let o = 2; + while (o + 9 < b.length) { + if (b[o] !== 0xff) { o++; continue; } + const marker = b[o + 1]; + // SOF0..SOF15 (excluding DHT/DAC/RST markers) carry the frame size + if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) { + return { height: u16be(b, o + 5), width: u16be(b, o + 7) }; + } + const segLen = u16be(b, o + 2); + if (segLen < 2) return null; + o += 2 + segLen; + } + return null; +} + +function parseWebpSize(b: Uint8Array): { width: number; height: number } | null { + if (b.length < 30) return null; + const riff = String.fromCharCode(b[0], b[1], b[2], b[3]); + const webp = String.fromCharCode(b[8], b[9], b[10], b[11]); + if (riff !== "RIFF" || webp !== "WEBP") return null; + const chunk = String.fromCharCode(b[12], b[13], b[14], b[15]); + if (chunk === "VP8X") { + // 24-bit little-endian canvas size minus one, at offsets 24..29 + const width = 1 + (b[24] | (b[25] << 8) | (b[26] << 16)); + const height = 1 + (b[27] | (b[28] << 8) | (b[29] << 16)); + return { width, height }; + } + if (chunk === "VP8 " && b.length > 29) { + // Start code 0x9D 0x01 0x2A at 23..25, then 14-bit LE width/height + if (b[23] === 0x9d && b[24] === 0x01 && b[25] === 0x2a) { + const width = (b[26] | (b[27] << 8)) & 0x3fff; + const height = (b[28] | (b[29] << 8)) & 0x3fff; + return { width, height }; + } + } + if (chunk === "VP8L" && b.length > 24) { + // Signature byte 0x2F then 14-bit LE width/height minus one + if (b[20] !== 0x2f) return null; + const bits = b[21] | (b[22] << 8) | (b[23] << 16) | (b[24] << 24); + const width = (bits & 0x3fff) + 1; + const height = ((bits >>> 14) & 0x3fff) + 1; + return { width, height }; + } + return null; +} + +/** Best-effort intrinsic size detection for PNG / JPEG / WebP buffers. */ +export function parseImageDimensions( + bytes: Uint8Array, + contentType: AllowedLogoMimeType, +): { width: number; height: number } | null { + try { + switch (contentType) { + case "image/png": + return parsePngSize(bytes); + case "image/jpeg": + return parseJpegSize(bytes); + case "image/webp": + return parseWebpSize(bytes); + default: + return null; + } + } catch { + return null; + } +} + +/** + * Stores a validated logo upload and returns its public (CDN-friendly) URL. + */ +export async function storeBrandAsset(params: { + address: string; + bytes: Uint8Array; + contentType: AllowedLogoMimeType; +}): Promise { + const { address, bytes, contentType } = params; + const ext = LOGO_MIME_EXTENSION[contentType]; + const key = `branding/${address}/logo-${Date.now()}.${ext}`; + + const size = parseImageDimensions(bytes, contentType); + const lowResolutionWarning = + size && size.width < RECOMMENDED_LOGO_WIDTH_PX + ? `Logo is ${size.width}px wide — upload at least ${RECOMMENDED_LOGO_WIDTH_PX}px-wide art for sharp 300 dpi print.` + : null; + + // Production path: Vercel Blob (S3-compatible object store with a CDN). + if (process.env.BLOB_READ_WRITE_TOKEN) { + const { put } = await import("@vercel/blob"); + const blob = await put(key, Buffer.from(bytes), { + access: "public", + contentType, + token: process.env.BLOB_READ_WRITE_TOKEN, + }); + return { + url: blob.url, + key: blob.pathname, + cdn: true, + width: size?.width ?? null, + height: size?.height ?? null, + lowResolutionWarning, + }; + } + + // Local/dev fallback: process-level store served via the logo GET route. + assetStore.set(key, { + bytes, + contentType, + createdAt: new Date().toISOString(), + }); + return { + url: localAssetUrl(key), + key, + cdn: false, + width: size?.width ?? null, + height: size?.height ?? null, + lowResolutionWarning, + }; +} diff --git a/src/lib/brandSettings.ts b/src/lib/brandSettings.ts new file mode 100644 index 0000000..ba28645 --- /dev/null +++ b/src/lib/brandSettings.ts @@ -0,0 +1,119 @@ +import { z } from "zod"; +import { checkContrast, isValidHexColor, WCAG_AA_CONTRAST_RATIO } from "@/lib/contrast"; + +/** Maximum accepted logo upload size: 2 MB. */ +export const MAX_LOGO_BYTES = 2 * 1024 * 1024; + +/** MIME types accepted for logo uploads. */ +export const ALLOWED_LOGO_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const; +export type AllowedLogoMimeType = (typeof ALLOWED_LOGO_MIME_TYPES)[number]; + +/** Extension mapping used when building storage keys for uploaded logos. */ +export const LOGO_MIME_EXTENSION: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", +}; + +/** Max characters for the invoice tagline. */ +export const MAX_TAGLINE_LENGTH = 120; + +export const LOGO_TYPE_ERROR = + "Unsupported file type. Upload a PNG, JPEG, or WebP image."; +export const LOGO_SIZE_ERROR = "Logo exceeds the 2 MB size limit."; + +/** True when the (already-typechecked) logo fits the 2 MB cap. */ +export function isLogoSizeOk(bytes: number): boolean { + return bytes > 0 && bytes <= MAX_LOGO_BYTES; +} + +/** True when the supplied MIME type is in the logo allow-list. */ +export function isAllowedLogoMimeType(mime: string): mime is AllowedLogoMimeType { + return (ALLOWED_LOGO_MIME_TYPES as readonly string[]).includes(mime); +} + +/** + * Brand settings scoped to one user account (identified by their Stellar + * address). `null` means "not set — fall back to platform defaults". + */ +export interface BrandSettings { + logoUrl: string | null; + accentColor: string | null; + tagline: string | null; + updatedAt: string; +} + +/** Platform-default branding (used when the user has not customized). */ +export const DEFAULT_BRAND_SETTINGS: Omit = { + logoUrl: null, + accentColor: null, + tagline: null, +}; + +/** Platform default accent used when no accent color is configured. */ +export const DEFAULT_ACCENT_COLOR = "#4f46e5"; + +const hexColorSchema = z + .string() + .trim() + .refine(isValidHexColor, { + message: "Enter a valid hex color (e.g. #4f46e5)", + }) + .superRefine((value, ctx) => { + if (!isValidHexColor(value)) return; + const { ratio, passes } = checkContrast(value, "#ffffff"); + if (!passes) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Accent color fails WCAG AA contrast against white (${ratio.toFixed( + 2, + )}:1; needs ${WCAG_AA_CONTRAST_RATIO}:1)`, + }); + } + }); + +/** + * Accepts absolute http(s) URLs (CDN-hosted logos in production) or + * root-relative paths (the local dev asset route, e.g. + * "/api/settings/branding/logo/branding//logo-1.png"). + */ +const logoUrlSchema = z + .string() + .trim() + .refine( + (value) => value.startsWith("/") || /^https?:\/\//i.test(value), + { message: "Logo URL must be a valid URL" }, + ); + +/** + * Zod schema shared by the branding form (react-hook-form resolver) and the + * PUT /api/settings/branding route. Empty strings and null treat a field as + * "cleared / platform default". + */ +export const brandSettingsSchema = z.object({ + logoUrl: z + .union([z.literal(""), logoUrlSchema]) + .nullable() + .transform((v) => (v === "" ? null : v ?? null)) + .optional(), + accentColor: z + .union([z.literal(""), hexColorSchema]) + .nullable() + .transform((v) => (v === "" ? null : v ?? null)) + .optional(), + tagline: z + .string() + .trim() + .max(MAX_TAGLINE_LENGTH, `Tagline must be ${MAX_TAGLINE_LENGTH} characters or fewer`) + .transform((v) => (v.length === 0 ? null : v)) + .nullable() + .optional(), +}); + +export type BrandSettingsInput = z.input; +export type BrandSettingsOutput = z.output; + +/** Basic Stellar public key sanity check (G + 55 base32 chars). */ +export function isStellarAddress(address: string): boolean { + return /^G[A-Z2-7]{55}$/.test(address); +} diff --git a/src/lib/brandSettingsStore.ts b/src/lib/brandSettingsStore.ts new file mode 100644 index 0000000..e5ebb2f --- /dev/null +++ b/src/lib/brandSettingsStore.ts @@ -0,0 +1,65 @@ +import { + BrandSettings, + DEFAULT_BRAND_SETTINGS, +} from "@/lib/brandSettings"; + +/** + * Server-side store for per-account brand settings, keyed by Stellar + * address. Follows the same in-memory pattern as serverAddressBook.ts — the + * module-level map persists for the lifetime of the server process and is + * shared by the branding API routes. + */ + +// In-memory store per server process session, keyed by Stellar address. +const brandStore = new Map(); + +function normalizeAddress(address: string): string { + return address.trim(); +} + +/** + * Returns the brand settings for an account, or platform defaults when the + * account has never saved any. + */ +export function getBrandSettings(address: string): BrandSettings { + const key = normalizeAddress(address); + return ( + brandStore.get(key) ?? { + ...DEFAULT_BRAND_SETTINGS, + updatedAt: "", + } + ); +} + +/** + * True when the account has an explicitly-saved settings record. + */ +export function hasBrandSettings(address: string): boolean { + return brandStore.has(normalizeAddress(address)); +} + +/** + * Saves brand settings for an account, replacing any previous record. + */ +export function saveBrandSettings( + address: string, + settings: { logoUrl: string | null; accentColor: string | null; tagline: string | null }, +): BrandSettings { + const key = normalizeAddress(address); + const record: BrandSettings = { + logoUrl: settings.logoUrl, + accentColor: settings.accentColor, + tagline: settings.tagline, + updatedAt: new Date().toISOString(), + }; + brandStore.set(key, record); + return record; +} + +/** + * Removes any stored branding for an account, reverting its invoices to + * platform-default styling. Returns true when a record existed. + */ +export function clearBrandSettings(address: string): boolean { + return brandStore.delete(normalizeAddress(address)); +} diff --git a/src/lib/branding.ts b/src/lib/branding.ts new file mode 100644 index 0000000..f8ce79f --- /dev/null +++ b/src/lib/branding.ts @@ -0,0 +1,121 @@ +import type { BrandSettings } from "@/lib/brandSettings"; +import { DEFAULT_BRAND_SETTINGS } from "@/lib/brandSettings"; + +/** + * Client helpers for account branding: fetch/save/clear through the settings + * API with a localStorage mirror so invoices paint instantly on repeat views + * and stay branded when the dev server restarts. + */ + +const cacheKey = (address: string) => `stellarsplit:branding:${address}`; + +function readCache(address: string): BrandSettings | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(cacheKey(address)); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + return { + logoUrl: parsed.logoUrl ?? null, + accentColor: parsed.accentColor ?? null, + tagline: parsed.tagline ?? null, + updatedAt: parsed.updatedAt ?? "", + }; + } + } catch { + // malformed cache — ignore + } + return null; +} + +function writeCache(address: string, settings: BrandSettings): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(cacheKey(address), JSON.stringify(settings)); + } catch { + // storage unavailable — server record still persists + } +} + +function clearCache(address: string): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(cacheKey(address)); + } catch { + // ignore + } +} + +/** + * Fetches the brand settings for an account. Falls back to the localStorage + * mirror when the network call fails, and finally to platform defaults. + */ +export async function fetchBrandSettings(address: string): Promise { + try { + const res = await fetch(`/api/settings/branding?address=${encodeURIComponent(address)}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const settings = (await res.json()) as BrandSettings; + writeCache(address, settings); + return settings; + } catch { + return ( + readCache(address) ?? { ...DEFAULT_BRAND_SETTINGS, updatedAt: "" } + ); + } +} + +/** Persists brand settings for an account via the settings API. */ +export async function saveBrandSettingsRemote( + address: string, + settings: { logoUrl: string | null; accentColor: string | null; tagline: string | null }, +): Promise { + const res = await fetch("/api/settings/branding", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address, ...settings }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(typeof data?.error === "string" ? data.error : "Could not save branding"); + } + writeCache(address, data as BrandSettings); + return data as BrandSettings; +} + +/** Clears brand settings for an account (invoice styling reverts to default). */ +export async function clearBrandSettingsRemote(address: string): Promise { + const res = await fetch("/api/settings/branding", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(typeof data?.error === "string" ? data.error : "Could not clear branding"); + } + clearCache(address); +} + +/** + * Uploads a logo file for an account; returns the assigned URL. Client-side + * pre-validation in BrandingForm means the server should only fail this + * request for genuine transport/storage errors. + */ +export async function uploadBrandLogo( + address: string, + file: File, +): Promise<{ logoUrl: string; width: number | null; height: number | null; warning: string | null; cdn: boolean }> { + const form = new FormData(); + form.append("file", file); + form.append("address", address); + const res = await fetch("/api/settings/branding/logo", { + method: "POST", + body: form, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(typeof data?.error === "string" ? data.error : "Logo upload failed"); + } + return data; +} diff --git a/src/lib/contrast.ts b/src/lib/contrast.ts new file mode 100644 index 0000000..2e811a4 --- /dev/null +++ b/src/lib/contrast.ts @@ -0,0 +1,94 @@ +/** + * WCAG 2.1 relative-luminance / contrast-ratio utilities. + * Used to validate brand accent colors against invoice backgrounds. + */ + +/** Minimum contrast ratio required by WCAG AA for normal text. */ +export const WCAG_AA_CONTRAST_RATIO = 4.5; + +export interface RgbColor { + r: number; + g: number; + b: number; +} + +/** + * Parses a hex color string ("#RGB" or "#RRGGBB", case-insensitive, "#" + * optional) into an RGB tuple. Returns null for anything else. + */ +export function hexToRgb(hex: string): RgbColor | null { + const trimmed = hex.trim().replace(/^#/, ""); + if (!/^[0-9a-fA-F]{3}$/.test(trimmed) && !/^[0-9a-fA-F]{6}$/.test(trimmed)) { + return null; + } + const normalized = + trimmed.length === 3 + ? trimmed + .split("") + .map((c) => c + c) + .join("") + : trimmed; + const int = parseInt(normalized, 16); + return { + r: (int >> 16) & 0xff, + g: (int >> 8) & 0xff, + b: int & 0xff, + }; +} + +/** True when the string is a well-formed hex color (#RGB / #RRGGBB). */ +export function isValidHexColor(hex: string): boolean { + return hexToRgb(hex) !== null; +} + +function channelLuminance(channel: number): number { + const c = channel / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} + +/** WCAG 2.1 relative luminance (0 = black, 1 = white). */ +export function relativeLuminance(hex: string): number { + const rgb = hexToRgb(hex); + if (!rgb) { + throw new Error(`Invalid hex color: ${hex}`); + } + return ( + 0.2126 * channelLuminance(rgb.r) + + 0.7152 * channelLuminance(rgb.g) + + 0.0722 * channelLuminance(rgb.b) + ); +} + +/** + * WCAG 2.1 contrast ratio between two hex colors (1..21). + * Accepts colors in any order — the lighter one is luminance-maximized. + */ +export function contrastRatio(hexA: string, hexB: string): number { + const lumA = relativeLuminance(hexA); + const lumB = relativeLuminance(hexB); + const lighter = Math.max(lumA, lumB); + const darker = Math.min(lumA, lumB); + return (lighter + 0.05) / (darker + 0.05); +} + +/** + * Returns an object describing whether `hex` meets WCAG AA contrast (4.5:1) + * for text rendered in `hex` over a `background` background (default white, + * which is what the invoice PDF / print view uses). + */ +export function checkContrast( + hex: string, + background = "#ffffff", +): { ratio: number; passes: boolean } { + const ratio = contrastRatio(hex, background); + return { ratio, passes: ratio >= WCAG_AA_CONTRAST_RATIO }; +} + +/** Convenience boolean: does this color pass WCAG AA against white? */ +export function meetsWcagAaAgainstWhite(hex: string): boolean { + try { + return checkContrast(hex, "#ffffff").passes; + } catch { + return false; + } +} diff --git a/src/lib/dashboardFilters.ts b/src/lib/dashboardFilters.ts index d4e1964..9304af4 100644 --- a/src/lib/dashboardFilters.ts +++ b/src/lib/dashboardFilters.ts @@ -1,5 +1,8 @@ import type { Invoice } from "@stellar-split/sdk"; +/** Dashboard view model for an invoice. Currently a direct alias of the SDK type. */ +export type DashboardInvoice = Invoice; + export type DashboardPresetId = "all" | "active" | "funded" | "refunded" | "expired" | "draft" | "overdue"; export type DashboardSortId = "newest" | "oldest" | "amount-desc" | "amount-asc" | "deadline"; @@ -157,18 +160,15 @@ export function filterDashboardInvoices( invoices: Invoice[], preset: DashboardPresetId, now = Math.floor(Date.now() / 1000), - selectedStatuses: InvoiceStatusFilter[] = [], -): DashboardInvoice[] { - return invoices.filter((invoice) => { - const matchesPreset = matchesDashboardPreset(invoice, publicKey, preset, now); - const matchesQuery = matchesTextSearch(invoice, query); - const matchesStatus = matchesStatusFilter(invoice, selectedStatuses, now); - return matchesPreset && matchesQuery && matchesStatus; - }); splitMetaMap?: Record, + selectedStatuses: InvoiceStatusFilter[] = [], + query = "", ): Invoice[] { - return invoices.filter((invoice) => - matchesDashboardPreset(invoice, preset, now, splitMetaMap?.[invoice.id]), + return invoices.filter( + (invoice) => + matchesDashboardPreset(invoice, preset, now, splitMetaMap?.[invoice.id]) && + matchesStatusFilter(invoice, selectedStatuses, now) && + matchesTextSearch(invoice, query), ); } diff --git a/src/lib/fraudRiskScorer.ts b/src/lib/fraudRiskScorer.ts index 26f45e4..ba99ce2 100644 --- a/src/lib/fraudRiskScorer.ts +++ b/src/lib/fraudRiskScorer.ts @@ -58,7 +58,11 @@ export function calculateFraudRiskScore( (f) => f.type === AnomalyType.RAPID_SUCCESSION, ).length; if (rapidCount > 0) { - const contribution = Math.min(rapidCount * 15, WEIGHTS.RAPID_SUCCESSION); + // Each rapid-succession flag carries the full signal weight (capped). + const contribution = Math.min( + rapidCount * WEIGHTS.RAPID_SUCCESSION, + WEIGHTS.RAPID_SUCCESSION, + ); signals.push({ signal: "rapid_succession", weight: WEIGHTS.RAPID_SUCCESSION, @@ -73,8 +77,9 @@ export function calculateFraudRiskScore( (f) => f.type === AnomalyType.FIRST_TIME_LARGE, ).length; if (firstTimeLargeCount > 0) { + // Each first-time large-payer flag carries the full signal weight (capped). const contribution = Math.min( - firstTimeLargeCount * 20, + firstTimeLargeCount * WEIGHTS.FIRST_TIME_LARGE, WEIGHTS.FIRST_TIME_LARGE, ); signals.push({ @@ -94,7 +99,7 @@ export function calculateFraudRiskScore( if (invoiceTotal > 1_000_000n) { // Large invoice threshold (>1M stroops) const contribution = Math.min( - (Number(invoiceTotal) / 1_000_000n) * 5, + (Number(invoiceTotal) / 1_000_000) * 5, WEIGHTS.LARGE_AMOUNT, ); signals.push({ diff --git a/src/lib/invoiceExcelExport.ts b/src/lib/invoiceExcelExport.ts index 25695a3..c396d48 100644 --- a/src/lib/invoiceExcelExport.ts +++ b/src/lib/invoiceExcelExport.ts @@ -93,15 +93,18 @@ export function transactionsToExportRows(invoices: Invoice[]): TransactionExport for (const invoice of invoices) { for (const payment of invoice.payments) { + // `timestamp` is set at runtime by the indexer but is not part of the + // published SDK Payment type — read it defensively. + const timestamp = (payment as { timestamp?: number }).timestamp; rows.push({ invoiceId: invoice.id, payer: payment.payer, amount: formatAmount(payment.amount), - timestamp: payment.timestamp + timestamp: timestamp ? new Date( - payment.timestamp < 10000000000 - ? payment.timestamp * 1000 - : payment.timestamp + timestamp < 10000000000 + ? timestamp * 1000 + : timestamp ).toISOString() : 'Unknown', txHash: (payment as any).txHash || '', @@ -248,7 +251,7 @@ export function downloadExcel( ): void { if (typeof window === 'undefined') return; - const blob = new Blob([data], { + const blob = new Blob([data as unknown as BlobPart], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); const url = URL.createObjectURL(blob); diff --git a/src/lib/securitySettings.ts b/src/lib/securitySettings.ts index e441d7c..cf3197f 100644 --- a/src/lib/securitySettings.ts +++ b/src/lib/securitySettings.ts @@ -1,5 +1,5 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto"; -import { authenticator } from "otplib"; +import { generateSecret, generateURI, verifySync } from "otplib"; import QRCode from "qrcode"; export interface SecuritySettings { @@ -84,8 +84,8 @@ export function getSecuritySettings(userId: string): SecuritySettings { } export async function beginMfaEnrollment(userId: string) { - const secret = authenticator.generateSecret(); - const otpauthUrl = authenticator.keyuri(userId, "Stellar Split", secret); + const secret = generateSecret(); + const otpauthUrl = generateURI({ issuer: "Stellar Split", label: userId, secret }); const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl); PENDING_ENROLLMENTS.set(userId, { @@ -113,7 +113,7 @@ export function confirmMfaEnrollment(userId: string, code: string) { return { success: false, error: "Enrollment expired. Start again." }; } - const isValid = authenticator.check(code, pending.secret); + const isValid = verifySync({ secret: pending.secret, token: code }).valid; if (!isValid) { return { success: false, error: "The verification code was invalid." }; } @@ -138,7 +138,7 @@ export function disableMfa(userId: string, code: string) { } const secret = decryptSecret(settings.encryptedSecret); - const isValid = authenticator.check(code, secret); + const isValid = verifySync({ secret: secret, token: code }).valid; if (!isValid) { return { success: false, error: "The verification code was invalid." }; } @@ -175,7 +175,7 @@ export function verifyMfaCode(userId: string, code: string) { try { const secret = decryptSecret(settings.encryptedSecret); - const isValid = authenticator.check(code, secret); + const isValid = verifySync({ secret: secret, token: code }).valid; if (!isValid) { const nextAttempts = (settings.failedAttempts || 0) + 1; const lockedUntil = nextAttempts >= MAX_FAILURES ? now + LOCKOUT_MS : undefined; diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index 4e9a04d..1da42f5 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -161,20 +161,31 @@ export async function payMilestone(params: { milestoneId: string; }): Promise<{ txHash: string }> { const client = getSplitClient(); - const { TransactionBuilder, Networks, BASE_FEE, Memo, nativeToScVal, SorobanRpc } = await import("@stellar/stellar-sdk"); + const { TransactionBuilder, BASE_FEE, Memo, nativeToScVal, rpc } = await import("@stellar/stellar-sdk"); const { signWithFreighter } = await import("./freighter"); + // The published @stellar-split/sdk keeps `contract`, `server` and `config` + // as private fields with no public accessors. Milestone payments need a + // custom memo, which the SDK's typed API does not support, so we reach the + // underlying internals here (object properties — still accessible at + // runtime despite the `private` TypeScript modifier). + const internals = client as unknown as { + contract: { call: (...args: any[]) => any }; + server: any; + config: { networkPassphrase: string }; + }; + const memoText = `INV:${params.invoiceId}:M:${params.milestoneId}`.slice(0, 28); - const operation = client.contract.call( + const operation = internals.contract.call( "pay", nativeToScVal(params.payer, { type: "address" }), nativeToScVal(BigInt(params.invoiceId), { type: "u64" }), nativeToScVal(params.amount, { type: "i128" }) ); - const sourceAccount = await client.server.getAccount(params.payer); - const networkPassphrase = client.config.networkPassphrase; + const sourceAccount = await internals.server.getAccount(params.payer); + const networkPassphrase = internals.config.networkPassphrase; const tx = new TransactionBuilder(sourceAccount, { fee: BASE_FEE, @@ -185,29 +196,29 @@ export async function payMilestone(params: { .setTimeout(30) .build(); - const simResult = await client.server.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationError(simResult)) { + const simResult = await internals.server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(simResult)) { throw new Error(`Simulation failed: ${simResult.error}`); } - const preparedTx = SorobanRpc.assembleTransaction(tx, simResult).build(); - const signedXdr = await signWithFreighter(preparedTx.toXDR(), networkPassphrase); + const preparedTx = rpc.assembleTransaction(tx, simResult).build(); + const signedXdr = await signWithFreighter(preparedTx.toXDR()); const signedTx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase); - const sendResult = await client.server.sendTransaction(signedTx); + const sendResult = await internals.server.sendTransaction(signedTx); if (sendResult.status === "ERROR") { throw new Error(`Transaction failed: ${JSON.stringify(sendResult.errorResult)}`); } - let getResult = await client.server.getTransaction(sendResult.hash); + let getResult = await internals.server.getTransaction(sendResult.hash); let attempts = 0; - while (getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && attempts < 20) { + while (getResult.status === rpc.Api.GetTransactionStatus.NOT_FOUND && attempts < 20) { await new Promise((r) => setTimeout(r, 1500)); - getResult = await client.server.getTransaction(sendResult.hash); + getResult = await internals.server.getTransaction(sendResult.hash); attempts++; } - if (getResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + if (getResult.status !== rpc.Api.GetTransactionStatus.SUCCESS) { throw new Error(`Transaction not confirmed: ${getResult.status}`); } @@ -276,8 +287,8 @@ export async function fetchUsdcBalance( */ export async function resolveFederationAddress(federationAddress: string): Promise { try { - const { FederationServer } = await import("@stellar/stellar-sdk"); - const result = await FederationServer.resolveAddress(federationAddress); + const { Federation } = await import("@stellar/stellar-sdk"); + const result = await Federation.Server.resolve(federationAddress); return result.account_id; } catch { return null; @@ -299,7 +310,13 @@ export async function validateFederationAddress(address: string): Promise<{ const balances = account.balances; const isFunded = balances.length > 0; - const requiresMemo = account.flags?.require_auth_memoized_flag || false; + // Horizon's standard account flags don't expose a memo-required flag in + // current SDK typings — some Horizon deployments include a non-standard + // `require_auth_memoized_flag`, so read it defensively. + const flags = account.flags as + | (typeof account.flags & { require_auth_memoized_flag?: boolean }) + | undefined; + const requiresMemo = flags?.require_auth_memoized_flag || false; return { isFunded, requiresMemo }; } catch (error) {