diff --git a/backend/package-lock.json b/backend/package-lock.json
index f235989..ed415a3 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -48,6 +48,7 @@
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
+ "@prisma/config": "^7.8.0",
"@types/dotenv": "^6.1.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
@@ -2926,15 +2927,15 @@
"license": "Apache-2.0"
},
"node_modules/@prisma/config": {
- "version": "7.4.2",
- "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.4.2.tgz",
- "integrity": "sha512-CftBjWxav99lzY1Z4oDgomdb1gh9BJFAOmWF6P2v1xRfXqQb56DfBub+QKcERRdNoAzCb3HXy3Zii8Vb4AsXhg==",
- "devOptional": true,
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz",
+ "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==",
+ "dev": true,
"license": "Apache-2.0",
"dependencies": {
- "c12": "3.1.0",
+ "c12": "3.3.4",
"deepmerge-ts": "7.1.5",
- "effect": "3.18.4",
+ "effect": "3.20.0",
"empathic": "2.0.0"
}
},
@@ -4104,9 +4105,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4121,9 +4119,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4138,9 +4133,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4155,9 +4147,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4172,9 +4161,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4189,9 +4175,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5121,27 +5104,27 @@
}
},
"node_modules/c12": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
- "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
- "devOptional": true,
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
+ "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
- "chokidar": "^4.0.3",
- "confbox": "^0.2.2",
- "defu": "^6.1.4",
- "dotenv": "^16.6.1",
- "exsolve": "^1.0.7",
- "giget": "^2.0.0",
- "jiti": "^2.4.2",
+ "chokidar": "^5.0.0",
+ "confbox": "^0.2.4",
+ "defu": "^6.1.6",
+ "dotenv": "^17.3.1",
+ "exsolve": "^1.0.8",
+ "giget": "^3.2.0",
+ "jiti": "^2.6.1",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
- "perfect-debounce": "^1.0.0",
- "pkg-types": "^2.2.0",
- "rc9": "^2.1.2"
+ "perfect-debounce": "^2.1.0",
+ "pkg-types": "^2.3.0",
+ "rc9": "^3.0.1"
},
"peerDependencies": {
- "magicast": "^0.3.5"
+ "magicast": "*"
},
"peerDependenciesMeta": {
"magicast": {
@@ -5149,17 +5132,34 @@
}
}
},
- "node_modules/c12/node_modules/dotenv": {
- "version": "16.6.1",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
- "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
- "devOptional": true,
- "license": "BSD-2-Clause",
+ "node_modules/c12/node_modules/chokidar": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
+ "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^5.0.0"
+ },
"engines": {
- "node": ">=12"
+ "node": ">= 20.19.0"
},
"funding": {
- "url": "https://dotenvx.com"
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/c12/node_modules/readdirp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
+ "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
}
},
"node_modules/call-bind": {
@@ -5869,9 +5869,9 @@
}
},
"node_modules/defu": {
- "version": "6.1.4",
- "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
- "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
+ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"devOptional": true,
"license": "MIT"
},
@@ -6016,10 +6016,10 @@
"license": "MIT"
},
"node_modules/effect": {
- "version": "3.18.4",
- "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
- "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
- "devOptional": true,
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
+ "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -7185,19 +7185,11 @@
}
},
"node_modules/giget": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
- "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
- "devOptional": true,
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz",
+ "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==",
+ "dev": true,
"license": "MIT",
- "dependencies": {
- "citty": "^0.1.6",
- "consola": "^3.4.0",
- "defu": "^6.1.4",
- "node-fetch-native": "^1.6.6",
- "nypm": "^0.6.0",
- "pathe": "^2.0.3"
- },
"bin": {
"giget": "dist/cli.mjs"
}
@@ -9431,15 +9423,15 @@
}
},
"node_modules/nypm": {
- "version": "0.6.5",
- "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
- "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==",
+ "version": "0.6.7",
+ "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.7.tgz",
+ "integrity": "sha512-s3ds97SD5pd1dULE+tHUk1DrV0cSHOnsfpcdGATJ8JpBo21DoKqN9exTH4/2nhPQNOLomBdTFMicN94S4DrZrQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
- "citty": "^0.2.0",
+ "citty": "^0.2.2",
"pathe": "^2.0.3",
- "tinyexec": "^1.0.2"
+ "tinyexec": "^1.2.4"
},
"bin": {
"nypm": "dist/cli.mjs"
@@ -9449,9 +9441,9 @@
}
},
"node_modules/nypm/node_modules/citty": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz",
- "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==",
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
+ "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"devOptional": true,
"license": "MIT"
},
@@ -9776,10 +9768,10 @@
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/perfect-debounce": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
- "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
- "devOptional": true,
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
+ "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
+ "dev": true,
"license": "MIT"
},
"node_modules/pg": {
@@ -9971,14 +9963,14 @@
}
},
"node_modules/pkg-types": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
- "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
+ "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
- "confbox": "^0.2.2",
- "exsolve": "^1.0.7",
+ "confbox": "^0.2.4",
+ "exsolve": "^1.0.8",
"pathe": "^2.0.3"
}
},
@@ -10155,6 +10147,108 @@
}
}
},
+ "node_modules/prisma/node_modules/@prisma/config": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.4.2.tgz",
+ "integrity": "sha512-CftBjWxav99lzY1Z4oDgomdb1gh9BJFAOmWF6P2v1xRfXqQb56DfBub+QKcERRdNoAzCb3HXy3Zii8Vb4AsXhg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "c12": "3.1.0",
+ "deepmerge-ts": "7.1.5",
+ "effect": "3.18.4",
+ "empathic": "2.0.0"
+ }
+ },
+ "node_modules/prisma/node_modules/c12": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
+ "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^4.0.3",
+ "confbox": "^0.2.2",
+ "defu": "^6.1.4",
+ "dotenv": "^16.6.1",
+ "exsolve": "^1.0.7",
+ "giget": "^2.0.0",
+ "jiti": "^2.4.2",
+ "ohash": "^2.0.11",
+ "pathe": "^2.0.3",
+ "perfect-debounce": "^1.0.0",
+ "pkg-types": "^2.2.0",
+ "rc9": "^2.1.2"
+ },
+ "peerDependencies": {
+ "magicast": "^0.3.5"
+ },
+ "peerDependenciesMeta": {
+ "magicast": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/prisma/node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "devOptional": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/prisma/node_modules/effect": {
+ "version": "3.18.4",
+ "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
+ "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "fast-check": "^3.23.1"
+ }
+ },
+ "node_modules/prisma/node_modules/giget": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
+ "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.1.6",
+ "consola": "^3.4.0",
+ "defu": "^6.1.4",
+ "node-fetch-native": "^1.6.6",
+ "nypm": "^0.6.0",
+ "pathe": "^2.0.3"
+ },
+ "bin": {
+ "giget": "dist/cli.mjs"
+ }
+ },
+ "node_modules/prisma/node_modules/perfect-debounce": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+ "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/prisma/node_modules/rc9": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
+ "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "defu": "^6.1.4",
+ "destr": "^2.0.3"
+ }
+ },
"node_modules/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
@@ -10269,14 +10363,14 @@
}
},
"node_modules/rc9": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
- "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
- "devOptional": true,
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
+ "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
- "defu": "^6.1.4",
- "destr": "^2.0.3"
+ "defu": "^6.1.6",
+ "destr": "^2.0.5"
}
},
"node_modules/react": {
@@ -11334,9 +11428,9 @@
}
},
"node_modules/tinyexec": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
- "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"devOptional": true,
"license": "MIT",
"engines": {
diff --git a/backend/package.json b/backend/package.json
index 2c92a6b..a1d8f58 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -74,6 +74,7 @@
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
+ "@prisma/config": "^7.8.0",
"@types/dotenv": "^6.1.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
@@ -131,4 +132,4 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
-}
\ No newline at end of file
+}
diff --git a/backend/prisma.config.ts b/backend/prisma.config.ts
index a39462b..516ea52 100644
--- a/backend/prisma.config.ts
+++ b/backend/prisma.config.ts
@@ -1,4 +1,4 @@
-import { defineConfig } from 'prisma/config';
+import { defineConfig } from '@prisma/config';
export default defineConfig({
datasource: {
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index f9378f3..c877f7e 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -14,6 +14,9 @@ model Merchant {
id String @id @default(uuid())
name String
stellarPublicKey String @unique @map("stellar_public_key")
+ businessEmail String? @map("business_email")
+ preferredAsset String? @map("preferred_asset")
+ payoutWallet String? @map("payout_wallet")
webhookUrl String? @map("webhook_url")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts
index 832deac..9f2344b 100644
--- a/backend/src/app.module.ts
+++ b/backend/src/app.module.ts
@@ -21,6 +21,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
import { CustomThrottlerModule } from "./throttler/throttler.module";
import { BackfillModule } from "./backfill/backfill.module";
import { AdminAnalyticsModule } from "./admin-analytics/admin-analytics.module";
+import { MerchantModule } from "./merchant/merchant.module";
/**
* Root application module
@@ -103,6 +104,7 @@ import { AdminAnalyticsModule } from "./admin-analytics/admin-analytics.module";
WebhooksModule,
BackfillModule,
AdminAnalyticsModule,
+ MerchantModule,
],
})
export class AppModule {}
\ No newline at end of file
diff --git a/backend/src/common/validators/is-stellar-public-key.validator.ts b/backend/src/common/validators/is-stellar-public-key.validator.ts
new file mode 100644
index 0000000..658c39e
--- /dev/null
+++ b/backend/src/common/validators/is-stellar-public-key.validator.ts
@@ -0,0 +1,31 @@
+import {
+ registerDecorator,
+ ValidationOptions,
+ ValidatorConstraint,
+ ValidatorConstraintInterface,
+} from 'class-validator';
+import { StrKey } from '@stellar/stellar-sdk';
+
+@ValidatorConstraint({ name: 'isStellarPublicKey', async: false })
+export class IsStellarPublicKeyConstraint implements ValidatorConstraintInterface {
+ validate(publicKey: any) {
+ if (typeof publicKey !== 'string') return false;
+ return StrKey.isValidEd25519PublicKey(publicKey);
+ }
+
+ defaultMessage() {
+ return 'payoutWallet must be a valid Stellar public key (starting with G)';
+ }
+}
+
+export function IsStellarPublicKey(validationOptions?: ValidationOptions) {
+ return function (object: Object, propertyName: string) {
+ registerDecorator({
+ target: object.constructor,
+ propertyName: propertyName,
+ options: validationOptions,
+ constraints: [],
+ validator: IsStellarPublicKeyConstraint,
+ });
+ };
+}
diff --git a/backend/src/merchant/dtos/update-merchant-profile.dto.ts b/backend/src/merchant/dtos/update-merchant-profile.dto.ts
new file mode 100644
index 0000000..596a9a5
--- /dev/null
+++ b/backend/src/merchant/dtos/update-merchant-profile.dto.ts
@@ -0,0 +1,20 @@
+import { IsEmail, IsOptional, IsString } from 'class-validator';
+import { IsStellarPublicKey } from '../../common/validators/is-stellar-public-key.validator';
+
+export class UpdateMerchantProfileDto {
+ @IsString()
+ @IsOptional()
+ name?: string;
+
+ @IsEmail()
+ @IsOptional()
+ businessEmail?: string;
+
+ @IsString()
+ @IsOptional()
+ preferredAsset?: string;
+
+ @IsStellarPublicKey()
+ @IsOptional()
+ payoutWallet?: string;
+}
diff --git a/backend/src/merchant/merchant.controller.ts b/backend/src/merchant/merchant.controller.ts
index 3eee4f6..2521125 100644
--- a/backend/src/merchant/merchant.controller.ts
+++ b/backend/src/merchant/merchant.controller.ts
@@ -5,6 +5,8 @@ import {
Patch,
Delete,
UseGuards,
+ Param,
+ Body,
} from "@nestjs/common";
import { JwtAuthGuard } from "../auth/guard/auth.guard";
@@ -12,10 +14,34 @@ import { MerchantMembershipGuard } from "../common/guards/merchant-membership.gu
import { MerchantRolesGuard } from "../common/guards/merchant-roles.guard";
import { Roles } from "../common/decorators/roles.decorator";
import { MerchantRole } from "../common/enums/merchant-role.enum";
+import { MerchantService } from "./merchant.service";
+import { UpdateMerchantProfileDto } from "./dtos/update-merchant-profile.dto";
@UseGuards(JwtAuthGuard, MerchantMembershipGuard, MerchantRolesGuard)
@Controller("merchants")
export class MerchantController {
+ constructor(private readonly merchantService: MerchantService) {}
+
+ @Get(":merchantId/profile")
+ @Roles(
+ MerchantRole.OWNER,
+ MerchantRole.ADMIN,
+ MerchantRole.OPERATOR,
+ MerchantRole.VIEWER,
+ )
+ getProfile(@Param("merchantId") merchantId: string) {
+ return this.merchantService.getProfile(merchantId);
+ }
+
+ @Patch(":merchantId/profile")
+ @Roles(MerchantRole.OWNER, MerchantRole.ADMIN)
+ updateProfile(
+ @Param("merchantId") merchantId: string,
+ @Body() data: UpdateMerchantProfileDto,
+ ) {
+ return this.merchantService.updateProfile(merchantId, data);
+ }
+
@Get(":merchantId/export")
@Roles(MerchantRole.OWNER, MerchantRole.ADMIN)
exportMerchantData() {
diff --git a/backend/src/merchant/merchant.module.ts b/backend/src/merchant/merchant.module.ts
new file mode 100644
index 0000000..5b377c6
--- /dev/null
+++ b/backend/src/merchant/merchant.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { MerchantController } from './merchant.controller';
+import { MerchantService } from './merchant.service';
+import { PrismaModule } from '../prisma/prisma.module';
+
+@Module({
+ imports: [PrismaModule],
+ controllers: [MerchantController],
+ providers: [MerchantService],
+ exports: [MerchantService],
+})
+export class MerchantModule {}
diff --git a/backend/src/merchant/merchant.service.ts b/backend/src/merchant/merchant.service.ts
index f5d1a58..b61db7a 100644
--- a/backend/src/merchant/merchant.service.ts
+++ b/backend/src/merchant/merchant.service.ts
@@ -1,4 +1,46 @@
-import { Injectable } from "@nestjs/common";
+import { Injectable, NotFoundException } from "@nestjs/common";
+import { PrismaService } from "../prisma/prisma.service";
+import { UpdateMerchantProfileDto } from "./dtos/update-merchant-profile.dto";
@Injectable()
-export class MerchantService {}
+export class MerchantService {
+ constructor(private prisma: PrismaService) {}
+
+ async getProfile(merchantId: string) {
+ const merchant = await this.prisma.merchant.findUnique({
+ where: { id: merchantId },
+ select: {
+ id: true,
+ name: true,
+ stellarPublicKey: true,
+ businessEmail: true,
+ preferredAsset: true,
+ payoutWallet: true,
+ webhookUrl: true,
+ createdAt: true,
+ updatedAt: true,
+ },
+ });
+
+ if (!merchant) {
+ throw new NotFoundException('Merchant not found');
+ }
+
+ return merchant;
+ }
+
+ async updateProfile(merchantId: string, data: UpdateMerchantProfileDto) {
+ const merchant = await this.prisma.merchant.findUnique({
+ where: { id: merchantId },
+ });
+
+ if (!merchant) {
+ throw new NotFoundException('Merchant not found');
+ }
+
+ return this.prisma.merchant.update({
+ where: { id: merchantId },
+ data,
+ });
+ }
+}
diff --git a/legacy/webapp/app/dashboard/layout.tsx b/legacy/webapp/app/dashboard/layout.tsx
new file mode 100644
index 0000000..4f50be1
--- /dev/null
+++ b/legacy/webapp/app/dashboard/layout.tsx
@@ -0,0 +1,13 @@
+import { ReactNode } from "react"
+import { MerchantDashboardShell } from "@/components/merchant-dashboard-shell"
+
+export default function DashboardLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/legacy/webapp/app/invoices/layout.tsx b/legacy/webapp/app/invoices/layout.tsx
new file mode 100644
index 0000000..8bc2958
--- /dev/null
+++ b/legacy/webapp/app/invoices/layout.tsx
@@ -0,0 +1,13 @@
+import { ReactNode } from "react"
+import { MerchantDashboardShell } from "@/components/merchant-dashboard-shell"
+
+export default function InvoicesLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/legacy/webapp/app/settings/layout.tsx b/legacy/webapp/app/settings/layout.tsx
new file mode 100644
index 0000000..2a53a2f
--- /dev/null
+++ b/legacy/webapp/app/settings/layout.tsx
@@ -0,0 +1,13 @@
+import { ReactNode } from "react"
+import { MerchantDashboardShell } from "@/components/merchant-dashboard-shell"
+
+export default function SettingsLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/legacy/webapp/app/settings/page.tsx b/legacy/webapp/app/settings/page.tsx
new file mode 100644
index 0000000..36e4a41
--- /dev/null
+++ b/legacy/webapp/app/settings/page.tsx
@@ -0,0 +1,73 @@
+"use client"
+
+import Link from "next/link"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { useEvmWallet } from "@/hooks/use-evm-wallet"
+import { useAuthStore } from "@/hooks/use-auth-store"
+import { ShieldCheck, LogOut, Wallet, ArrowRight } from "lucide-react"
+
+export default function SettingsPage() {
+ const { address, connected, displayAddress, disconnect } = useEvmWallet()
+ const { user } = useAuthStore()
+
+ return (
+
+
+
+
+
+ Account Settings
+
+
+ Wallet access and dashboard preferences for the deployed legacy app.
+
+
+
+
+
Wallet
+
+ {connected ? displayAddress : "Not connected"}
+
+
+ {address ? "Connected via your EVM wallet" : "Connect a wallet to authenticate"}
+
+
+
+
Session
+
+ {user?.walletAddress || "No active session"}
+
+
+ Session data is stored locally for the legacy frontend.
+
+
+
+
+
+
+
+
+
+ Quick Actions
+
+
+ Jump to common merchant tasks.
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/legacy/webapp/components/merchant-dashboard-shell.tsx b/legacy/webapp/components/merchant-dashboard-shell.tsx
new file mode 100644
index 0000000..1611cad
--- /dev/null
+++ b/legacy/webapp/components/merchant-dashboard-shell.tsx
@@ -0,0 +1,153 @@
+"use client"
+
+import Link from "next/link"
+import { usePathname } from "next/navigation"
+import { ReactNode } from "react"
+import { FileText, PieChart, Settings } from "lucide-react"
+import { ThemeToggle } from "@/components/theme-toggle"
+import { WalletHeader } from "@/components/wallet-header"
+import { cn } from "@/lib/utils"
+import { useEvmWallet } from "@/hooks/use-evm-wallet"
+
+const NAV_ITEMS = [
+ {
+ label: "Dashboard",
+ href: "/dashboard",
+ description: "Overview and wallet health",
+ icon: PieChart,
+ },
+ {
+ label: "Invoices",
+ href: "/invoices",
+ description: "Create and manage invoices",
+ icon: FileText,
+ },
+ {
+ label: "Settings",
+ href: "/settings",
+ description: "Wallet and merchant preferences",
+ icon: Settings,
+ },
+]
+
+function isActive(pathname: string, href: string) {
+ return pathname === href || pathname.startsWith(`${href}/`)
+}
+
+interface MerchantDashboardShellProps {
+ children: ReactNode
+ title: string
+ description: string
+}
+
+export function MerchantDashboardShell({ children, title, description }: MerchantDashboardShellProps) {
+ const pathname = usePathname()
+ const { address, connected, displayAddress } = useEvmWallet()
+
+ return (
+
+
+
+
+
+
+
+
+
+ Merchant Dashboard
+
+
{title}
+
{description}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/legacy/webapp/package-lock.json b/legacy/webapp/package-lock.json
index 53f118e..baa0115 100644
--- a/legacy/webapp/package-lock.json
+++ b/legacy/webapp/package-lock.json
@@ -9456,7 +9456,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9477,7 +9476,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9498,7 +9496,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9519,7 +9516,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9540,7 +9536,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9561,7 +9556,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9582,7 +9576,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9603,7 +9596,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9624,7 +9616,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9645,7 +9636,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
diff --git a/web/app/dashboard/invoices/page.tsx b/web/app/dashboard/invoices/page.tsx
new file mode 100644
index 0000000..e4dea73
--- /dev/null
+++ b/web/app/dashboard/invoices/page.tsx
@@ -0,0 +1,256 @@
+'use client';
+
+import { useQuery } from '@tanstack/react-query';
+import { apiClient } from '@/lib/api-client';
+import { useMerchant } from '@/hooks/use-merchant';
+import { useWalletAuth } from '@/hooks/use-wallet-auth';
+import { Search, Plus, Filter } from 'lucide-react';
+import Link from 'next/link';
+import { useState } from 'react';
+
+interface Invoice {
+ id: string;
+ number: string;
+ clientName: string;
+ clientEmail: string;
+ amount: string;
+ currency: string;
+ status: 'draft' | 'sent' | 'paid' | 'overdue';
+ dueDate: string;
+ createdAt: string;
+}
+
+export default function InvoicesPage() {
+ const { merchantId } = useMerchant();
+ const { isAuthenticated } = useWalletAuth();
+ const [searchTerm, setSearchTerm] = useState('');
+ const [statusFilter, setStatusFilter] = useState('all');
+
+ const { data: invoices = [], isLoading } = useQuery({
+ queryKey: ['invoices', merchantId, searchTerm, statusFilter],
+ queryFn: async () => {
+ if (!merchantId) return [];
+
+ try {
+ const params: { limit: number; q?: string } = { limit: 50 };
+ if (searchTerm) params.q = searchTerm;
+
+ const response = await apiClient.request({
+ method: 'GET',
+ url: '/invoices',
+ params,
+ });
+
+ const rows = Array.isArray(response.data) ? response.data : [];
+ if (statusFilter === 'all') {
+ return rows;
+ }
+
+ return rows.filter((invoice) => invoice.status === statusFilter);
+ } catch (err) {
+ console.error('Failed to fetch invoices:', err);
+ return [];
+ }
+ },
+ enabled: !!merchantId && isAuthenticated,
+ });
+
+ const getStatusColor = (status: string) => {
+ const colors: Record = {
+ draft: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300',
+ sent: 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300',
+ paid: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300',
+ overdue: 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300',
+ };
+ return colors[status] || colors.draft;
+ };
+
+ const formatDate = (date: string) => {
+ return new Date(date).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ };
+
+ // Calculate stats
+ const stats = {
+ total: invoices.length,
+ paid: invoices.filter((inv) => inv.status === 'paid').length,
+ pending: invoices.filter((inv) => inv.status === 'sent' || inv.status === 'overdue').length,
+ draft: invoices.filter((inv) => inv.status === 'draft').length,
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
Invoices
+
+ Manage all your invoices in one place
+
+
+
+
+
Create Invoice
+
+
+
+ {/* Stats Cards */}
+
+
+
Total
+
+ {isLoading ? '...' : stats.total}
+
+
+
+
Paid
+
+ {isLoading ? '...' : stats.paid}
+
+
+
+
Pending
+
+ {isLoading ? '...' : stats.pending}
+
+
+
+
Draft
+
+ {isLoading ? '...' : stats.draft}
+
+
+
+
+ {/* Filters */}
+
+
+ {/* Search */}
+
+
+ setSearchTerm(e.target.value)}
+ className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+ {/* Status Filter */}
+
+
+
+
+
+
+
+ {/* Invoices Table */}
+
+ {isLoading ? (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ ) : invoices.length === 0 ? (
+
+
No invoices found
+
+ Create Your First Invoice
+
+
+ ) : (
+
+
+
+
+ |
+ Invoice
+ |
+
+ Client
+ |
+
+ Amount
+ |
+
+ Due Date
+ |
+
+ Status
+ |
+
+ Action
+ |
+
+
+
+ {invoices.map((invoice) => (
+
+ |
+
+ {invoice.number}
+
+ |
+
+ {invoice.clientName}
+
+ {invoice.clientEmail}
+
+ |
+
+ {invoice.amount} {invoice.currency}
+ |
+
+ {formatDate(invoice.dueDate)}
+ |
+
+
+ {invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
+
+ |
+
+
+ View
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/web/app/dashboard/layout.tsx b/web/app/dashboard/layout.tsx
new file mode 100644
index 0000000..8f5cb9b
--- /dev/null
+++ b/web/app/dashboard/layout.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { ReactNode } from 'react';
+import { DashboardLayout } from '@/components/dashboard-layout';
+import { RequireAuth } from '@/components/require-auth';
+
+export default function DashboardRootLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web/app/dashboard/page.tsx b/web/app/dashboard/page.tsx
new file mode 100644
index 0000000..0a0e0c9
--- /dev/null
+++ b/web/app/dashboard/page.tsx
@@ -0,0 +1,282 @@
+'use client';
+
+import { useMerchant } from '@/hooks/use-merchant';
+import { useQuery } from '@tanstack/react-query';
+import { apiClient } from '@/lib/api-client';
+import { useWalletAuth } from '@/hooks/use-wallet-auth';
+import { TrendingUp, FileText, DollarSign, Clock } from 'lucide-react';
+import Link from 'next/link';
+
+interface Invoice {
+ id: string;
+ number: string;
+ clientName: string;
+ amount: string;
+ currency: string;
+ status: 'draft' | 'sent' | 'paid' | 'overdue';
+ createdAt: string;
+}
+
+export default function DashboardPage() {
+ const { wallet, merchantId } = useMerchant();
+ const { isAuthenticated } = useWalletAuth();
+
+ // Fetch recent invoices
+ const { data: invoices = [], isLoading: invoicesLoading } = useQuery({
+ queryKey: ['invoices', merchantId],
+ queryFn: async () => {
+ if (!merchantId) return [];
+
+ try {
+ const response = await apiClient.request({
+ method: 'GET',
+ url: '/invoices',
+ params: { limit: 5 },
+ });
+
+ return Array.isArray(response.data) ? response.data : [];
+ } catch (err) {
+ console.error('Failed to fetch invoices:', err);
+ return [];
+ }
+ },
+ enabled: !!merchantId && isAuthenticated,
+ });
+
+ // Calculate stats
+ const stats = {
+ totalInvoices: invoices.length,
+ paidInvoices: invoices.filter((inv) => inv.status === 'paid').length,
+ pendingAmount: invoices
+ .filter((inv) => inv.status === 'sent' || inv.status === 'overdue')
+ .reduce((sum, inv) => sum + parseFloat(inv.amount || '0'), 0)
+ .toFixed(2),
+ overdueCount: invoices.filter((inv) => inv.status === 'overdue').length,
+ };
+
+ const getStatusColor = (status: string) => {
+ const colors: Record = {
+ draft: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300',
+ sent: 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300',
+ paid: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300',
+ overdue: 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300',
+ };
+ return colors[status] || colors.draft;
+ };
+
+ const formatDate = (date: string) => {
+ return new Date(date).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ };
+
+ return (
+
+ {/* Welcome Section */}
+
+
+ Welcome back, {wallet?.name || 'Merchant'}!
+
+
+ Manage your invoices and monitor your merchant wallet activity
+
+
+
+ {/* Stats Grid */}
+
+ {/* Total Invoices */}
+
+
+
+
Total Invoices
+
+ {invoicesLoading ? '...' : stats.totalInvoices}
+
+
+
+
+
+
+ {/* Paid Invoices */}
+
+
+
+
Paid Invoices
+
+ {invoicesLoading ? '...' : stats.paidInvoices}
+
+
+
+
+
+
+ {/* Pending Amount */}
+
+
+
+
Pending Amount
+
+ {invoicesLoading ? '...' : `$${stats.pendingAmount}`}
+
+
+
+
+
+
+ {/* Overdue */}
+
+
+
+
Overdue
+
+ {invoicesLoading ? '...' : stats.overdueCount}
+
+
+
+
+
+
+
+ {/* Wallet Balance Section */}
+ {wallet && (
+
+
+ Wallet Information
+
+
+
+
Wallet Address
+
+ {wallet.publicKey}
+
+
+
+
Balance
+
+ {wallet.balance} {wallet.currency}
+
+ {wallet.balanceUSD && (
+
+ ≈ ${wallet.balanceUSD}
+
+ )}
+
+
+
Merchant ID
+
+ {wallet.id}
+
+
+
+
+ )}
+
+ {/* Recent Invoices */}
+
+
+
+ Recent Invoices
+
+
+ View All
+
+
+
+ {invoicesLoading ? (
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+ ) : invoices.length === 0 ? (
+
+
+
No invoices yet
+
+ Create Invoice
+
+
+ ) : (
+
+
+
+
+ |
+ Invoice
+ |
+
+ Client
+ |
+
+ Amount
+ |
+
+ Status
+ |
+
+ Date
+ |
+
+
+
+ {invoices.map((invoice) => (
+
+ |
+
+ {invoice.number}
+
+ |
+
+ {invoice.clientName}
+ |
+
+ {invoice.amount} {invoice.currency}
+ |
+
+
+ {invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
+
+ |
+
+ {formatDate(invoice.createdAt)}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/web/app/dashboard/settings/page.tsx b/web/app/dashboard/settings/page.tsx
new file mode 100644
index 0000000..9581021
--- /dev/null
+++ b/web/app/dashboard/settings/page.tsx
@@ -0,0 +1,197 @@
+'use client';
+
+import { useMerchant } from '@/hooks/use-merchant';
+import { useWalletAuth } from '@/hooks/use-wallet-auth';
+import { useState } from 'react';
+import { Save } from 'lucide-react';
+
+export default function SettingsPage() {
+ const { wallet, isLoading } = useMerchant();
+ const { publicKey, signOut } = useWalletAuth();
+ const [isSaving, setIsSaving] = useState(false);
+ const [formData, setFormData] = useState({
+ merchantName: wallet?.name || '',
+ email: '',
+ webhookUrl: '',
+ });
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const handleSave = async () => {
+ setIsSaving(true);
+ // Simulated save - integrate with actual API
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ setIsSaving(false);
+ alert('Settings saved successfully!');
+ };
+
+ return (
+
+ {/* Header */}
+
+
Settings
+
+ Manage your merchant account and preferences
+
+
+
+ {/* Account Settings */}
+
+
+ Account Settings
+
+
+
+ {/* Merchant Name */}
+
+
+
+
+ The name displayed on your invoices
+
+
+
+ {/* Email */}
+
+
+
+
+ Used for invoice notifications and account recovery
+
+
+
+ {/* Save Button */}
+
+
+
+
+
+
+ {/* Wallet Settings */}
+
+
+ Wallet Information
+
+
+
+
+
+
+
+ {publicKey || 'Not connected'}
+
+
+
+ This is your Stellar public key used for authentication
+
+
+
+ {wallet && (
+
+
+
+
+ {wallet.id}
+
+
+
+ Unique identifier for your merchant account
+
+
+ )}
+
+
+
+ {/* Webhook Settings */}
+
+
+ Webhook Configuration
+
+
+
+
+
+
+
+ Receive payment notifications and invoice updates
+
+
+
+
+
+ Webhook Events
+
+
+ - Invoice created, sent, viewed
+ - Payment received
+ - Invoice reminder sent
+
+
+
+
+
+ {/* Danger Zone */}
+
+
Danger Zone
+
+ These actions cannot be undone.
+
+
+
+
+ );
+}
diff --git a/web/app/globals.css b/web/app/globals.css
index a2dc41e..c768150 100644
--- a/web/app/globals.css
+++ b/web/app/globals.css
@@ -1,8 +1,69 @@
@import "tailwindcss";
+/* ── Design Tokens ─────────────────────────────────────────────── */
:root {
- --background: #ffffff;
- --foreground: #171717;
+ /* Core palette */
+ --color-bg-primary: #0a0e1a;
+ --color-bg-secondary: #0f1422;
+ --color-bg-elevated: #151b2e;
+ --color-bg-surface: #1a2138;
+ --color-bg-hover: #1e2743;
+
+ --color-text-primary: #e8ecf4;
+ --color-text-secondary: #8892a8;
+ --color-text-muted: #5a6478;
+ --color-text-inverse: #0a0e1a;
+
+ --color-border-default: rgba(255, 255, 255, 0.06);
+ --color-border-subtle: rgba(255, 255, 255, 0.04);
+ --color-border-strong: rgba(255, 255, 255, 0.12);
+
+ /* Accent: Stellar-inspired cool blue-violet */
+ --color-accent: #6366f1;
+ --color-accent-hover: #818cf8;
+ --color-accent-muted: rgba(99, 102, 241, 0.15);
+ --color-accent-glow: rgba(99, 102, 241, 0.25);
+
+ /* Semantic */
+ --color-success: #22c55e;
+ --color-success-muted: rgba(34, 197, 94, 0.12);
+ --color-warning: #f59e0b;
+ --color-warning-muted: rgba(245, 158, 11, 0.12);
+ --color-danger: #ef4444;
+ --color-danger-muted: rgba(239, 68, 68, 0.12);
+ --color-info: #3b82f6;
+ --color-info-muted: rgba(59, 130, 246, 0.12);
+
+ /* Glassmorphism */
+ --glass-bg: rgba(15, 20, 34, 0.72);
+ --glass-border: rgba(255, 255, 255, 0.08);
+ --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.32);
+
+ /* Layout */
+ --sidebar-width: 260px;
+ --sidebar-collapsed: 72px;
+ --header-height: 72px;
+
+ /* Radius */
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --radius-xl: 20px;
+ --radius-full: 9999px;
+
+ /* Transitions */
+ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-default: 250ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
+
+ /* Fonts — set by Next.js */
+ --font-sans: var(--font-geist-sans);
+ --font-mono: var(--font-geist-mono);
+
+ /* For Tailwind theming */
+ --background: var(--color-bg-primary);
+ --foreground: var(--color-text-primary);
}
@theme inline {
@@ -12,15 +73,130 @@
--font-mono: var(--font-geist-mono);
}
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
+/* ── Base Reset ────────────────────────────────────────────────── */
+* {
+ box-sizing: border-box;
+}
+
+html {
+ min-height: 100%;
}
body {
- background: var(--background);
- color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+ min-height: 100vh;
+ margin: 0;
+ background: var(--color-bg-primary);
+ color: var(--color-text-primary);
+ font-family: var(--font-sans), system-ui, -apple-system, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* ── Scrollbar ─────────────────────────────────────────────────── */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--color-text-muted);
+ border-radius: var(--radius-full);
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-text-secondary);
+}
+
+/* ── Keyframe Animations ──────────────────────────────────────── */
+@keyframes slideInLeft {
+ from { transform: translateX(-100%); }
+ to { transform: translateX(0); }
+}
+
+@keyframes slideOutLeft {
+ from { transform: translateX(0); }
+ to { transform: translateX(-100%); }
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes fadeOut {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+
+@keyframes shimmer {
+ 0% { background-position: -200% 0; }
+ 100% { background-position: 200% 0; }
+}
+
+@keyframes pulseGlow {
+ 0%, 100% { box-shadow: 0 0 0 0 var(--color-accent-glow); }
+ 50% { box-shadow: 0 0 0 6px transparent; }
+}
+
+@keyframes float {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-4px); }
+}
+
+@keyframes scaleIn {
+ from { opacity: 0; transform: scale(0.95); }
+ to { opacity: 1; transform: scale(1); }
+}
+
+/* ── Utility Classes ──────────────────────────────────────────── */
+.animate-slide-in-left {
+ animation: slideInLeft var(--transition-slow) forwards;
+}
+
+.animate-fade-in {
+ animation: fadeIn var(--transition-default) forwards;
+}
+
+.animate-scale-in {
+ animation: scaleIn var(--transition-default) forwards;
+}
+
+.animate-shimmer {
+ background: linear-gradient(
+ 90deg,
+ var(--color-bg-surface) 25%,
+ var(--color-bg-hover) 50%,
+ var(--color-bg-surface) 75%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.5s ease-in-out infinite;
+}
+
+.animate-pulse-glow {
+ animation: pulseGlow 2s ease-in-out infinite;
+}
+
+/* Glass panel */
+.glass-panel {
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+ box-shadow: var(--glass-shadow);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+}
+
+/* Sidebar overlay for mobile */
+.sidebar-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 39;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+}
+
+/* Smooth page transitions */
+.page-enter {
+ animation: fadeIn var(--transition-default) forwards, scaleIn var(--transition-default) forwards;
}
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
index 6ce4e25..d52eed5 100644
--- a/web/app/layout.tsx
+++ b/web/app/layout.tsx
@@ -14,8 +14,12 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "Invoisio — Merchant Dashboard",
+ description:
+ "Privacy-focused AI invoice platform on the Stellar network. Manage invoices, track payments, and monitor your merchant wallet.",
+ other: {
+ "theme-color": "#0a0e1a",
+ },
};
export default function RootLayout({
@@ -24,8 +28,9 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+
{children}
diff --git a/web/components/dashboard-layout.tsx b/web/components/dashboard-layout.tsx
new file mode 100644
index 0000000..de0df89
--- /dev/null
+++ b/web/components/dashboard-layout.tsx
@@ -0,0 +1,59 @@
+'use client';
+
+import { ReactNode } from 'react';
+import { Sidebar } from '@/components/sidebar';
+import { Header } from '@/components/header';
+import { SidebarProvider, useSidebar } from '@/components/sidebar-context';
+
+function DashboardShell({ children }: { children: ReactNode }) {
+ const { isCollapsed } = useSidebar();
+
+ return (
+
+
+
+
+ {/* Set the CSS variable for content offset based on sidebar state */}
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+export function DashboardLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web/components/dashboard-nav.ts b/web/components/dashboard-nav.ts
new file mode 100644
index 0000000..d601119
--- /dev/null
+++ b/web/components/dashboard-nav.ts
@@ -0,0 +1,42 @@
+import type { LucideIcon } from 'lucide-react';
+import { LayoutDashboard, ReceiptText, Settings } from 'lucide-react';
+
+export interface DashboardNavItem {
+ label: string;
+ shortLabel: string;
+ href: string;
+ description: string;
+ icon: LucideIcon;
+ badge?: string | number;
+}
+
+export const DASHBOARD_NAV_ITEMS: DashboardNavItem[] = [
+ {
+ label: 'Dashboard',
+ shortLabel: 'Home',
+ href: '/dashboard',
+ description: 'Overview and wallet health',
+ icon: LayoutDashboard,
+ },
+ {
+ label: 'Invoices',
+ shortLabel: 'Invoices',
+ href: '/dashboard/invoices',
+ description: 'Create and manage invoices',
+ icon: ReceiptText,
+ },
+ {
+ label: 'Settings',
+ shortLabel: 'Settings',
+ href: '/dashboard/settings',
+ description: 'Wallet and merchant preferences',
+ icon: Settings,
+ },
+];
+
+export function isDashboardNavActive(pathname: string, href: string): boolean {
+ if (href === '/dashboard') {
+ return pathname === '/dashboard';
+ }
+ return pathname === href || pathname.startsWith(`${href}/`);
+}
\ No newline at end of file
diff --git a/web/components/header.tsx b/web/components/header.tsx
new file mode 100644
index 0000000..cae7fe0
--- /dev/null
+++ b/web/components/header.tsx
@@ -0,0 +1,340 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import { useMerchant } from '@/hooks/use-merchant';
+import { useWalletAuth } from '@/hooks/use-wallet-auth';
+import { useSidebar } from '@/components/sidebar-context';
+import {
+ DASHBOARD_NAV_ITEMS,
+ isDashboardNavActive,
+} from '@/components/dashboard-nav';
+import { LogOut, Wallet, Menu, Globe, ChevronRight } from 'lucide-react';
+
+export function Header() {
+ const { wallet, isLoading } = useMerchant();
+ const { publicKey, status, signOut } = useWalletAuth();
+ const { isCollapsed, openMobile } = useSidebar();
+ const pathname = usePathname();
+
+ const formatAddress = (addr: string) => {
+ if (!addr) return '—';
+ if (addr.length <= 12) return addr;
+ return `${addr.substring(0, 6)}…${addr.substring(addr.length - 4)}`;
+ };
+
+ // Derive page title from pathname
+ const getPageTitle = () => {
+ const segments = pathname.split('/').filter(Boolean);
+ if (segments.length <= 1) return 'Dashboard';
+ const last = segments[segments.length - 1];
+ return last.charAt(0).toUpperCase() + last.slice(1);
+ };
+
+ // Derive breadcrumb segments
+ const getBreadcrumbs = () => {
+ const segments = pathname.split('/').filter(Boolean);
+ return segments.map((seg, i) => ({
+ label: seg.charAt(0).toUpperCase() + seg.slice(1),
+ href: '/' + segments.slice(0, i + 1).join('/'),
+ isLast: i === segments.length - 1,
+ }));
+ };
+
+ const merchantWallet = wallet?.publicKey || publicKey || '';
+ const isAuthenticated = status === 'signed-in';
+
+ return (
+
+ );
+}
diff --git a/web/components/require-auth.tsx b/web/components/require-auth.tsx
index 072b9c9..8a87df8 100644
--- a/web/components/require-auth.tsx
+++ b/web/components/require-auth.tsx
@@ -21,8 +21,47 @@ export function RequireAuth({ children, redirectTo = '/login' }: RequireAuthProp
if (isLoading) {
return (
-
-
+
+
+ {/* Branded shimmer skeleton mimicking sidebar + header */}
+
+ {/* Fake header bar */}
+
+
+
+ {/* Fake content cards */}
+
+
+
+
+
+ Verifying wallet session…
+
+
);
}
diff --git a/web/components/sidebar-context.tsx b/web/components/sidebar-context.tsx
new file mode 100644
index 0000000..6c1e0c3
--- /dev/null
+++ b/web/components/sidebar-context.tsx
@@ -0,0 +1,98 @@
+'use client';
+
+import {
+ createContext,
+ ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+
+const SIDEBAR_STORAGE_KEY = 'invoisio:sidebar-collapsed';
+
+interface SidebarContextValue {
+ isCollapsed: boolean;
+ isMobileOpen: boolean;
+ toggleCollapsed: () => void;
+ openMobile: () => void;
+ closeMobile: () => void;
+}
+
+const SidebarContext = createContext
(null);
+
+export function SidebarProvider({ children }: { children: ReactNode }) {
+ const [isCollapsed, setIsCollapsed] = useState(false);
+ const [isMobileOpen, setIsMobileOpen] = useState(false);
+
+ // Restore collapsed preference from localStorage
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ const stored = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
+ if (stored === 'true') {
+ setIsCollapsed(true);
+ }
+ }, []);
+
+ const toggleCollapsed = useCallback(() => {
+ setIsCollapsed((prev) => {
+ const next = !prev;
+ if (typeof window !== 'undefined') {
+ window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(next));
+ }
+ return next;
+ });
+ }, []);
+
+ const openMobile = useCallback(() => {
+ setIsMobileOpen(true);
+ }, []);
+
+ const closeMobile = useCallback(() => {
+ setIsMobileOpen(false);
+ }, []);
+
+ // Close mobile drawer on Escape key
+ useEffect(() => {
+ if (!isMobileOpen) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ setIsMobileOpen(false);
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ // Prevent body scroll when mobile drawer is open
+ document.body.style.overflow = 'hidden';
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ document.body.style.overflow = '';
+ };
+ }, [isMobileOpen]);
+
+ const value = useMemo(
+ () => ({
+ isCollapsed,
+ isMobileOpen,
+ toggleCollapsed,
+ openMobile,
+ closeMobile,
+ }),
+ [isCollapsed, isMobileOpen, toggleCollapsed, openMobile, closeMobile],
+ );
+
+ return (
+ {children}
+ );
+}
+
+export function useSidebar(): SidebarContextValue {
+ const context = useContext(SidebarContext);
+ if (context == null) {
+ throw new Error('useSidebar must be used within SidebarProvider');
+ }
+ return context;
+}
diff --git a/web/components/sidebar.tsx b/web/components/sidebar.tsx
new file mode 100644
index 0000000..83cf2a3
--- /dev/null
+++ b/web/components/sidebar.tsx
@@ -0,0 +1,231 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import { LogOut, PanelLeftClose, PanelLeft, Zap } from 'lucide-react';
+import { useWalletAuth } from '@/hooks/use-wallet-auth';
+import { useSidebar } from '@/components/sidebar-context';
+import {
+ DASHBOARD_NAV_ITEMS,
+ isDashboardNavActive,
+} from '@/components/dashboard-nav';
+
+export function Sidebar() {
+ const pathname = usePathname();
+ const { signOut } = useWalletAuth();
+ const { isCollapsed, isMobileOpen, toggleCollapsed, closeMobile } =
+ useSidebar();
+
+ const sidebarContent = (
+
+ {/* ── Brand Header ───────────────────────────────── */}
+
+
+
+
+ {!isCollapsed && (
+
+
+ Invoisio
+
+
+ Merchant Workspace
+
+
+ )}
+
+
+ {/* ── Navigation ─────────────────────────────────── */}
+
+
+ {/* ── Footer ─────────────────────────────────────── */}
+
+ {/* Collapse toggle — desktop only */}
+
+
+ {/* Sign out */}
+
+
+
+ );
+
+ return (
+ <>
+ {/* ── Desktop Sidebar ────────────────────────────── */}
+
+
+ {/* ── Mobile Drawer ──────────────────────────────── */}
+ {isMobileOpen && (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Drawer */}
+
+ >
+ )}
+ >
+ );
+}
diff --git a/web/hooks/use-merchant.ts b/web/hooks/use-merchant.ts
new file mode 100644
index 0000000..10f5fc6
--- /dev/null
+++ b/web/hooks/use-merchant.ts
@@ -0,0 +1,68 @@
+'use client';
+
+import { useQuery } from '@tanstack/react-query';
+import { apiClient } from '@/lib/api-client';
+import { useWalletAuth } from './use-wallet-auth';
+
+export interface MerchantWallet {
+ id: string;
+ name: string;
+ publicKey: string;
+ balance: string;
+ balanceUSD: string;
+ currency: string;
+ avatar?: string;
+}
+
+export interface MerchantContext {
+ merchantId: string;
+ wallet: MerchantWallet | null;
+}
+
+export function useMerchant() {
+ const { publicKey, isAuthenticated } = useWalletAuth();
+
+ const { data: wallet, isLoading, error } = useQuery({
+ queryKey: ['merchant-wallet', publicKey],
+ queryFn: async () => {
+ if (!publicKey) return null;
+
+ try {
+ const userResponse = await apiClient.request<{
+ id: string;
+ merchantId?: string;
+ publicKey: string;
+ createdAt: string;
+ }>({
+ method: 'GET',
+ url: '/auth/me',
+ });
+
+ const merchantId = userResponse.data?.merchantId || userResponse.data?.id;
+ const resolvedPublicKey = userResponse.data?.publicKey || publicKey;
+ const alias = resolvedPublicKey ? `Merchant ${resolvedPublicKey.slice(0, 6)}` : 'My Merchant';
+
+ return {
+ id: merchantId,
+ name: alias,
+ publicKey: resolvedPublicKey,
+ balance: '0',
+ balanceUSD: '0',
+ currency: 'XLM',
+ } as MerchantWallet;
+ } catch (err) {
+ console.error('Failed to fetch merchant wallet:', err);
+ return null;
+ }
+ },
+ enabled: isAuthenticated && !!publicKey,
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ });
+
+ return {
+ wallet,
+ isLoading,
+ error,
+ merchantId: wallet?.id,
+ };
+}