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 = ``;
+ 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 && (
+
Pay toward this invoice
@@ -888,6 +911,7 @@ export default function InvoiceDetailPage({ params }: Props) {
Your share is {formatAmount(recipientShare.amount)} USDC.
)}
+
{
+ // `metadata` (MFA token) is forwarded to the contract ABI at
+ // runtime but missing from the published SDK PayParams type.
const result = await splitClient.pay({
payer: publicKey,
invoiceId: id,
amount,
metadata: mfaToken ? { mfaToken } : undefined,
- });
+ } as Parameters[0] & { metadata?: { mfaToken: string } });
fetch("/api/cron/funding-thresholds", {
method: "POST",
headers: { "Content-Type": "application/json" },
diff --git a/src/app/invoice/new/page.tsx b/src/app/invoice/new/page.tsx
index d21139a..e2998bc 100644
--- a/src/app/invoice/new/page.tsx
+++ b/src/app/invoice/new/page.tsx
@@ -36,6 +36,9 @@ import {
calculateSplit,
type SplitMeta,
} from "@/hooks/useSplitCalculator";
+
+import InstallmentPlanBuilder, { type InstallmentMilestone as PlanMilestone } from "@/components/invoice/InstallmentPlanBuilder";
+
import InstallmentPlanBuilder from "@/components/invoice/InstallmentPlanBuilder";
import AmountDenominationInput from "@/components/AmountDenominationInput";
import { useXlmUsdcRate } from "@/hooks/useXlmUsdcRate";
@@ -45,6 +48,7 @@ import CursorOverlay from "@/components/CursorOverlay";
import PresencePill from "@/components/PresencePill";
import ReconnectionBanner from "@/components/ReconnectionBanner";
+
const RecipientForm = dynamic(() => import("@/components/RecipientForm"), { ssr: false });
const TemplateManager = dynamic(() => import("@/components/TemplateManager"), { ssr: false });
const TxImportPanel = dynamic(() => import("@/components/invoice/TxImportPanel"), { ssr: false });
@@ -144,7 +148,7 @@ function NewInvoiceForm() {
const [intervalDays, setIntervalDays] = useState<7 | 30>(7);
const [submitting, setSubmitting] = useState(false);
const [splitMeta, setSplitMeta] = useState(null);
- const [installments, setInstallments] = useState<{ id: string; amount: number; dueDate: number; status: string; txHash?: string }[]>([]);
+ const [installments, setInstallments] = useState([]);
const [tags, setTags] = useState([]);
const { allTags, saveTags } = useInvoiceTags();
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 826aa8e..35c48d3 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -4,9 +4,6 @@ import "./globals.css";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { AccessibilityProvider } from "@/contexts/AccessibilityContext";
import { WalletProvider } from "@/contexts/WalletContext";
-import ThemeToggle from "@/components/ThemeToggle";
-import NotificationCenter from "@/components/NotificationCenter";
-import WalletConnect from "@/components/WalletConnect";
import Navbar from "@/components/Navbar";
import ErrorBoundary from "@/components/ErrorBoundary";
import OnboardingFlow from "@/components/OnboardingFlow";
@@ -109,67 +106,6 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
-
-
-
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
// dir="ltr" is set here as scaffold; I18nProvider will update it client-side when RTL locales (ar/he) are added
@@ -180,6 +116,37 @@ export default function RootLayout({
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -209,6 +176,7 @@ export default function RootLayout({
+
diff --git a/src/app/settings/address-book/page.tsx b/src/app/settings/address-book/page.tsx
index bd191ff..360891f 100644
--- a/src/app/settings/address-book/page.tsx
+++ b/src/app/settings/address-book/page.tsx
@@ -387,7 +387,7 @@ export default function SettingsAddressBookPage() {
Are you sure you want to delete contact{" "}
- "{deleteConfirmContact.label}" (
+ "{deleteConfirmContact.label}" (
{truncateAddress(deleteConfirmContact.address)}
diff --git a/src/app/settings/branding/page.tsx b/src/app/settings/branding/page.tsx
new file mode 100644
index 0000000..666e91d
--- /dev/null
+++ b/src/app/settings/branding/page.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from "next";
+import BrandingForm from "@/components/settings/BrandingForm";
+
+export const metadata: Metadata = {
+ title: "Branding — StellarSplit Settings",
+ robots: { index: false, follow: false },
+};
+
+export default function BrandingSettingsPage() {
+ return (
+
+ Branding
+
+ Upload your company logo, pick an accent color, and write a tagline.
+ They are rendered on every invoice you create — both the live invoice
+ page and the PDF export — and are scoped to your Stellar account.
+
+
+
+ );
+}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index 76b5db9..715a5f2 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -66,6 +66,11 @@ export default function SettingsPage() {
Accessibility
+
+
+ Branding
+
+
Notifications
diff --git a/src/components/DashboardClient.tsx b/src/components/DashboardClient.tsx
index cf672f3..c6dcf41 100644
--- a/src/components/DashboardClient.tsx
+++ b/src/components/DashboardClient.tsx
@@ -3,7 +3,6 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import Link from "next/link";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { useRouter, useSearchParams } from "next/navigation";
import { getFreighterPublicKey } from "@/lib/freighter";
import { splitClient } from "@/lib/stellar";
import InvoiceSearch from "@/components/InvoiceSearch";
@@ -11,7 +10,7 @@ import InvoiceCard from "@/components/InvoiceCard";
import ActivityFeed from "@/components/ActivityFeed";
import { useActivityFeed } from "@/hooks/useActivityFeed";
import InvoiceShareQRModal from "@/components/InvoiceShareQRModal";
-import { InvoiceListSkeleton, SkeletonCard } from "@/components/Skeleton";
+import { SkeletonCard } from "@/components/Skeleton";
import BatchPayModal from "@/components/BatchPayModal";
import StatusFilterChips from "@/components/invoice/StatusFilterChips";
import { setBulkReminders, type BulkReminderResult } from "@/lib/reminders";
@@ -27,13 +26,13 @@ import {
SORT_OPTIONS,
filterDashboardInvoices,
getDashboardPresetCounts,
+ matchesStatusFilter,
INVOICE_STATUS_FILTERS,
- type DashboardPresetId,
- type InvoiceStatusFilter,
sortInvoices,
filterByDateRange,
type DashboardPresetId,
type DashboardSortId,
+ type InvoiceStatusFilter,
} from "@/lib/dashboardFilters";
import { useInvoiceTags } from "@/hooks/useInvoiceTags";
import { invoiceHasTag } from "@/lib/invoiceTags";
@@ -54,6 +53,7 @@ function readParams(sp: URLSearchParams) {
export default function DashboardClient() {
const router = useRouter();
+ const pathname = usePathname();
const searchParams = useSearchParams();
// URL-derived filter state
@@ -82,7 +82,13 @@ export default function DashboardClient() {
// Activity feed panel
const [feedOpen, setFeedOpen] = useState(false);
const { unreadCount } = useActivityFeed();
- const [splitMetaMap, setSplitMetaMap] = useState>({});
+ const [splitMetaMap, setSplitMetaMap] = useState<
+ Record
+ >({});
+ // Compare mode
+ const [shareQRInvoiceId, setShareQRInvoiceId] = useState(null);
+ const [compareMode, setCompareMode] = useState(false);
+ const [compareSelected, setCompareSelected] = useState>(new Set());
// Multi-select state management
const {
@@ -105,9 +111,10 @@ export default function DashboardClient() {
if (v) sp.set(k, v);
else sp.delete(k);
}
- router.replace(`?${sp.toString()}`, { scroll: false });
+ const qs = sp.toString();
+ router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
},
- [router, searchParams],
+ [router, pathname, searchParams],
);
const toggleStatus = (preset: DashboardPresetId) => {
@@ -118,10 +125,14 @@ export default function DashboardClient() {
};
const clearFilters = () => {
- router.replace("?", { scroll: false });
+ router.replace(pathname, { scroll: false });
setSearchValue("");
};
+
+ // Lifecycle display-status chips (separate URL param so it never clashes
+ // with the preset `status` param above).
+
const isFiltered =
statuses.length > 0 || dateFrom || dateTo || sort !== "newest" || !!tag;
@@ -177,8 +188,9 @@ export default function DashboardClient() {
const pathname = usePathname();
const searchParams = useSearchParams();
+
const selectedStatuses = useMemo(() => {
- const raw = searchParams.get("status");
+ const raw = searchParams.get("display");
if (!raw) return [];
return raw
.split(",")
@@ -187,20 +199,23 @@ export default function DashboardClient() {
);
}, [searchParams]);
- const toggleStatus = (status: InvoiceStatusFilter) => {
+ const toggleDisplayStatus = (status: InvoiceStatusFilter) => {
const next = selectedStatuses.includes(status)
? selectedStatuses.filter((s) => s !== status)
: [...selectedStatuses, status];
- const params = new URLSearchParams(searchParams.toString());
- if (next.length > 0) {
- params.set("status", next.join(","));
- } else {
- params.delete("status");
- }
- const qs = params.toString();
- router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
+ pushParams({ display: next.join(",") });
};
+ const isFiltered =
+ statuses.length > 0 ||
+ selectedStatuses.length > 0 ||
+ dateFrom ||
+ dateTo ||
+ sort !== "newest" ||
+ !!tag;
+
+ // ── Data fetching ───────────────────────────────────────────────────────────
+
// Get wallet public key
useEffect(() => {
getFreighterPublicKey()
@@ -232,31 +247,6 @@ export default function DashboardClient() {
};
}, [router]);
- // Fetch invoices progressively
- useEffect(() => {
- if (!publicKey) return;
- const fetchInvoices = async () => {
- setLoading(true);
- const results: Invoice[] = [];
- for (let id = 1; id <= 50; id++) {
- try {
- const inv = await splitClient.getInvoice(String(id));
- const mine =
- inv.creator === publicKey ||
- inv.recipients.some((r) => r.address === publicKey);
- if (mine) {
- results.push(inv);
- setInvoices([...results]);
- }
- } catch {
- break;
- }
- }
- setLoading(false);
- };
- fetchInvoices().catch((e) => { setError(String(e)); setLoading(false); });
- }, [publicKey]);
-
// Fetch splitMeta for overdue detection
useEffect(() => {
if (!publicKey || invoices.length === 0) return;
@@ -280,10 +270,11 @@ export default function DashboardClient() {
);
if (!cancelled) setSplitMetaMap(map);
})();
- return () => { cancelled = true; };
+ return () => {
+ cancelled = true;
+ };
}, [publicKey, invoices]);
- // Numeric search debounce
// ── Numeric search debounce ─────────────────────────────────────────────────
useEffect(() => {
const trimmed = searchValue.trim();
@@ -304,38 +295,48 @@ export default function DashboardClient() {
if (!cancelled) setSearchLoading(false);
}
}, 300);
- return () => { cancelled = true; window.clearTimeout(t); };
+ return () => {
+ cancelled = true;
+ window.clearTimeout(t);
+ };
}, [searchValue]);
// ── Derived data ────────────────────────────────────────────────────────────
- const presetCounts = useMemo(() => getDashboardPresetCounts(invoices, Math.floor(Date.now() / 1000), splitMetaMap), [invoices, splitMetaMap]);
+ const presetCounts = useMemo(
+ () => getDashboardPresetCounts(invoices, Math.floor(Date.now() / 1000), splitMetaMap),
+ [invoices, splitMetaMap],
+ );
const visibleInvoices = useMemo(() => {
- // 1. status filter (multi-select chips); if none selected show all
+ const now = Math.floor(Date.now() / 1000);
+ // 1. preset chips (multi-select); if none selected show all
let result =
statuses.length === 0
? invoices
: invoices.filter((inv) =>
- statuses.some((s) =>
- filterDashboardInvoices([inv], s, Math.floor(Date.now() / 1000), splitMetaMap).length > 0,
+ statuses.some(
+ (s) => filterDashboardInvoices([inv], s, now, splitMetaMap).length > 0,
),
);
- // 2. date range
+ // 2. lifecycle display-status chips
+ result = result.filter((inv) => matchesStatusFilter(inv, selectedStatuses, now));
+ // 3. date range
result = filterByDateRange(result, dateFrom, dateTo);
- // 3. tag
+ // 4. tag
if (tag) {
result = result.filter((inv) => invoiceHasTag(tagsByInvoice[inv.id] ?? [], tag));
}
- // 4. sort
+ // 5. sort
result = sortInvoices(result, sort);
return result;
- }, [invoices, statuses, dateFrom, dateTo, sort, splitMetaMap]);
- }, [invoices, statuses, dateFrom, dateTo, sort, tag, tagsByInvoice]);
+ }, [invoices, statuses, selectedStatuses, dateFrom, dateTo, sort, tag, tagsByInvoice, splitMetaMap]);
const { totalActive, totalValueLocked, totalReleased } = useMemo(() => {
const now = Math.floor(Date.now() / 1000);
- let tvl = 0n, released = 0n, active = 0;
+ let tvl = 0n,
+ released = 0n,
+ active = 0;
for (const inv of invoices) {
const total = inv.recipients.reduce((s, r) => s + r.amount, 0n);
if (inv.status === "Pending") {
@@ -351,19 +352,26 @@ export default function DashboardClient() {
const pendingInvoices = invoices.filter((inv) => inv.status === "Pending");
const selectedInvoices = invoices.filter((inv) => selected.has(inv.id));
+ // ── Selection handlers ─────────────────────────────────────────────────────
+
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
- next.has(id) ? next.delete(id) : next.add(id);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
return next;
});
};
- const exitMultiSelect = () => { setMultiSelect(false); setSelected(new Set()); };
+ const exitMultiSelect = () => {
+ setMultiSelect(false);
+ setSelected(new Set());
+ };
const toggleReminderSelect = (id: string) => {
setReminderSelected((prev) => {
const next = new Set(prev);
- next.has(id) ? next.delete(id) : next.add(id);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
return next;
});
};
@@ -375,18 +383,11 @@ export default function DashboardClient() {
setBulkReminderResults(null);
};
- const clearFilters = () => {
- setActivePreset("all");
- setSearchValue("");
- setNumericResult(null);
- if (searchParams.get("status")) {
- router.replace(pathname, { scroll: false });
- }
const toggleCompareSelect = (id: string) => {
- if (compareSelected.size >= 2 && !compareSelected.has(id)) {
- return; // Max 2 invoices
- }
setCompareSelected((prev) => {
+ if (prev.size >= 2 && !prev.has(id)) {
+ return prev; // Max 2 invoices
+ }
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
@@ -406,24 +407,6 @@ export default function DashboardClient() {
}
};
- const pendingInvoices = invoices.filter((inv) => inv.status === "Pending");
- const selectedInvoices = invoices.filter((inv) => selected.has(inv.id));
- const presetCounts = useMemo(
- () => getDashboardPresetCounts(invoices, publicKey),
- [invoices, publicKey],
- );
- const visibleInvoices = useMemo(
- () =>
- filterDashboardInvoices(
- invoices,
- publicKey,
- activePreset,
- searchValue,
- undefined,
- selectedStatuses,
- ),
- [invoices, publicKey, activePreset, searchValue, selectedStatuses],
- );
const handleScheduleBulkReminders = () => {
if (!reminderDateTime || reminderSelected.size === 0) return;
const results = setBulkReminders(
@@ -439,7 +422,11 @@ export default function DashboardClient() {
// ── Error state ─────────────────────────────────────────────────────────────
if (error) {
- return ;
+ return (
+
+ );
}
// ── Filter / sort controls (shared between sticky bar and drawer) ──────────
@@ -474,13 +461,44 @@ export default function DashboardClient() {
{/* Date range */}
+
+
+
+
+ pushParams({ from: e.target.value })}
+ className="min-h-9 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ />
+
+
+
+ pushParams({ to: e.target.value })}
+ className="min-h-9 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ />
+
+
+
{/* Tag */}
-
@@ -547,7 +572,10 @@ export default function DashboardClient() {
)}
{!multiSelect && !reminderSelect && !compareMode && invoices.length > 0 && (
-
+
-
{/* Summary Stats */}
{!loading && invoices.length > 0 && (
-
-
+
+
Total Active
-
{totalActive}
+
{totalActive}
-
+
Total Value Locked
-
{formatAmount(totalValueLocked)} USDC
+
+ {formatAmount(totalValueLocked)} USDC
+
-
+
Total Released
-
{formatAmount(totalReleased)} USDC
+
+ {formatAmount(totalReleased)} USDC
+
)}
@@ -722,47 +763,24 @@ export default function DashboardClient() {
aria-controls="filter-drawer"
>
- Filters {isFiltered &&
on}
+ Filters{" "}
+ {isFiltered && (
+
on
+ )}
- {DASHBOARD_PRESETS.map((preset) => {
- const isActive = activePreset === preset.id;
- const count = presetCounts[preset.id] ?? 0;
-
- return (
-
handlePresetToggle(preset.id)}
- className={`rounded-full px-3 py-1.5 text-sm font-semibold transition-colors ${
- isActive
- ? "bg-indigo-600 text-white"
- : "bg-gray-800 text-gray-300 hover:bg-gray-700"
- }`}
- aria-pressed={isActive}
- >
- {preset.label}
-
- {count}
-
-
- );
- })}
- {(activePreset !== "all" || searchValue.trim().length > 0 || selectedStatuses.length > 0) && (
-
- {isFiltered && (
-
- Clear filters
-
- )}
{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}}
))}
-
setBulkReminderResults(null)} className="mt-3 text-xs text-gray-500 hover:text-gray-300 transition-colors">Dismiss
+
setBulkReminderResults(null)}
+ className="mt-3 text-xs text-gray-500 hover:text-gray-300 transition-colors"
+ >
+ Dismiss
+
)}
@@ -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" : ""}.
-
Reminder date & time
+
+ Applies to {reminderSelected.size} selected invoice
+ {reminderSelected.size !== 1 ? "s" : ""}.
+
+
+ Reminder date & time
+
- { setShowReminderPicker(false); setReminderDateTime(""); }}
- className="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-medium transition-colors">
+ {
+ setShowReminderPicker(false);
+ setReminderDateTime("");
+ }}
+ className="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
+ >
Cancel
-
+
Schedule
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 (
= {
+ "1h": "1h",
+ "24h": "24h",
+ "7d": "7d",
+};
+
+/** Fill color by volume/threshold ratio: green < 80%, amber < 100%, red beyond. */
+function fillFor(volume: number, threshold: number): { pct: number; color: string } {
+ const ratio = threshold > 0 ? volume / threshold : 0;
+ const pct = Math.min(ratio * 100, 100);
+ const color = ratio > 1 ? "#ef4444" : ratio >= 0.8 ? "#f59e0b" : "#10b981";
+ return { pct, color };
+}
+
+function formatMoney(value: number): string {
+ return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
+}
+
+/**
+ * PaymentVelocityGauge (#408) — SVG gauges for the rolling 1h / 24h / 7d
+ * outgoing payment volume of the connected account, with inline threshold
+ * configuration and an alert banner when a threshold is breached.
+ */
+export default function PaymentVelocityGauge({ account }: Props) {
+ const { velocities, lastUpdated, loading, error, thresholds, setThreshold } =
+ usePaymentVelocity(account);
+ const [alerts, setAlerts] = useState([]);
+ const [settingsOpen, setSettingsOpen] = useState(false);
+
+ useEffect(() => {
+ const onAlert = (e: Event) => {
+ const detail = (e as CustomEvent).detail;
+ if (!detail) return;
+ setAlerts((prev) =>
+ prev.some((a) => a.window === detail.window) ? prev : [...prev, detail],
+ );
+ };
+ globalThis.addEventListener?.("velocity:alert", onAlert);
+ return () => globalThis.removeEventListener?.("velocity:alert", onAlert);
+ }, []);
+
+ if (loading && !velocities) {
+ return (
+
+
Loading velocity data...
+
+ );
+ }
+
+ if (error && !velocities) {
+ return (
+
+
Unable to load velocity data
+
+ );
+ }
+
+ return (
+
+
+
+ Payment Velocity
+
+ setSettingsOpen((v) => !v)}
+ className="min-h-9 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 px-3 text-xs font-semibold transition-colors"
+ aria-expanded={settingsOpen}
+ >
+ ⚙️ Settings
+
+
+
+ {settingsOpen && (
+
+ {VELOCITY_WINDOWS.map((window) => (
+
+ {WINDOW_LABELS[window]} threshold
+ setThreshold(window, Number(e.target.value) || 0)}
+ className="min-h-9 rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 text-sm"
+ />
+
+ ))}
+
+ )}
+
+ {alerts.length > 0 && (
+
+
+
+ Payment Velocity Alert
+
+
setAlerts([])}
+ className="text-xs text-red-500 hover:text-red-400"
+ >
+ Dismiss
+
+
+ {alerts.map((alert) => (
+
+ {alert.window} window: ${formatMoney(alert.volume)} exceeded threshold of $
+ {formatMoney(alert.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)}
Disconnect
{!compact && (
-
+
{balanceLoading
? "Loading USDC…"
: balance !== null
@@ -192,21 +172,12 @@ export default function WalletConnect() {
{connecting ? "Connecting…" : "Connect Wallet"}
{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() {
{connecting ? "Connecting…" : "Connect with Freighter"}
@@ -230,11 +198,8 @@ export default function WalletConnect() {
{connecting ? "Connecting…" : "Connect with 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) => (
+ 0 ? (tier.stroopsPerOp * ops * 100) / maxTotal : 33.33}%`,
+ }}
+ />
+ ))}
+
+
+
+ {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 (
+
+ );
+}
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) {