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 + +
+ ) : ( +
+ + + + + + + + + + + + + {invoices.map((invoice) => ( + + + + + + + + + ))} + +
+ Invoice + + Client + + Amount + + Due Date + + Status + + Action +
+ + {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 + +
+ ) : ( +
+ + + + + + + + + + + + {invoices.map((invoice) => ( + + + + + + + + ))} + +
+ Invoice + + Client + + Amount + + Status + + Date +
+ + {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 ( +
+
+ {/* ── Left: Mobile hamburger + Breadcrumb ─────── */} +
+ {/* Mobile menu button */} + + +
+ {/* Breadcrumb */} +
+ {getBreadcrumbs().map((crumb) => ( + + {crumb.isLast ? ( + + {crumb.label} + + ) : ( + <> + + {crumb.label} + + + + )} + + ))} +
+ + {/* Page title */} +

+ {getPageTitle()} +

+
+
+ + {/* ── Right: Status + Wallet + Actions ────────── */} +
+ {/* Network badge — hidden on mobile */} +
+ + Testnet +
+ + {/* Auth status pill */} +
+ + {isAuthenticated ? 'Authenticated' : 'Connecting'} +
+ + {/* Wallet balance card */} +
+
+ +
+
+

+ Balance +

+

+ {isLoading + ? '...' + : `${wallet?.balance || '0'} ${wallet?.currency || 'XLM'}`} +

+
+ {wallet?.balanceUSD && ( + + ≈ ${wallet.balanceUSD} + + )} +
+ + {/* Wallet address chip */} +
+
+ + {merchantWallet ? formatAddress(merchantWallet) : 'Not connected'} + +
+ + {/* Sign out button */} + +
+
+ + {/* ── Mobile bottom nav bar ──────────────────────── */} + + + {/* ── Mobile wallet info row ─────────────────────── */} +
+
+

+ Wallet +

+

+ {formatAddress(merchantWallet)} +

+
+
+

+ Balance +

+

+ {isLoading + ? '...' + : `${wallet?.balance || '0'} ${wallet?.currency || 'XLM'}`} +

+
+
+
+ ); +} 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 */} +