Compare commits
16 Commits
b2f92e260c
...
8168a4dd02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8168a4dd02 | ||
|
|
b018d96735 | ||
|
|
0dc56ff1eb | ||
|
|
f7427825d1 | ||
|
|
c312e7901e | ||
|
|
64de600292 | ||
|
|
c5f823f8b7 | ||
|
|
795e635df3 | ||
|
|
6cdbf29bf7 | ||
|
|
25fe78ccfc | ||
|
|
bce77edd76 | ||
|
|
8877265526 | ||
|
|
7234343e20 | ||
|
|
19b96517d2 | ||
|
|
71436cb9c4 | ||
|
|
96aa140207 |
614
apps/backend/package-lock.json
generated
614
apps/backend/package-lock.json
generated
@ -60,6 +60,7 @@
|
|||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"stripe": "^14.14.0",
|
"stripe": "^14.14.0",
|
||||||
"typeorm": "^0.3.17",
|
"typeorm": "^0.3.17",
|
||||||
@ -1444,6 +1445,16 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@epic-web/invariant": {
|
"node_modules/@epic-web/invariant": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||||
@ -1696,6 +1707,554 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@img/sharp-wasm32": "0.35.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.11.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@img/sharp-wasm32": "0.35.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ioredis/as-callback": {
|
"node_modules/@ioredis/as-callback": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ioredis/as-callback/-/as-callback-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ioredis/as-callback/-/as-callback-3.0.0.tgz",
|
||||||
@ -12858,9 +13417,9 @@
|
|||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.2",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@ -12972,6 +13531,55 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.1.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.8.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.35.3",
|
||||||
|
"@img/sharp-darwin-x64": "0.35.3",
|
||||||
|
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||||
|
"@img/sharp-linux-arm": "0.35.3",
|
||||||
|
"@img/sharp-linux-arm64": "0.35.3",
|
||||||
|
"@img/sharp-linux-ppc64": "0.35.3",
|
||||||
|
"@img/sharp-linux-riscv64": "0.35.3",
|
||||||
|
"@img/sharp-linux-s390x": "0.35.3",
|
||||||
|
"@img/sharp-linux-x64": "0.35.3",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||||
|
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||||
|
"@img/sharp-win32-arm64": "0.35.3",
|
||||||
|
"@img/sharp-win32-ia32": "0.35.3",
|
||||||
|
"@img/sharp-win32-x64": "0.35.3"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@ -76,6 +76,7 @@
|
|||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"stripe": "^14.14.0",
|
"stripe": "^14.14.0",
|
||||||
"typeorm": "^0.3.17",
|
"typeorm": "^0.3.17",
|
||||||
|
|||||||
@ -70,6 +70,12 @@ export class AuthService {
|
|||||||
organizationData?: RegisterOrganizationDto,
|
organizationData?: RegisterOrganizationDto,
|
||||||
invitationRole?: string
|
invitationRole?: string
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
||||||
|
// Emails are stored lowercase (enforced by the chk_users_email DB CHECK
|
||||||
|
// constraint), so normalize before any lookup or insert. Without this, an
|
||||||
|
// address containing uppercase letters fails the user INSERT after the
|
||||||
|
// organization has already been created — leaving an orphaned organization.
|
||||||
|
email = email.trim().toLowerCase();
|
||||||
|
|
||||||
this.logger.log(`Registering new user: ${email}`);
|
this.logger.log(`Registering new user: ${email}`);
|
||||||
|
|
||||||
const existingUser = await this.userRepository.findByEmail(email);
|
const existingUser = await this.userRepository.findByEmail(email);
|
||||||
@ -90,6 +96,9 @@ export class AuthService {
|
|||||||
// 2. If organizationData is provided (new user), create a new organization
|
// 2. If organizationData is provided (new user), create a new organization
|
||||||
// 3. Otherwise, use default organization
|
// 3. Otherwise, use default organization
|
||||||
const finalOrganizationId = await this.resolveOrganizationId(organizationId, organizationData);
|
const finalOrganizationId = await this.resolveOrganizationId(organizationId, organizationData);
|
||||||
|
// Track whether a brand-new organization was created for this registration,
|
||||||
|
// so it can be rolled back if the subsequent user creation fails.
|
||||||
|
const createdNewOrg = !organizationId && !!organizationData;
|
||||||
|
|
||||||
// Determine role:
|
// Determine role:
|
||||||
// - If invitation role is provided (invited user), use it
|
// - If invitation role is provided (invited user), use it
|
||||||
@ -121,7 +130,28 @@ export class AuthService {
|
|||||||
role: userRole,
|
role: userRole,
|
||||||
});
|
});
|
||||||
|
|
||||||
const savedUser = await this.userRepository.save(user);
|
let savedUser: User;
|
||||||
|
try {
|
||||||
|
savedUser = await this.userRepository.save(user);
|
||||||
|
} catch (err) {
|
||||||
|
// The organization is created before the user (non-atomic flow). If the
|
||||||
|
// user INSERT fails, roll the organization back so a retry isn't blocked
|
||||||
|
// by an orphaned organization ("name already exists").
|
||||||
|
if (createdNewOrg) {
|
||||||
|
try {
|
||||||
|
await this.organizationRepository.deleteById(finalOrganizationId);
|
||||||
|
this.logger.warn(
|
||||||
|
`Rolled back orphaned organization ${finalOrganizationId} after failed user creation`
|
||||||
|
);
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to roll back organization ${finalOrganizationId}`,
|
||||||
|
cleanupErr as Error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
// Allocate a license for the new user
|
// Allocate a license for the new user
|
||||||
try {
|
try {
|
||||||
@ -158,6 +188,7 @@ export class AuthService {
|
|||||||
password: string,
|
password: string,
|
||||||
rememberMe = false
|
rememberMe = false
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
||||||
|
email = email.trim().toLowerCase();
|
||||||
this.logger.log(`Login attempt for: ${email}`);
|
this.logger.log(`Login attempt for: ${email}`);
|
||||||
|
|
||||||
const user = await this.userRepository.findByEmail(email);
|
const user = await this.userRepository.findByEmail(email);
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
@ -756,6 +757,12 @@ export class AdminController {
|
|||||||
routeDescription: booking.getRouteDescription(),
|
routeDescription: booking.getRouteDescription(),
|
||||||
isExpired: booking.isExpired(),
|
isExpired: booking.isExpired(),
|
||||||
price: booking.getPriceInCurrency(primaryCurrency),
|
price: booking.getPriceInCurrency(primaryCurrency),
|
||||||
|
commissionRate: booking.commissionRate,
|
||||||
|
commissionAmountEur: booking.commissionAmountEur,
|
||||||
|
freightTotal: booking.freightTotal,
|
||||||
|
freightCurrency: booking.freightCurrency,
|
||||||
|
fobTotal: booking.fobTotal,
|
||||||
|
fobCurrency: booking.fobCurrency,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -995,17 +1002,79 @@ export class AdminController {
|
|||||||
return { url: `/api/v1/blog/images/${filename}`, filename };
|
return { url: `/api/v1/blog/images/${filename}`, filename };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('blog/cover-images')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('image', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: MAX_IMAGE_SIZE },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
// SVG cannot be raster-cropped; restrict to raster formats.
|
||||||
|
if (['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new BadRequestException('Only JPG, PNG, WebP or GIF images are allowed'), false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Upload and auto-crop a blog cover image (Admin only)',
|
||||||
|
description:
|
||||||
|
'Resizes and crops the image to the public 16:9 card ratio (1280x720) so covers are never distorted or cut off on the public site.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
schema: { properties: { url: { type: 'string' }, filename: { type: 'string' } } },
|
||||||
|
})
|
||||||
|
async uploadBlogCoverImage(
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: UserPayload
|
||||||
|
): Promise<{ url: string; filename: string }> {
|
||||||
|
if (!file) throw new BadRequestException('No image file provided');
|
||||||
|
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Uploading blog cover image: ${file.originalname}`);
|
||||||
|
|
||||||
|
// Crop to the public blog card ratio (16:9). "attention" focuses on the most
|
||||||
|
// salient region so important content is preserved.
|
||||||
|
let processed: Buffer;
|
||||||
|
try {
|
||||||
|
processed = await sharp(file.buffer)
|
||||||
|
.resize(1280, 720, { fit: 'cover', position: sharp.strategy.attention })
|
||||||
|
.webp({ quality: 82 })
|
||||||
|
.toBuffer();
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Failed to process cover image: ${err?.message}`);
|
||||||
|
throw new BadRequestException("Impossible de traiter l'image");
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `${uuidv4()}-cover.webp`;
|
||||||
|
const key = `blog-images/${filename}`;
|
||||||
|
|
||||||
|
await this.storage.upload({
|
||||||
|
bucket: BLOG_IMAGES_BUCKET,
|
||||||
|
key,
|
||||||
|
body: processed,
|
||||||
|
contentType: 'image/webp',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`[ADMIN] Blog cover image uploaded: ${key}`);
|
||||||
|
return { url: `/api/v1/blog/images/${filename}`, filename };
|
||||||
|
}
|
||||||
|
|
||||||
@Get('blog')
|
@Get('blog')
|
||||||
@ApiOperation({ summary: 'List all blog posts (Admin only)' })
|
@ApiOperation({ summary: 'List all blog posts (Admin only)' })
|
||||||
@ApiQuery({ name: 'status', required: false })
|
@ApiQuery({ name: 'status', required: false })
|
||||||
@ApiQuery({ name: 'category', required: false })
|
@ApiQuery({ name: 'category', required: false })
|
||||||
@ApiQuery({ name: 'search', required: false })
|
@ApiQuery({ name: 'search', required: false })
|
||||||
|
@ApiQuery({ name: 'trashed', required: false, type: Boolean })
|
||||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
@ApiQuery({ name: 'offset', required: false, type: Number })
|
@ApiQuery({ name: 'offset', required: false, type: Number })
|
||||||
async listBlogPosts(
|
async listBlogPosts(
|
||||||
@Query('status') status?: any,
|
@Query('status') status?: any,
|
||||||
@Query('category') category?: BlogPostCategory,
|
@Query('category') category?: BlogPostCategory,
|
||||||
@Query('search') search?: string,
|
@Query('search') search?: string,
|
||||||
|
@Query('trashed') trashed?: string,
|
||||||
@Query('limit') limit = 50,
|
@Query('limit') limit = 50,
|
||||||
@Query('offset') offset = 0,
|
@Query('offset') offset = 0,
|
||||||
@CurrentUser() user?: UserPayload
|
@CurrentUser() user?: UserPayload
|
||||||
@ -1015,6 +1084,7 @@ export class AdminController {
|
|||||||
status,
|
status,
|
||||||
category,
|
category,
|
||||||
search,
|
search,
|
||||||
|
trashed: trashed === 'true',
|
||||||
limit: Number(limit),
|
limit: Number(limit),
|
||||||
offset: Number(offset),
|
offset: Number(offset),
|
||||||
});
|
});
|
||||||
@ -1045,15 +1115,45 @@ export class AdminController {
|
|||||||
|
|
||||||
@Delete('blog/:id')
|
@Delete('blog/:id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Delete a blog post (Admin only)' })
|
@ApiOperation({ summary: 'Move a blog post to the trash (Admin only)' })
|
||||||
async deleteBlogPost(
|
async deleteBlogPost(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@CurrentUser() user: UserPayload
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Deleting blog post: ${id}`);
|
this.logger.log(`[ADMIN: ${user.email}] Trashing blog post: ${id}`);
|
||||||
await this.blogService.deletePost(id);
|
await this.blogService.deletePost(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('blog/:id/restore')
|
||||||
|
@ApiOperation({ summary: 'Restore a blog post from the trash (Admin only)' })
|
||||||
|
async restoreBlogPost(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Restoring blog post: ${id}`);
|
||||||
|
const post = await this.blogService.restorePost(id);
|
||||||
|
return this.mapBlogPostToDto(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('blog/:id/permanent')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: 'Permanently delete a blog post (Admin only)' })
|
||||||
|
async permanentlyDeleteBlogPost(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: UserPayload
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Permanently deleting blog post: ${id}`);
|
||||||
|
await this.blogService.permanentlyDeletePost(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('blog/:id/duplicate')
|
||||||
|
@ApiOperation({ summary: 'Duplicate a blog post as a new draft (Admin only)' })
|
||||||
|
async duplicateBlogPost(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: UserPayload
|
||||||
|
) {
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Duplicating blog post: ${id}`);
|
||||||
|
const post = await this.blogService.duplicatePost(id);
|
||||||
|
return this.mapBlogPostToDto(post);
|
||||||
|
}
|
||||||
|
|
||||||
private mapBlogPostToDto(post: BlogPost) {
|
private mapBlogPostToDto(post: BlogPost) {
|
||||||
return {
|
return {
|
||||||
id: post.id,
|
id: post.id,
|
||||||
@ -1072,6 +1172,10 @@ export class AdminController {
|
|||||||
metaDescription: post.metaDescription,
|
metaDescription: post.metaDescription,
|
||||||
primaryKeyword: post.primaryKeyword,
|
primaryKeyword: post.primaryKeyword,
|
||||||
secondaryKeywords: post.secondaryKeywords,
|
secondaryKeywords: post.secondaryKeywords,
|
||||||
|
aiSummary: post.aiSummary,
|
||||||
|
faq: post.faq,
|
||||||
|
keyTakeaways: post.keyTakeaways,
|
||||||
|
aiEntities: post.aiEntities,
|
||||||
createdAt: post.createdAt,
|
createdAt: post.createdAt,
|
||||||
updatedAt: post.updatedAt,
|
updatedAt: post.updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -117,7 +117,7 @@ export class BlogController {
|
|||||||
slug: post.slug,
|
slug: post.slug,
|
||||||
excerpt: post.excerpt,
|
excerpt: post.excerpt,
|
||||||
content: post.content,
|
content: post.content,
|
||||||
coverImageUrl: post.coverImageUrl,
|
coverImageUrl: post.coverImageUrl ?? undefined,
|
||||||
category: post.category,
|
category: post.category,
|
||||||
tags: post.tags,
|
tags: post.tags,
|
||||||
authorName: post.authorName,
|
authorName: post.authorName,
|
||||||
@ -128,6 +128,10 @@ export class BlogController {
|
|||||||
metaDescription: post.metaDescription,
|
metaDescription: post.metaDescription,
|
||||||
primaryKeyword: post.primaryKeyword,
|
primaryKeyword: post.primaryKeyword,
|
||||||
secondaryKeywords: post.secondaryKeywords,
|
secondaryKeywords: post.secondaryKeywords,
|
||||||
|
aiSummary: post.aiSummary ?? undefined,
|
||||||
|
faq: post.faq,
|
||||||
|
keyTakeaways: post.keyTakeaways,
|
||||||
|
aiEntities: post.aiEntities,
|
||||||
createdAt: post.createdAt,
|
createdAt: post.createdAt,
|
||||||
updatedAt: post.updatedAt,
|
updatedAt: post.updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common';
|
import { Controller, Get, Post, Param, Query, Body, Res, StreamableFile } from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
import { CsvBookingService } from '../services/csv-booking.service';
|
import { CsvBookingService } from '../services/csv-booking.service';
|
||||||
@ -173,4 +174,75 @@ export class CsvBookingActionsController {
|
|||||||
async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> {
|
async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> {
|
||||||
return this.csvBookingService.getDocumentsForCarrier(token);
|
return this.csvBookingService.getDocumentsForCarrier(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a single booking document, streamed through the API (PUBLIC - token-based).
|
||||||
|
*
|
||||||
|
* Password-protected bookings must use POST with the password in the body.
|
||||||
|
*
|
||||||
|
* POST /api/v1/csv-booking-actions/documents/:token/:documentId/download
|
||||||
|
*/
|
||||||
|
@Public()
|
||||||
|
@Post('documents/:token/:documentId/download')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Download a booking document with password (public)',
|
||||||
|
description:
|
||||||
|
'Streams a single booking document to the carrier. Applies the same access rules as the documents list (booking accepted + password when protected).',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||||||
|
@ApiParam({ name: 'documentId', description: 'Document ID' })
|
||||||
|
@ApiBody({ type: VerifyDocumentAccessDto })
|
||||||
|
@ApiResponse({ status: 200, description: 'Document streamed successfully.' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Invalid or missing password' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Booking or document not found' })
|
||||||
|
async downloadBookingDocumentWithPassword(
|
||||||
|
@Param('token') token: string,
|
||||||
|
@Param('documentId') documentId: string,
|
||||||
|
@Body() dto: VerifyDocumentAccessDto,
|
||||||
|
@Res({ passthrough: true }) res: Response
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
return this.streamDocument(token, documentId, dto?.password, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a single booking document (PUBLIC - token-based) - Legacy without password.
|
||||||
|
*
|
||||||
|
* GET /api/v1/csv-booking-actions/documents/:token/:documentId/download
|
||||||
|
*/
|
||||||
|
@Public()
|
||||||
|
@Get('documents/:token/:documentId/download')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Download a booking document (public) - Legacy',
|
||||||
|
description:
|
||||||
|
'Streams a single booking document for bookings without password protection. Protected bookings must use the POST variant.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||||||
|
@ApiParam({ name: 'documentId', description: 'Document ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Document streamed successfully.' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Password required for this booking' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Booking or document not found' })
|
||||||
|
async downloadBookingDocument(
|
||||||
|
@Param('token') token: string,
|
||||||
|
@Param('documentId') documentId: string,
|
||||||
|
@Res({ passthrough: true }) res: Response
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
return this.streamDocument(token, documentId, undefined, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async streamDocument(
|
||||||
|
token: string,
|
||||||
|
documentId: string,
|
||||||
|
password: string | undefined,
|
||||||
|
res: Response
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
const { buffer, fileName, mimeType } = await this.csvBookingService.streamDocumentForCarrier(
|
||||||
|
token,
|
||||||
|
documentId,
|
||||||
|
password
|
||||||
|
);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', mimeType);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
|
||||||
|
return new StreamableFile(buffer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,6 +47,8 @@ import {
|
|||||||
CsvBookingResponseDto,
|
CsvBookingResponseDto,
|
||||||
CsvBookingListResponseDto,
|
CsvBookingListResponseDto,
|
||||||
CsvBookingStatsDto,
|
CsvBookingStatsDto,
|
||||||
|
UpdateCsvBookingDetailsDto,
|
||||||
|
UpdateCsvBookingRateDto,
|
||||||
} from '../dto/csv-booking.dto';
|
} from '../dto/csv-booking.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -166,12 +168,12 @@ export class CsvBookingsController {
|
|||||||
|
|
||||||
// ADMIN users bypass shipment limits
|
// ADMIN users bypass shipment limits
|
||||||
if (req.user.role !== 'ADMIN') {
|
if (req.user.role !== 'ADMIN') {
|
||||||
// Check shipment limit (Bronze plan = 12/year)
|
// Check the paid-reservation limit (free/Bronze plan = 5 paid shipments/year)
|
||||||
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
|
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
|
||||||
const maxShipments = subscription.plan.maxShipmentsPerYear;
|
const maxShipments = subscription.plan.maxShipmentsPerYear;
|
||||||
if (maxShipments !== -1) {
|
if (maxShipments !== -1) {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const count = await this.shipmentCounter.countShipmentsForOrganizationInYear(
|
const count = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear(
|
||||||
organizationId,
|
organizationId,
|
||||||
currentYear
|
currentYear
|
||||||
);
|
);
|
||||||
@ -226,6 +228,35 @@ export class CsvBookingsController {
|
|||||||
return await this.csvBookingService.getUserBookings(userId, page, limit);
|
return await this.csvBookingService.getUserBookings(userId, page, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the reservation (paid shipment) quota for the current organization.
|
||||||
|
*
|
||||||
|
* Uses the organization's real plan (not the ADMIN PLATINIUM override) and
|
||||||
|
* counts only PAID shipments this year, so the UI can show an upgrade prompt.
|
||||||
|
*
|
||||||
|
* GET /api/v1/csv-bookings/reservation-quota
|
||||||
|
*/
|
||||||
|
@Get('reservation-quota')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get the paid-reservation quota for the current organization' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Quota retrieved successfully' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
async getReservationQuota(
|
||||||
|
@Request() req: any
|
||||||
|
): Promise<{ max: number; used: number; unlimited: boolean; limitReached: boolean }> {
|
||||||
|
const organizationId = req.user.organizationId;
|
||||||
|
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
|
||||||
|
const max = subscription.plan.maxShipmentsPerYear;
|
||||||
|
const unlimited = max === -1;
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const used = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear(
|
||||||
|
organizationId,
|
||||||
|
currentYear
|
||||||
|
);
|
||||||
|
return { max, used, unlimited, limitReached: !unlimited && used >= max };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get booking statistics for user
|
* Get booking statistics for user
|
||||||
*
|
*
|
||||||
@ -560,6 +591,68 @@ export class CsvBookingsController {
|
|||||||
return await this.csvBookingService.cancelBooking(id, userId);
|
return await this.csvBookingService.cancelBooking(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update booking cargo details before payment
|
||||||
|
*
|
||||||
|
* PATCH /api/v1/csv-bookings/:id/details
|
||||||
|
*/
|
||||||
|
@Patch(':id/details')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Update booking details before payment',
|
||||||
|
description:
|
||||||
|
'Edit cargo characteristics (volume, weight, pallets, notes) of a booking awaiting payment. Only the owner can edit, and only while the booking is PENDING_PAYMENT.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Booking details updated successfully',
|
||||||
|
type: CsvBookingResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: 'Booking cannot be edited (invalid status or values)' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||||
|
async updateBookingDetails(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateCsvBookingDetailsDto,
|
||||||
|
@Request() req: any
|
||||||
|
): Promise<CsvBookingResponseDto> {
|
||||||
|
const userId = req.user.id;
|
||||||
|
return await this.csvBookingService.updateBookingDetails(id, userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-apply a full rate selection before payment
|
||||||
|
*
|
||||||
|
* PATCH /api/v1/csv-bookings/:id/rate
|
||||||
|
*/
|
||||||
|
@Patch(':id/rate')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Update booking rate/route before payment',
|
||||||
|
description:
|
||||||
|
'Re-apply a rate selection (carrier, route, container, transit, cargo, price) to a PENDING_PAYMENT booking. Only the owner can edit.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Booking rate updated successfully',
|
||||||
|
type: CsvBookingResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: 'Booking cannot be edited (invalid status or values)' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||||
|
async updateBookingRate(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateCsvBookingRateDto,
|
||||||
|
@Request() req: any
|
||||||
|
): Promise<CsvBookingResponseDto> {
|
||||||
|
const userId = req.user.id;
|
||||||
|
return await this.csvBookingService.updateBookingRate(id, userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add documents to an existing booking
|
* Add documents to an existing booking
|
||||||
*
|
*
|
||||||
|
|||||||
@ -42,10 +42,7 @@ import {
|
|||||||
ORGANIZATION_REPOSITORY,
|
ORGANIZATION_REPOSITORY,
|
||||||
} from '@domain/ports/out/organization.repository';
|
} from '@domain/ports/out/organization.repository';
|
||||||
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
|
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
|
||||||
import {
|
import { NotificationType, NotificationPriority } from '@domain/entities/notification.entity';
|
||||||
NotificationType,
|
|
||||||
NotificationPriority,
|
|
||||||
} from '@domain/entities/notification.entity';
|
|
||||||
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../guards/roles.guard';
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
@ -71,7 +68,8 @@ export class OrganizationsController {
|
|||||||
private readonly logger = new Logger(OrganizationsController.name);
|
private readonly logger = new Logger(OrganizationsController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
|
@Inject(ORGANIZATION_REPOSITORY)
|
||||||
|
private readonly organizationRepository: OrganizationRepository,
|
||||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||||
private readonly notificationService: NotificationService
|
private readonly notificationService: NotificationService
|
||||||
) {}
|
) {}
|
||||||
@ -320,9 +318,7 @@ export class OrganizationsController {
|
|||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: 'Approval request sent to admins' })
|
@ApiResponse({ status: 200, description: 'Approval request sent to admins' })
|
||||||
@ApiResponse({ status: 400, description: 'No SIRET/SIREN registered or already verified' })
|
@ApiResponse({ status: 400, description: 'No SIRET/SIREN registered or already verified' })
|
||||||
async requestSiretApproval(
|
async requestSiretApproval(@CurrentUser() user: UserPayload): Promise<{ message: string }> {
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
const organization = await this.organizationRepository.findById(user.organizationId);
|
const organization = await this.organizationRepository.findById(user.organizationId);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new NotFoundException('Organization not found');
|
throw new NotFoundException('Organization not found');
|
||||||
|
|||||||
@ -10,12 +10,64 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
Matches,
|
Matches,
|
||||||
|
ValidateIf,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { BlogPostStatus, type BlogPostCategory } from '@domain/entities/blog-post.entity';
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
BlogPostStatus,
|
||||||
|
type BlogPostCategory,
|
||||||
|
type BlogFaqItem,
|
||||||
|
} from '@domain/entities/blog-post.entity';
|
||||||
|
|
||||||
const CATEGORIES: BlogPostCategory[] = ['industry', 'technology', 'guides', 'news'];
|
const CATEGORIES: BlogPostCategory[] = ['industry', 'technology', 'guides', 'news'];
|
||||||
|
|
||||||
export class CreateBlogPostDto {
|
export class BlogFaqItemDto implements BlogFaqItem {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(500)
|
||||||
|
question: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(4000)
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GEO (Generative Engine Optimisation) fields, shared by create/update DTOs.
|
||||||
|
*/
|
||||||
|
export class BlogGeoFields {
|
||||||
|
@ApiPropertyOptional({ description: 'AI summary / TL;DR of the article' })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
aiSummary?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [BlogFaqItemDto], description: 'FAQ (question/answer pairs)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => BlogFaqItemDto)
|
||||||
|
faq?: BlogFaqItemDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String], description: 'Key takeaways' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
keyTakeaways?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String], description: 'Related entities / topics' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
aiEntities?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateBlogPostDto extends BlogGeoFields {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ -94,7 +146,7 @@ export class CreateBlogPostDto {
|
|||||||
secondaryKeywords?: string[];
|
secondaryKeywords?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateBlogPostDto {
|
export class UpdateBlogPostDto extends BlogGeoFields {
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -121,11 +173,15 @@ export class UpdateBlogPostDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
description: 'Cover image URL. Pass null to remove the current cover image.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
coverImageUrl?: string;
|
coverImageUrl?: string | null;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: CATEGORIES })
|
@ApiPropertyOptional({ enum: CATEGORIES })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -201,6 +257,10 @@ export class BlogPostResponseDto {
|
|||||||
@ApiPropertyOptional() metaDescription?: string;
|
@ApiPropertyOptional() metaDescription?: string;
|
||||||
@ApiPropertyOptional() primaryKeyword?: string;
|
@ApiPropertyOptional() primaryKeyword?: string;
|
||||||
@ApiProperty({ type: [String] }) secondaryKeywords: string[];
|
@ApiProperty({ type: [String] }) secondaryKeywords: string[];
|
||||||
|
@ApiPropertyOptional() aiSummary?: string | null;
|
||||||
|
@ApiProperty({ type: [BlogFaqItemDto] }) faq: BlogFaqItemDto[];
|
||||||
|
@ApiProperty({ type: [String] }) keyTakeaways: string[];
|
||||||
|
@ApiProperty({ type: [String] }) aiEntities: string[];
|
||||||
@ApiProperty() createdAt: Date;
|
@ApiProperty() createdAt: Date;
|
||||||
@ApiProperty() updatedAt: Date;
|
@ApiProperty() updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
Min,
|
Min,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsObject,
|
||||||
MinLength,
|
MinLength,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@ -104,6 +105,44 @@ export class CreateCsvBookingDto {
|
|||||||
@IsEnum(['USD', 'EUR'])
|
@IsEnum(['USD', 'EUR'])
|
||||||
primaryCurrency: string;
|
primaryCurrency: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Freight charge total (in freightCurrency)',
|
||||||
|
example: 420.0,
|
||||||
|
minimum: 0,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
freightTotal?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Currency of the freight charge',
|
||||||
|
enum: ['USD', 'EUR'],
|
||||||
|
example: 'USD',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['USD', 'EUR'])
|
||||||
|
freightCurrency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'FOB charges total (in fobCurrency)',
|
||||||
|
example: 245.0,
|
||||||
|
minimum: 0,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
fobTotal?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Currency of the FOB charges',
|
||||||
|
enum: ['USD', 'EUR'],
|
||||||
|
example: 'EUR',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['USD', 'EUR'])
|
||||||
|
fobCurrency?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Transit time in days',
|
description: 'Transit time in days',
|
||||||
example: 28,
|
example: 28,
|
||||||
@ -131,10 +170,203 @@ export class CreateCsvBookingDto {
|
|||||||
@MaxLength(1000)
|
@MaxLength(1000)
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Selected options & services as a JSON string (multipart), e.g. {"insurance":true}',
|
||||||
|
example: '{"insurance":true,"customsStop":true}',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
options?: string;
|
||||||
|
|
||||||
// Documents will be handled via file upload interceptor
|
// Documents will be handled via file upload interceptor
|
||||||
// Not included in DTO validation but processed separately
|
// Not included in DTO validation but processed separately
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update CSV Booking Details DTO
|
||||||
|
*
|
||||||
|
* Editable cargo characteristics before payment. Carrier, route and price are
|
||||||
|
* derived from the selected rate and cannot be changed here.
|
||||||
|
*/
|
||||||
|
export class UpdateCsvBookingDetailsDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Total volume in cubic meters (CBM)', example: 12.5 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
volumeCBM?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Total weight in kilograms', example: 1500 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
weightKG?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Number of pallets', example: 5 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
palletCount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Additional notes', example: 'Please handle with care' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
// Recomputed pricing (sent when cargo details change). Optional; when omitted
|
||||||
|
// the price is left unchanged.
|
||||||
|
@ApiPropertyOptional({ description: 'Recomputed total price in USD' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
priceUSD?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Recomputed total price in EUR' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
priceEUR?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Primary currency (USD/EUR)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
primaryCurrency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Recomputed freight total' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
freightTotal?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Freight currency' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
freightCurrency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Recomputed FOB total' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
fobTotal?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'FOB currency' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
fobCurrency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update CSV Booking Rate DTO
|
||||||
|
*
|
||||||
|
* Full re-selection of a rate before payment (carrier + route + container +
|
||||||
|
* transit + cargo + price). Used when the user re-runs the search and picks a
|
||||||
|
* (possibly different) rate for a PENDING_PAYMENT booking.
|
||||||
|
*/
|
||||||
|
export class UpdateCsvBookingRateDto {
|
||||||
|
@ApiProperty({ example: 'SSC Consolidation' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(200)
|
||||||
|
carrierName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'bookings@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
carrierEmail: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'FRLIO' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(5)
|
||||||
|
origin: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'AUADL' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(5)
|
||||||
|
destination: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'LCL' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
containerType: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 46 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
transitDays: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 12.5 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
volumeCBM: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1500 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
weightKG: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 5 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
palletCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 0 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
priceUSD: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 258 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
priceEUR: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'EUR' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
primaryCurrency: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 150 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
freightTotal?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'EUR' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
freightCurrency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 108 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
fobTotal?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'EUR' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
fobCurrency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Handle with care' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Selected options & services (customs, insurance, DG, handling…)',
|
||||||
|
example: { insurance: true, customsStop: true },
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
options?: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Document DTO for response
|
* Document DTO for response
|
||||||
*/
|
*/
|
||||||
@ -365,6 +597,36 @@ export class CsvBookingResponseDto {
|
|||||||
example: 313.27,
|
example: 313.27,
|
||||||
})
|
})
|
||||||
commissionAmountEur?: number;
|
commissionAmountEur?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Freight charge total (in freightCurrency)',
|
||||||
|
example: 420.0,
|
||||||
|
})
|
||||||
|
freightTotal?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Currency of the freight charge',
|
||||||
|
example: 'USD',
|
||||||
|
})
|
||||||
|
freightCurrency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'FOB charges total (in fobCurrency)',
|
||||||
|
example: 245.0,
|
||||||
|
})
|
||||||
|
fobTotal?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Currency of the FOB charges',
|
||||||
|
example: 'EUR',
|
||||||
|
})
|
||||||
|
fobCurrency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Selected options & services (customs, insurance, DG, handling…)',
|
||||||
|
example: { insurance: true },
|
||||||
|
})
|
||||||
|
options?: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -207,14 +207,14 @@ export class PlanDetailsDto {
|
|||||||
maxLicenses: number;
|
maxLicenses: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 249,
|
example: 299,
|
||||||
description: 'Monthly price in EUR',
|
description: 'Monthly price in EUR',
|
||||||
})
|
})
|
||||||
monthlyPriceEur: number;
|
monthlyPriceEur: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 2739,
|
example: 3289,
|
||||||
description: 'Yearly price in EUR (11 months)',
|
description: 'Yearly price in EUR (11 months — 1 month deducted)',
|
||||||
})
|
})
|
||||||
yearlyPriceEur: number;
|
yearlyPriceEur: number;
|
||||||
|
|
||||||
@ -225,10 +225,10 @@ export class PlanDetailsDto {
|
|||||||
maxShipmentsPerYear: number;
|
maxShipmentsPerYear: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 3,
|
example: 10,
|
||||||
description: 'Commission rate percentage on shipments',
|
description: 'Per-booking fee in EUR (-1 for custom / on quote)',
|
||||||
})
|
})
|
||||||
commissionRatePercent: number;
|
bookingFeeEur: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'email',
|
example: 'email',
|
||||||
|
|||||||
@ -9,11 +9,7 @@ import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/
|
|||||||
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [TypeOrmModule.forFeature([OrganizationOrmEntity]), UsersModule, NotificationsModule],
|
||||||
TypeOrmModule.forFeature([OrganizationOrmEntity]),
|
|
||||||
UsersModule,
|
|
||||||
NotificationsModule,
|
|
||||||
],
|
|
||||||
controllers: [OrganizationsController],
|
controllers: [OrganizationsController],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -50,11 +50,17 @@ import { CarrierOrmEntity } from '../../infrastructure/persistence/typeorm/entit
|
|||||||
cache: any,
|
cache: any,
|
||||||
rateQuoteRepo: any,
|
rateQuoteRepo: any,
|
||||||
portRepo: any,
|
portRepo: any,
|
||||||
carrierRepo: any,
|
carrierRepo: any
|
||||||
) => {
|
) => {
|
||||||
return new RateSearchService(connectors, cache, rateQuoteRepo, portRepo, carrierRepo);
|
return new RateSearchService(connectors, cache, rateQuoteRepo, portRepo, carrierRepo);
|
||||||
},
|
},
|
||||||
inject: ['CarrierConnectors', CACHE_PORT, RATE_QUOTE_REPOSITORY, PORT_REPOSITORY, CARRIER_REPOSITORY],
|
inject: [
|
||||||
|
'CarrierConnectors',
|
||||||
|
CACHE_PORT,
|
||||||
|
RATE_QUOTE_REPOSITORY,
|
||||||
|
PORT_REPOSITORY,
|
||||||
|
CARRIER_REPOSITORY,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [RATE_QUOTE_REPOSITORY, RateSearchService],
|
exports: [RATE_QUOTE_REPOSITORY, RateSearchService],
|
||||||
|
|||||||
@ -58,6 +58,10 @@ export class BlogService implements OnApplicationBootstrap {
|
|||||||
metaDescription: dto.metaDescription,
|
metaDescription: dto.metaDescription,
|
||||||
primaryKeyword: dto.primaryKeyword,
|
primaryKeyword: dto.primaryKeyword,
|
||||||
secondaryKeywords: dto.secondaryKeywords ?? [],
|
secondaryKeywords: dto.secondaryKeywords ?? [],
|
||||||
|
aiSummary: dto.aiSummary,
|
||||||
|
faq: dto.faq ?? [],
|
||||||
|
keyTakeaways: dto.keyTakeaways ?? [],
|
||||||
|
aiEntities: dto.aiEntities ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.scheduledAt) {
|
if (dto.scheduledAt) {
|
||||||
@ -92,6 +96,10 @@ export class BlogService implements OnApplicationBootstrap {
|
|||||||
metaDescription: dto.metaDescription,
|
metaDescription: dto.metaDescription,
|
||||||
primaryKeyword: dto.primaryKeyword,
|
primaryKeyword: dto.primaryKeyword,
|
||||||
secondaryKeywords: dto.secondaryKeywords,
|
secondaryKeywords: dto.secondaryKeywords,
|
||||||
|
aiSummary: dto.aiSummary,
|
||||||
|
faq: dto.faq,
|
||||||
|
keyTakeaways: dto.keyTakeaways,
|
||||||
|
aiEntities: dto.aiEntities,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.scheduledAt) {
|
if (dto.scheduledAt) {
|
||||||
@ -108,11 +116,83 @@ export class BlogService implements OnApplicationBootstrap {
|
|||||||
return this.blogPostRepository.save(updated);
|
return this.blogPostRepository.save(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete a post: move it to the trash. It can be restored later or
|
||||||
|
* permanently deleted from the trash.
|
||||||
|
*/
|
||||||
async deletePost(id: string): Promise<void> {
|
async deletePost(id: string): Promise<void> {
|
||||||
await this.findOrFail(id);
|
const post = await this.findOrFail(id);
|
||||||
|
await this.blogPostRepository.save(post.trash());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore a post from the trash. */
|
||||||
|
async restorePost(id: string): Promise<BlogPost> {
|
||||||
|
const post = await this.findOrFail(id);
|
||||||
|
return this.blogPostRepository.save(post.restore());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently delete a post (hard delete). Also removes its cover image from
|
||||||
|
* storage when it is a locally hosted blog image (best-effort).
|
||||||
|
*/
|
||||||
|
async permanentlyDeletePost(id: string): Promise<void> {
|
||||||
|
const post = await this.findOrFail(id);
|
||||||
|
|
||||||
|
const coverUrl = post.coverImageUrl;
|
||||||
|
if (coverUrl) {
|
||||||
|
const match = coverUrl.match(/\/api\/v1\/blog\/images\/([^/?#]+)$/);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
await this.storage.delete({ bucket: BLOG_IMAGES_BUCKET, key: `blog-images/${match[1]}` });
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Could not delete cover image for post ${id}: ${err?.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.blogPostRepository.delete(id);
|
await this.blogPostRepository.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate a post as a new draft with a unique slug.
|
||||||
|
*/
|
||||||
|
async duplicatePost(id: string): Promise<BlogPost> {
|
||||||
|
const source = await this.findOrFail(id);
|
||||||
|
const slug = await this.generateUniqueSlug(source.slug);
|
||||||
|
|
||||||
|
const copy = BlogPost.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
title: `${source.title} (copie)`,
|
||||||
|
slug,
|
||||||
|
excerpt: source.excerpt,
|
||||||
|
content: source.content,
|
||||||
|
coverImageUrl: source.coverImageUrl,
|
||||||
|
category: source.category,
|
||||||
|
tags: [...source.tags],
|
||||||
|
authorName: source.authorName,
|
||||||
|
metaTitle: source.metaTitle,
|
||||||
|
metaDescription: source.metaDescription,
|
||||||
|
primaryKeyword: source.primaryKeyword,
|
||||||
|
secondaryKeywords: [...source.secondaryKeywords],
|
||||||
|
aiSummary: source.aiSummary,
|
||||||
|
faq: source.faq.map(item => ({ ...item })),
|
||||||
|
keyTakeaways: [...source.keyTakeaways],
|
||||||
|
aiEntities: [...source.aiEntities],
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.blogPostRepository.save(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateUniqueSlug(baseSlug: string): Promise<string> {
|
||||||
|
let candidate = `${baseSlug}-copie`;
|
||||||
|
let counter = 2;
|
||||||
|
while (await this.blogPostRepository.slugExists(candidate)) {
|
||||||
|
candidate = `${baseSlug}-copie-${counter}`;
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
async getPostById(id: string): Promise<BlogPost> {
|
async getPostById(id: string): Promise<BlogPost> {
|
||||||
return this.findOrFail(id);
|
return this.findOrFail(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,8 @@ import {
|
|||||||
CsvBookingDocumentDto,
|
CsvBookingDocumentDto,
|
||||||
CsvBookingListResponseDto,
|
CsvBookingListResponseDto,
|
||||||
CsvBookingStatsDto,
|
CsvBookingStatsDto,
|
||||||
|
UpdateCsvBookingDetailsDto,
|
||||||
|
UpdateCsvBookingRateDto,
|
||||||
} from '../dto/csv-booking.dto';
|
} from '../dto/csv-booking.dto';
|
||||||
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
|
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
|
||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
@ -160,18 +162,26 @@ export class CsvBookingService {
|
|||||||
// Upload documents to S3
|
// Upload documents to S3
|
||||||
const documents = await this.uploadDocuments(files, bookingId);
|
const documents = await this.uploadDocuments(files, bookingId);
|
||||||
|
|
||||||
// Calculate commission based on organization's subscription plan
|
// Flat per-booking service fee (forfait par booking) based on the org's plan.
|
||||||
let commissionRate = 5; // default Bronze
|
// A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the
|
||||||
let commissionAmountEur = 0;
|
// booking skips the payment gate and the carrier is notified immediately.
|
||||||
try {
|
const bookingFeeEur = await this.resolveBookingFeeEur(organizationId);
|
||||||
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
|
const requiresPayment = bookingFeeEur > 0;
|
||||||
commissionRate = subscription.plan.commissionRatePercent;
|
const initialStatus = requiresPayment
|
||||||
} catch (error: any) {
|
? CsvBookingStatus.PENDING_PAYMENT
|
||||||
this.logger.error(`Failed to get subscription for commission: ${error?.message}`);
|
: CsvBookingStatus.PENDING;
|
||||||
}
|
|
||||||
commissionAmountEur = Math.round(dto.priceEUR * commissionRate) / 100;
|
// Create domain entity (no email sent yet when a payment is required)
|
||||||
|
let parsedOptions: Record<string, boolean> = {};
|
||||||
|
if (dto.options) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(dto.options);
|
||||||
|
if (parsed && typeof parsed === 'object') parsedOptions = parsed;
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`Ignoring invalid options JSON for booking creation`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create domain entity in PENDING_PAYMENT status (no email sent yet)
|
|
||||||
const booking = new CsvBooking(
|
const booking = new CsvBooking(
|
||||||
bookingId,
|
bookingId,
|
||||||
userId,
|
userId,
|
||||||
@ -188,7 +198,7 @@ export class CsvBookingService {
|
|||||||
dto.primaryCurrency,
|
dto.primaryCurrency,
|
||||||
dto.transitDays,
|
dto.transitDays,
|
||||||
dto.containerType,
|
dto.containerType,
|
||||||
CsvBookingStatus.PENDING_PAYMENT,
|
initialStatus,
|
||||||
documents,
|
documents,
|
||||||
confirmationToken,
|
confirmationToken,
|
||||||
new Date(),
|
new Date(),
|
||||||
@ -196,8 +206,14 @@ export class CsvBookingService {
|
|||||||
dto.notes,
|
dto.notes,
|
||||||
undefined,
|
undefined,
|
||||||
bookingNumber,
|
bookingNumber,
|
||||||
commissionRate,
|
undefined, // commissionRate — flat fee, no percentage
|
||||||
commissionAmountEur
|
requiresPayment ? bookingFeeEur : 0,
|
||||||
|
undefined, // stripePaymentIntentId — set after payment
|
||||||
|
dto.freightTotal,
|
||||||
|
dto.freightCurrency,
|
||||||
|
dto.fobTotal,
|
||||||
|
dto.fobCurrency,
|
||||||
|
parsedOptions
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
@ -214,15 +230,37 @@ export class CsvBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: PENDING_PAYMENT, commission: ${commissionRate}% = ${commissionAmountEur}€`
|
`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: ${initialStatus}, booking fee: ${bookingFeeEur > 0 ? `${bookingFeeEur}€` : 'none (custom)'}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// NO email sent to carrier yet - will be sent after commission payment
|
if (requiresPayment) {
|
||||||
// NO notification yet - will be created after payment confirmation
|
// Carrier email + notification are sent after the booking fee is paid
|
||||||
|
} else {
|
||||||
|
// No fee to collect — notify the carrier immediately
|
||||||
|
await this.sendCarrierBookingRequest(booking, bookingNumber, documentPassword);
|
||||||
|
await this.notifyBookingRequestSent(booking);
|
||||||
|
}
|
||||||
|
|
||||||
return this.toResponseDto(savedBooking);
|
return this.toResponseDto(savedBooking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the flat per-booking fee (forfait par booking) for an organization
|
||||||
|
* from its subscription plan. Returns the plan's bookingFeeEur, or 0 when the
|
||||||
|
* plan has a custom fee (-1, e.g. Platinium "sur mesure") or on error — such
|
||||||
|
* bookings are not auto-charged.
|
||||||
|
*/
|
||||||
|
private async resolveBookingFeeEur(organizationId: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
|
||||||
|
const fee = subscription.plan.bookingFeeEur;
|
||||||
|
return fee > 0 ? fee : 0;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Failed to resolve booking fee: ${error?.message}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Stripe Checkout session for commission payment
|
* Create a Stripe Checkout session for commission payment
|
||||||
*/
|
*/
|
||||||
@ -250,7 +288,7 @@ export class CsvBookingService {
|
|||||||
|
|
||||||
const commissionAmountEur = booking.commissionAmountEur || 0;
|
const commissionAmountEur = booking.commissionAmountEur || 0;
|
||||||
if (commissionAmountEur <= 0) {
|
if (commissionAmountEur <= 0) {
|
||||||
throw new BadRequestException('Commission amount is invalid');
|
throw new BadRequestException('Booking fee amount is invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
const amountCents = Math.round(commissionAmountEur * 100);
|
const amountCents = Math.round(commissionAmountEur * 100);
|
||||||
@ -261,13 +299,13 @@ export class CsvBookingService {
|
|||||||
currency: 'eur',
|
currency: 'eur',
|
||||||
customerEmail: userEmail,
|
customerEmail: userEmail,
|
||||||
organizationId: booking.organizationId,
|
organizationId: booking.organizationId,
|
||||||
bookingDescription: `Commission booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()} → ${booking.destination.getValue()}`,
|
bookingDescription: `Frais de booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()} → ${booking.destination.getValue()}`,
|
||||||
successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`,
|
successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`,
|
cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Created Stripe commission checkout for booking ${bookingId}: ${amountCents} cents EUR`
|
`Created Stripe booking-fee checkout for booking ${bookingId}: ${amountCents} cents EUR`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -332,7 +370,22 @@ export class CsvBookingService {
|
|||||||
const bookingNumber = ormBooking?.bookingNumber;
|
const bookingNumber = ormBooking?.bookingNumber;
|
||||||
const documentPassword = await this.syncDocumentPassword(booking.id);
|
const documentPassword = await this.syncDocumentPassword(booking.id);
|
||||||
|
|
||||||
// NOW send email to carrier
|
// NOW send email to carrier + notify the user (booking fee has been paid)
|
||||||
|
await this.sendCarrierBookingRequest(booking, bookingNumber, documentPassword);
|
||||||
|
await this.notifyBookingRequestSent(booking);
|
||||||
|
|
||||||
|
return this.toResponseDto(updatedBooking);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the booking request email to the carrier.
|
||||||
|
* Used both when no booking fee is due (at creation) and after the fee is paid.
|
||||||
|
*/
|
||||||
|
private async sendCarrierBookingRequest(
|
||||||
|
booking: CsvBooking,
|
||||||
|
bookingNumber: string | null | undefined,
|
||||||
|
documentPassword: string | null | undefined
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
|
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
@ -359,8 +412,12 @@ export class CsvBookingService {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
|
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create notification for user
|
/**
|
||||||
|
* Notify the user that their booking request has been sent to the carrier.
|
||||||
|
*/
|
||||||
|
private async notifyBookingRequestSent(booking: CsvBooking): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const notification = Notification.create({
|
const notification = Notification.create({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
@ -369,15 +426,13 @@ export class CsvBookingService {
|
|||||||
type: NotificationType.CSV_BOOKING_REQUEST_SENT,
|
type: NotificationType.CSV_BOOKING_REQUEST_SENT,
|
||||||
priority: NotificationPriority.MEDIUM,
|
priority: NotificationPriority.MEDIUM,
|
||||||
title: 'Booking Request Sent',
|
title: 'Booking Request Sent',
|
||||||
message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully after payment.`,
|
message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully.`,
|
||||||
metadata: { bookingId: booking.id, carrierName: booking.carrierName },
|
metadata: { bookingId: booking.id, carrierName: booking.carrierName },
|
||||||
});
|
});
|
||||||
await this.notificationRepository.save(notification);
|
await this.notificationRepository.save(notification);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
|
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.toResponseDto(updatedBooking);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -693,20 +748,17 @@ export class CsvBookingService {
|
|||||||
throw new NotFoundException('Réservation introuvable');
|
throw new NotFoundException('Réservation introuvable');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate signed URLs for all documents
|
// Build proxy download URLs. The file is streamed through the API rather than
|
||||||
const documentsWithUrls = await Promise.all(
|
// served via a presigned storage URL, whose internal host is unreachable from
|
||||||
booking.documents.map(async doc => {
|
// the carrier's browser. The frontend prepends the public API base URL.
|
||||||
const signedUrl = await this.generateSignedUrlForDocument(doc.filePath);
|
const documentsWithUrls = booking.documents.map(doc => ({
|
||||||
return {
|
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
type: doc.type,
|
type: doc.type,
|
||||||
fileName: doc.fileName,
|
fileName: doc.fileName,
|
||||||
mimeType: doc.mimeType,
|
mimeType: doc.mimeType,
|
||||||
size: doc.size,
|
size: doc.size,
|
||||||
downloadUrl: signedUrl,
|
downloadUrl: `/api/v1/csv-booking-actions/documents/${token}/${doc.id}/download`,
|
||||||
};
|
}));
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
|
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
|
||||||
|
|
||||||
@ -753,12 +805,10 @@ export class CsvBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate signed URL for a document file path
|
* Extract the storage object key from a stored document file path.
|
||||||
|
* Handles raw keys, "bucket/key" paths and full URLs.
|
||||||
*/
|
*/
|
||||||
private async generateSignedUrlForDocument(filePath: string): Promise<string> {
|
private extractDocumentKey(filePath: string): string {
|
||||||
const bucket = 'xpeditis-documents';
|
|
||||||
|
|
||||||
// Extract key from the file path
|
|
||||||
let key = filePath;
|
let key = filePath;
|
||||||
if (filePath.includes('xpeditis-documents/')) {
|
if (filePath.includes('xpeditis-documents/')) {
|
||||||
key = filePath.split('xpeditis-documents/')[1];
|
key = filePath.split('xpeditis-documents/')[1];
|
||||||
@ -769,10 +819,65 @@ export class CsvBookingService {
|
|||||||
key = key.replace('xpeditis-documents/', '');
|
key = key.replace('xpeditis-documents/', '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate signed URL with 1 hour expiration
|
/**
|
||||||
const signedUrl = await this.storageAdapter.getSignedUrl({ bucket, key }, 3600);
|
* Stream a booking document to the carrier through the API (public, token-based).
|
||||||
return signedUrl;
|
*
|
||||||
|
* Downloads the file from storage and returns it as a buffer so the API can
|
||||||
|
* proxy it to the carrier's browser. This avoids handing out presigned storage
|
||||||
|
* URLs whose host (internal MinIO/S3 endpoint) is not reachable from outside.
|
||||||
|
*
|
||||||
|
* Applies the same access rules as {@link getDocumentsForCarrier}: the booking
|
||||||
|
* must be ACCEPTED and, when protected, the correct password must be supplied.
|
||||||
|
*/
|
||||||
|
async streamDocumentForCarrier(
|
||||||
|
token: string,
|
||||||
|
documentId: string,
|
||||||
|
password?: string
|
||||||
|
): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> {
|
||||||
|
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||||
|
where: { confirmationToken: token },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ormBooking) {
|
||||||
|
throw new NotFoundException('Réservation introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ormBooking.status !== 'ACCEPTED') {
|
||||||
|
throw new BadRequestException("Cette réservation n'a pas encore été acceptée");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ormBooking.passwordHash) {
|
||||||
|
if (!password) {
|
||||||
|
throw new UnauthorizedException('Mot de passe requis pour accéder aux documents');
|
||||||
|
}
|
||||||
|
const isPasswordValid = await argon2.verify(ormBooking.passwordHash, password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new UnauthorizedException('Mot de passe incorrect');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const booking = await this.csvBookingRepository.findByToken(token);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException('Réservation introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = booking.documents.find(doc => doc.id === documentId);
|
||||||
|
if (!document) {
|
||||||
|
throw new NotFoundException('Document introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = 'xpeditis-documents';
|
||||||
|
const key = this.extractDocumentKey(document.filePath);
|
||||||
|
const buffer = await this.storageAdapter.download({ bucket, key });
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
fileName: document.fileName,
|
||||||
|
mimeType: document.mimeType || 'application/octet-stream',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -795,20 +900,12 @@ export class CsvBookingService {
|
|||||||
// Accept the booking (domain logic validates status)
|
// Accept the booking (domain logic validates status)
|
||||||
booking.accept();
|
booking.accept();
|
||||||
|
|
||||||
// Apply commission based on organization's subscription plan
|
// Apply the flat per-booking service fee (forfait par booking) from the org's plan
|
||||||
try {
|
const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId);
|
||||||
const subscription = await this.subscriptionService.getOrCreateSubscription(
|
booking.applyBookingFee(bookingFeeEur);
|
||||||
booking.organizationId
|
|
||||||
);
|
|
||||||
const commissionRate = subscription.plan.commissionRatePercent;
|
|
||||||
const baseAmountEur = booking.priceEUR;
|
|
||||||
booking.applyCommission(commissionRate, baseAmountEur);
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Commission applied: ${commissionRate}% on ${baseAmountEur}€ = ${booking.commissionAmountEur}€`
|
`Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}`
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to apply commission: ${error?.message}`, error?.stack);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save updated booking
|
// Save updated booking
|
||||||
const updatedBooking = await this.csvBookingRepository.update(booking);
|
const updatedBooking = await this.csvBookingRepository.update(booking);
|
||||||
@ -927,6 +1024,123 @@ export class CsvBookingService {
|
|||||||
return this.toResponseDto(updatedBooking);
|
return this.toResponseDto(updatedBooking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the cargo details of a booking before payment (user action).
|
||||||
|
*
|
||||||
|
* Only the owner can edit, and only while the booking is PENDING_PAYMENT.
|
||||||
|
*/
|
||||||
|
async updateBookingDetails(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
dto: UpdateCsvBookingDetailsDto
|
||||||
|
): Promise<CsvBookingResponseDto> {
|
||||||
|
this.logger.log(`Updating booking details ${id} by user ${userId}`);
|
||||||
|
|
||||||
|
const booking = await this.csvBookingRepository.findById(id);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException('Booking not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user owns this booking
|
||||||
|
if (booking.userId !== userId) {
|
||||||
|
throw new NotFoundException('Booking not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect whether any pricing field was provided
|
||||||
|
const hasPricing =
|
||||||
|
dto.priceUSD !== undefined ||
|
||||||
|
dto.priceEUR !== undefined ||
|
||||||
|
dto.primaryCurrency !== undefined ||
|
||||||
|
dto.freightTotal !== undefined ||
|
||||||
|
dto.freightCurrency !== undefined ||
|
||||||
|
dto.fobTotal !== undefined ||
|
||||||
|
dto.fobCurrency !== undefined;
|
||||||
|
|
||||||
|
// Apply the changes (domain logic validates status + values)
|
||||||
|
try {
|
||||||
|
booking.editDetails({
|
||||||
|
volumeCBM: dto.volumeCBM,
|
||||||
|
weightKG: dto.weightKG,
|
||||||
|
palletCount: dto.palletCount,
|
||||||
|
notes: dto.notes,
|
||||||
|
pricing: hasPricing
|
||||||
|
? {
|
||||||
|
priceUSD: dto.priceUSD,
|
||||||
|
priceEUR: dto.priceEUR,
|
||||||
|
primaryCurrency: dto.primaryCurrency,
|
||||||
|
freightTotal: dto.freightTotal,
|
||||||
|
freightCurrency: dto.freightCurrency,
|
||||||
|
fobTotal: dto.fobTotal,
|
||||||
|
fobCurrency: dto.fobCurrency,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking details');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedBooking = await this.csvBookingRepository.update(booking);
|
||||||
|
this.logger.log(`Booking ${id} details updated`);
|
||||||
|
|
||||||
|
return this.toResponseDto(updatedBooking);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-apply a full rate selection to a booking before payment (user action).
|
||||||
|
*
|
||||||
|
* Used when the user re-runs the search and picks a (possibly different) rate:
|
||||||
|
* carrier, route, container, transit, cargo and price are all replaced. Only
|
||||||
|
* the owner can edit, and only while the booking is PENDING_PAYMENT.
|
||||||
|
*/
|
||||||
|
async updateBookingRate(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
dto: UpdateCsvBookingRateDto
|
||||||
|
): Promise<CsvBookingResponseDto> {
|
||||||
|
this.logger.log(`Updating booking rate ${id} by user ${userId}`);
|
||||||
|
|
||||||
|
const booking = await this.csvBookingRepository.findById(id);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException('Booking not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.userId !== userId) {
|
||||||
|
throw new NotFoundException('Booking not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
booking.editFromRate({
|
||||||
|
carrierName: dto.carrierName,
|
||||||
|
carrierEmail: dto.carrierEmail,
|
||||||
|
origin: PortCode.create(dto.origin),
|
||||||
|
destination: PortCode.create(dto.destination),
|
||||||
|
containerType: dto.containerType,
|
||||||
|
transitDays: dto.transitDays,
|
||||||
|
volumeCBM: dto.volumeCBM,
|
||||||
|
weightKG: dto.weightKG,
|
||||||
|
palletCount: dto.palletCount,
|
||||||
|
priceUSD: dto.priceUSD,
|
||||||
|
priceEUR: dto.priceEUR,
|
||||||
|
primaryCurrency: dto.primaryCurrency,
|
||||||
|
freightTotal: dto.freightTotal,
|
||||||
|
freightCurrency: dto.freightCurrency,
|
||||||
|
fobTotal: dto.fobTotal,
|
||||||
|
fobCurrency: dto.fobCurrency,
|
||||||
|
notes: dto.notes,
|
||||||
|
options: dto.options,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking rate');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedBooking = await this.csvBookingRepository.update(booking);
|
||||||
|
this.logger.log(`Booking ${id} rate updated`);
|
||||||
|
|
||||||
|
return this.toResponseDto(updatedBooking);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get bookings for a user (paginated)
|
* Get bookings for a user (paginated)
|
||||||
*/
|
*/
|
||||||
@ -1183,31 +1397,14 @@ export class CsvBookingService {
|
|||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify booking is still pending or awaiting payment
|
// Find the document (no status restriction — aligned with replaceDocument;
|
||||||
if (
|
// deleting down to zero documents is allowed)
|
||||||
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
|
|
||||||
booking.status !== CsvBookingStatus.PENDING
|
|
||||||
) {
|
|
||||||
throw new BadRequestException('Cannot delete documents from a booking that is not pending');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the document
|
|
||||||
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
|
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
|
||||||
|
|
||||||
if (documentIndex === -1) {
|
if (documentIndex === -1) {
|
||||||
throw new NotFoundException(`Document with ID ${documentId} not found`);
|
throw new NotFoundException(`Document with ID ${documentId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure at least one document remains
|
|
||||||
if (booking.documents.length <= 1) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Cannot delete the last document. At least one document is required.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the document to delete (for potential S3 cleanup - currently kept for audit)
|
|
||||||
const _documentToDelete = booking.documents[documentIndex];
|
|
||||||
|
|
||||||
// Remove document from array
|
// Remove document from array
|
||||||
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
|
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
|
||||||
|
|
||||||
@ -1382,6 +1579,11 @@ export class CsvBookingService {
|
|||||||
price: booking.getPriceInCurrency(primaryCurrency),
|
price: booking.getPriceInCurrency(primaryCurrency),
|
||||||
commissionRate: booking.commissionRate,
|
commissionRate: booking.commissionRate,
|
||||||
commissionAmountEur: booking.commissionAmountEur,
|
commissionAmountEur: booking.commissionAmountEur,
|
||||||
|
freightTotal: booking.freightTotal,
|
||||||
|
freightCurrency: booking.freightCurrency,
|
||||||
|
fobTotal: booking.fobTotal,
|
||||||
|
fobCurrency: booking.fobCurrency,
|
||||||
|
options: booking.options ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -674,7 +674,7 @@ export class SubscriptionService {
|
|||||||
monthlyPriceEur: plan.monthlyPriceEur,
|
monthlyPriceEur: plan.monthlyPriceEur,
|
||||||
yearlyPriceEur: plan.yearlyPriceEur,
|
yearlyPriceEur: plan.yearlyPriceEur,
|
||||||
maxShipmentsPerYear: plan.maxShipmentsPerYear,
|
maxShipmentsPerYear: plan.maxShipmentsPerYear,
|
||||||
commissionRatePercent: plan.commissionRatePercent,
|
bookingFeeEur: plan.bookingFeeEur,
|
||||||
supportLevel: plan.supportLevel,
|
supportLevel: plan.supportLevel,
|
||||||
statusBadge: plan.statusBadge,
|
statusBadge: plan.statusBadge,
|
||||||
planFeatures: [...plan.planFeatures],
|
planFeatures: [...plan.planFeatures],
|
||||||
|
|||||||
@ -7,13 +7,19 @@ export enum BlogPostStatus {
|
|||||||
|
|
||||||
export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news';
|
export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news';
|
||||||
|
|
||||||
|
/** A single question/answer pair used for GEO (AI) optimisation and FAQ schema. */
|
||||||
|
export interface BlogFaqItem {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface BlogPostProps {
|
interface BlogPostProps {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
content: string;
|
content: string;
|
||||||
coverImageUrl?: string;
|
coverImageUrl?: string | null;
|
||||||
category: BlogPostCategory;
|
category: BlogPostCategory;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
authorName: string;
|
authorName: string;
|
||||||
@ -24,6 +30,13 @@ interface BlogPostProps {
|
|||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
primaryKeyword?: string;
|
primaryKeyword?: string;
|
||||||
secondaryKeywords: string[];
|
secondaryKeywords: string[];
|
||||||
|
// GEO (Generative Engine Optimisation) — helps AI/LLM engines cite the article.
|
||||||
|
aiSummary?: string | null;
|
||||||
|
faq: BlogFaqItem[];
|
||||||
|
keyTakeaways: string[];
|
||||||
|
aiEntities: string[];
|
||||||
|
// Soft delete (trash). Null/undefined means the post is not trashed.
|
||||||
|
deletedAt?: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@ -32,12 +45,27 @@ export class BlogPost {
|
|||||||
private constructor(private readonly props: BlogPostProps) {}
|
private constructor(private readonly props: BlogPostProps) {}
|
||||||
|
|
||||||
static create(
|
static create(
|
||||||
props: Omit<BlogPostProps, 'status' | 'isFeatured' | 'publishedAt' | 'createdAt' | 'updatedAt'>
|
props: Omit<
|
||||||
|
BlogPostProps,
|
||||||
|
| 'status'
|
||||||
|
| 'isFeatured'
|
||||||
|
| 'publishedAt'
|
||||||
|
| 'createdAt'
|
||||||
|
| 'updatedAt'
|
||||||
|
| 'faq'
|
||||||
|
| 'keyTakeaways'
|
||||||
|
| 'aiEntities'
|
||||||
|
| 'deletedAt'
|
||||||
|
> &
|
||||||
|
Partial<Pick<BlogPostProps, 'faq' | 'keyTakeaways' | 'aiEntities'>>
|
||||||
): BlogPost {
|
): BlogPost {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return new BlogPost({
|
return new BlogPost({
|
||||||
...props,
|
...props,
|
||||||
secondaryKeywords: props.secondaryKeywords ?? [],
|
secondaryKeywords: props.secondaryKeywords ?? [],
|
||||||
|
faq: props.faq ?? [],
|
||||||
|
keyTakeaways: props.keyTakeaways ?? [],
|
||||||
|
aiEntities: props.aiEntities ?? [],
|
||||||
status: BlogPostStatus.DRAFT,
|
status: BlogPostStatus.DRAFT,
|
||||||
isFeatured: false,
|
isFeatured: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@ -64,7 +92,7 @@ export class BlogPost {
|
|||||||
get content(): string {
|
get content(): string {
|
||||||
return this.props.content;
|
return this.props.content;
|
||||||
}
|
}
|
||||||
get coverImageUrl(): string | undefined {
|
get coverImageUrl(): string | null | undefined {
|
||||||
return this.props.coverImageUrl;
|
return this.props.coverImageUrl;
|
||||||
}
|
}
|
||||||
get category(): BlogPostCategory {
|
get category(): BlogPostCategory {
|
||||||
@ -97,6 +125,24 @@ export class BlogPost {
|
|||||||
get secondaryKeywords(): string[] {
|
get secondaryKeywords(): string[] {
|
||||||
return this.props.secondaryKeywords;
|
return this.props.secondaryKeywords;
|
||||||
}
|
}
|
||||||
|
get aiSummary(): string | null | undefined {
|
||||||
|
return this.props.aiSummary;
|
||||||
|
}
|
||||||
|
get faq(): BlogFaqItem[] {
|
||||||
|
return this.props.faq;
|
||||||
|
}
|
||||||
|
get keyTakeaways(): string[] {
|
||||||
|
return this.props.keyTakeaways;
|
||||||
|
}
|
||||||
|
get aiEntities(): string[] {
|
||||||
|
return this.props.aiEntities;
|
||||||
|
}
|
||||||
|
get deletedAt(): Date | null | undefined {
|
||||||
|
return this.props.deletedAt;
|
||||||
|
}
|
||||||
|
get isTrashed(): boolean {
|
||||||
|
return !!this.props.deletedAt;
|
||||||
|
}
|
||||||
get createdAt(): Date {
|
get createdAt(): Date {
|
||||||
return this.props.createdAt;
|
return this.props.createdAt;
|
||||||
}
|
}
|
||||||
@ -121,12 +167,26 @@ export class BlogPost {
|
|||||||
| 'metaDescription'
|
| 'metaDescription'
|
||||||
| 'primaryKeyword'
|
| 'primaryKeyword'
|
||||||
| 'secondaryKeywords'
|
| 'secondaryKeywords'
|
||||||
|
| 'aiSummary'
|
||||||
|
| 'faq'
|
||||||
|
| 'keyTakeaways'
|
||||||
|
| 'aiEntities'
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
): BlogPost {
|
): BlogPost {
|
||||||
return new BlogPost({ ...this.props, ...data, updatedAt: new Date() });
|
return new BlogPost({ ...this.props, ...data, updatedAt: new Date() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Move the post to the trash (soft delete). */
|
||||||
|
trash(): BlogPost {
|
||||||
|
return new BlogPost({ ...this.props, deletedAt: new Date(), updatedAt: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore the post from the trash. */
|
||||||
|
restore(): BlogPost {
|
||||||
|
return new BlogPost({ ...this.props, deletedAt: null, updatedAt: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
publish(): BlogPost {
|
publish(): BlogPost {
|
||||||
return new BlogPost({
|
return new BlogPost({
|
||||||
...this.props,
|
...this.props,
|
||||||
@ -163,6 +223,7 @@ export class BlogPost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isVisibleToPublic(): boolean {
|
isVisibleToPublic(): boolean {
|
||||||
|
if (this.props.deletedAt) return false;
|
||||||
if (this.props.status === BlogPostStatus.PUBLISHED) return true;
|
if (this.props.status === BlogPostStatus.PUBLISHED) return true;
|
||||||
if (
|
if (
|
||||||
this.props.status === BlogPostStatus.SCHEDULED &&
|
this.props.status === BlogPostStatus.SCHEDULED &&
|
||||||
|
|||||||
@ -191,7 +191,9 @@ describe('CsvBooking Entity', () => {
|
|||||||
}).toThrow('Invalid carrier email format');
|
}).toThrow('Invalid carrier email format');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error if no documents provided', () => {
|
it('should allow reconstituting a booking with no documents (all deleted before payment)', () => {
|
||||||
|
// A booking may end up with zero documents after the owner deletes them.
|
||||||
|
// Reconstitution must not fail (creation still requires >=1 doc at the service level).
|
||||||
expect(() => {
|
expect(() => {
|
||||||
new CsvBooking(
|
new CsvBooking(
|
||||||
'booking-123',
|
'booking-123',
|
||||||
@ -214,7 +216,7 @@ describe('CsvBooking Entity', () => {
|
|||||||
'token-abc123',
|
'token-abc123',
|
||||||
new Date()
|
new Date()
|
||||||
);
|
);
|
||||||
}).toThrow('At least one document is required for booking');
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -484,4 +486,24 @@ describe('CsvBooking Entity', () => {
|
|||||||
expect(responseTime).toBeLessThan(1); // Should be less than 1 hour for this test
|
expect(responseTime).toBeLessThan(1); // Should be less than 1 hour for this test
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('applyBookingFee', () => {
|
||||||
|
it('should set a flat per-booking fee and clear the percentage rate', () => {
|
||||||
|
const booking = createValidBooking();
|
||||||
|
|
||||||
|
booking.applyBookingFee(10);
|
||||||
|
|
||||||
|
expect(booking.commissionAmountEur).toBe(10);
|
||||||
|
expect(booking.commissionRate).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store no fee when the plan fee is custom or zero', () => {
|
||||||
|
const booking = createValidBooking();
|
||||||
|
|
||||||
|
booking.applyBookingFee(-1);
|
||||||
|
|
||||||
|
expect(booking.commissionAmountEur).toBe(0);
|
||||||
|
expect(booking.commissionRate).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -63,18 +63,22 @@ export class CsvBooking {
|
|||||||
public readonly id: string,
|
public readonly id: string,
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly organizationId: string,
|
public readonly organizationId: string,
|
||||||
public readonly carrierName: string,
|
// Carrier + route + transit + container — editable before payment when the
|
||||||
public readonly carrierEmail: string,
|
// user re-runs the search and picks another rate (see editFromRate).
|
||||||
public readonly origin: PortCode,
|
public carrierName: string,
|
||||||
public readonly destination: PortCode,
|
public carrierEmail: string,
|
||||||
public readonly volumeCBM: number,
|
public origin: PortCode,
|
||||||
public readonly weightKG: number,
|
public destination: PortCode,
|
||||||
public readonly palletCount: number,
|
// Cargo characteristics — editable before payment (see editDetails).
|
||||||
public readonly priceUSD: number,
|
public volumeCBM: number,
|
||||||
public readonly priceEUR: number,
|
public weightKG: number,
|
||||||
public readonly primaryCurrency: string,
|
public palletCount: number,
|
||||||
public readonly transitDays: number,
|
// Pricing — recomputed when cargo details change before payment (see editDetails).
|
||||||
public readonly containerType: string,
|
public priceUSD: number,
|
||||||
|
public priceEUR: number,
|
||||||
|
public primaryCurrency: string,
|
||||||
|
public transitDays: number,
|
||||||
|
public containerType: string,
|
||||||
public status: CsvBookingStatus,
|
public status: CsvBookingStatus,
|
||||||
public readonly documents: CsvBookingDocument[],
|
public readonly documents: CsvBookingDocument[],
|
||||||
public readonly confirmationToken: string,
|
public readonly confirmationToken: string,
|
||||||
@ -85,7 +89,16 @@ export class CsvBooking {
|
|||||||
public readonly bookingNumber?: string,
|
public readonly bookingNumber?: string,
|
||||||
public commissionRate?: number,
|
public commissionRate?: number,
|
||||||
public commissionAmountEur?: number,
|
public commissionAmountEur?: number,
|
||||||
public stripePaymentIntentId?: string
|
public stripePaymentIntentId?: string,
|
||||||
|
// Detailed transport cost breakdown (informational — paid to the carrier,
|
||||||
|
// not collected by Xpeditis). Freight and FOB may be in different currencies.
|
||||||
|
public freightTotal?: number,
|
||||||
|
public freightCurrency?: string,
|
||||||
|
public fobTotal?: number,
|
||||||
|
public fobCurrency?: string,
|
||||||
|
// Options & services selected in the search form (customs, insurance, DG,
|
||||||
|
// handling…). Stored as a flat map of enabled flags. Editable before payment.
|
||||||
|
public options: Record<string, boolean> = {}
|
||||||
) {
|
) {
|
||||||
this.validate();
|
this.validate();
|
||||||
}
|
}
|
||||||
@ -144,19 +157,34 @@ export class CsvBooking {
|
|||||||
throw new Error('Confirmation token is required');
|
throw new Error('Confirmation token is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.documents || this.documents.length === 0) {
|
// Note: at least one document is required *at creation* (enforced in the
|
||||||
throw new Error('At least one document is required for booking');
|
// create flow). A booking may end up with zero documents after the owner
|
||||||
}
|
// deletes them, so reconstitution must not fail here.
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply commission to the booking
|
* Apply commission to the booking
|
||||||
|
*
|
||||||
|
* @deprecated Commission has been replaced by a flat per-booking fee.
|
||||||
|
* Use {@link applyBookingFee} instead.
|
||||||
*/
|
*/
|
||||||
applyCommission(ratePercent: number, baseAmountEur: number): void {
|
applyCommission(ratePercent: number, baseAmountEur: number): void {
|
||||||
this.commissionRate = ratePercent;
|
this.commissionRate = ratePercent;
|
||||||
this.commissionAmountEur = Math.round(baseAmountEur * ratePercent) / 100;
|
this.commissionAmountEur = Math.round(baseAmountEur * ratePercent) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the flat per-booking service fee (forfait par booking).
|
||||||
|
*
|
||||||
|
* Replaces the percentage-based commission: the amount is the subscription
|
||||||
|
* plan's fixed booking fee, so the percentage rate is cleared. A fee <= 0
|
||||||
|
* (e.g. Platinium "sur mesure") means no automatic charge.
|
||||||
|
*/
|
||||||
|
applyBookingFee(feeEur: number): void {
|
||||||
|
this.commissionRate = undefined;
|
||||||
|
this.commissionAmountEur = feeEur > 0 ? feeEur : 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark commission payment as completed → transition to PENDING
|
* Mark commission payment as completed → transition to PENDING
|
||||||
*
|
*
|
||||||
@ -270,6 +298,145 @@ export class CsvBooking {
|
|||||||
this.respondedAt = new Date();
|
this.respondedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit the cargo details of a booking before it is paid.
|
||||||
|
*
|
||||||
|
* Only allowed while the booking is awaiting payment (PENDING_PAYMENT), i.e.
|
||||||
|
* before it is sent to the carrier. Carrier, route and price derive from the
|
||||||
|
* selected rate and are not editable here.
|
||||||
|
*
|
||||||
|
* @throws Error if the booking is not in PENDING_PAYMENT status or values are invalid
|
||||||
|
*/
|
||||||
|
editDetails(details: {
|
||||||
|
volumeCBM?: number;
|
||||||
|
weightKG?: number;
|
||||||
|
palletCount?: number;
|
||||||
|
notes?: string;
|
||||||
|
pricing?: {
|
||||||
|
priceUSD?: number;
|
||||||
|
priceEUR?: number;
|
||||||
|
primaryCurrency?: string;
|
||||||
|
freightTotal?: number;
|
||||||
|
freightCurrency?: string;
|
||||||
|
fobTotal?: number;
|
||||||
|
fobCurrency?: string;
|
||||||
|
};
|
||||||
|
}): void {
|
||||||
|
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot edit booking with status ${this.status}. Only PENDING_PAYMENT bookings can be edited.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.volumeCBM !== undefined) {
|
||||||
|
if (details.volumeCBM <= 0) {
|
||||||
|
throw new Error('Volume must be positive');
|
||||||
|
}
|
||||||
|
this.volumeCBM = details.volumeCBM;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.weightKG !== undefined) {
|
||||||
|
if (details.weightKG <= 0) {
|
||||||
|
throw new Error('Weight must be positive');
|
||||||
|
}
|
||||||
|
this.weightKG = details.weightKG;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.palletCount !== undefined) {
|
||||||
|
if (details.palletCount < 0) {
|
||||||
|
throw new Error('Pallet count cannot be negative');
|
||||||
|
}
|
||||||
|
this.palletCount = details.palletCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.notes !== undefined) {
|
||||||
|
this.notes = details.notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pricing is recomputed from the current rate when cargo details change.
|
||||||
|
const p = details.pricing;
|
||||||
|
if (p) {
|
||||||
|
if (p.priceUSD !== undefined) {
|
||||||
|
if (p.priceUSD < 0) throw new Error('Price cannot be negative');
|
||||||
|
this.priceUSD = p.priceUSD;
|
||||||
|
}
|
||||||
|
if (p.priceEUR !== undefined) {
|
||||||
|
if (p.priceEUR < 0) throw new Error('Price cannot be negative');
|
||||||
|
this.priceEUR = p.priceEUR;
|
||||||
|
}
|
||||||
|
if (p.primaryCurrency !== undefined) this.primaryCurrency = p.primaryCurrency;
|
||||||
|
if (p.freightTotal !== undefined) this.freightTotal = p.freightTotal;
|
||||||
|
if (p.freightCurrency !== undefined) this.freightCurrency = p.freightCurrency;
|
||||||
|
if (p.fobTotal !== undefined) this.fobTotal = p.fobTotal;
|
||||||
|
if (p.fobCurrency !== undefined) this.fobCurrency = p.fobCurrency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-apply a full rate selection before payment: the user re-ran the search
|
||||||
|
* and picked a rate, so carrier, route, container, transit, cargo and price
|
||||||
|
* are all replaced. Only allowed while the booking is PENDING_PAYMENT.
|
||||||
|
*
|
||||||
|
* @throws Error if the booking is not PENDING_PAYMENT or values are invalid
|
||||||
|
*/
|
||||||
|
editFromRate(data: {
|
||||||
|
carrierName: string;
|
||||||
|
carrierEmail: string;
|
||||||
|
origin: PortCode;
|
||||||
|
destination: PortCode;
|
||||||
|
containerType: string;
|
||||||
|
transitDays: number;
|
||||||
|
volumeCBM: number;
|
||||||
|
weightKG: number;
|
||||||
|
palletCount: number;
|
||||||
|
priceUSD: number;
|
||||||
|
priceEUR: number;
|
||||||
|
primaryCurrency: string;
|
||||||
|
freightTotal?: number;
|
||||||
|
freightCurrency?: string;
|
||||||
|
fobTotal?: number;
|
||||||
|
fobCurrency?: string;
|
||||||
|
notes?: string;
|
||||||
|
options?: Record<string, boolean>;
|
||||||
|
}): void {
|
||||||
|
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot edit booking with status ${this.status}. Only PENDING_PAYMENT bookings can be edited.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data.carrierName || data.carrierName.trim().length === 0) {
|
||||||
|
throw new Error('Carrier name is required');
|
||||||
|
}
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!data.carrierEmail || !emailRegex.test(data.carrierEmail)) {
|
||||||
|
throw new Error('Invalid carrier email format');
|
||||||
|
}
|
||||||
|
if (data.volumeCBM <= 0) throw new Error('Volume must be positive');
|
||||||
|
if (data.weightKG <= 0) throw new Error('Weight must be positive');
|
||||||
|
if (data.palletCount < 0) throw new Error('Pallet count cannot be negative');
|
||||||
|
if (data.transitDays <= 0) throw new Error('Transit days must be positive');
|
||||||
|
if (data.priceUSD < 0 || data.priceEUR < 0) throw new Error('Price cannot be negative');
|
||||||
|
|
||||||
|
this.carrierName = data.carrierName;
|
||||||
|
this.carrierEmail = data.carrierEmail;
|
||||||
|
this.origin = data.origin;
|
||||||
|
this.destination = data.destination;
|
||||||
|
this.containerType = data.containerType;
|
||||||
|
this.transitDays = data.transitDays;
|
||||||
|
this.volumeCBM = data.volumeCBM;
|
||||||
|
this.weightKG = data.weightKG;
|
||||||
|
this.palletCount = data.palletCount;
|
||||||
|
this.priceUSD = data.priceUSD;
|
||||||
|
this.priceEUR = data.priceEUR;
|
||||||
|
this.primaryCurrency = data.primaryCurrency;
|
||||||
|
this.freightTotal = data.freightTotal;
|
||||||
|
this.freightCurrency = data.freightCurrency;
|
||||||
|
this.fobTotal = data.fobTotal;
|
||||||
|
this.fobCurrency = data.fobCurrency;
|
||||||
|
if (data.notes !== undefined) this.notes = data.notes;
|
||||||
|
if (data.options !== undefined) this.options = data.options;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if booking has expired (7 days without response)
|
* Check if booking has expired (7 days without response)
|
||||||
*
|
*
|
||||||
@ -434,7 +601,12 @@ export class CsvBooking {
|
|||||||
bookingNumber?: string,
|
bookingNumber?: string,
|
||||||
commissionRate?: number,
|
commissionRate?: number,
|
||||||
commissionAmountEur?: number,
|
commissionAmountEur?: number,
|
||||||
stripePaymentIntentId?: string
|
stripePaymentIntentId?: string,
|
||||||
|
freightTotal?: number,
|
||||||
|
freightCurrency?: string,
|
||||||
|
fobTotal?: number,
|
||||||
|
fobCurrency?: string,
|
||||||
|
options?: Record<string, boolean>
|
||||||
): CsvBooking {
|
): CsvBooking {
|
||||||
// Create instance without calling constructor validation
|
// Create instance without calling constructor validation
|
||||||
const booking = Object.create(CsvBooking.prototype);
|
const booking = Object.create(CsvBooking.prototype);
|
||||||
@ -466,6 +638,11 @@ export class CsvBooking {
|
|||||||
booking.commissionRate = commissionRate;
|
booking.commissionRate = commissionRate;
|
||||||
booking.commissionAmountEur = commissionAmountEur;
|
booking.commissionAmountEur = commissionAmountEur;
|
||||||
booking.stripePaymentIntentId = stripePaymentIntentId;
|
booking.stripePaymentIntentId = stripePaymentIntentId;
|
||||||
|
booking.freightTotal = freightTotal;
|
||||||
|
booking.freightCurrency = freightCurrency;
|
||||||
|
booking.fobTotal = fobTotal;
|
||||||
|
booking.fobCurrency = fobCurrency;
|
||||||
|
booking.options = options ?? {};
|
||||||
|
|
||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -80,10 +80,10 @@ export class Subscription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the commission rate for this subscription's plan
|
* Get the per-booking fee for this subscription's plan
|
||||||
*/
|
*/
|
||||||
get commissionRatePercent(): number {
|
get bookingFeeEur(): number {
|
||||||
return this.props.plan.commissionRatePercent;
|
return this.props.plan.bookingFeeEur;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -11,6 +11,8 @@ export interface BlogPostFilters {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
/** When true, also include SCHEDULED posts whose publishedAt <= now */
|
/** When true, also include SCHEDULED posts whose publishedAt <= now */
|
||||||
includeScheduled?: boolean;
|
includeScheduled?: boolean;
|
||||||
|
/** When true, return only trashed posts; otherwise trashed posts are excluded. */
|
||||||
|
trashed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlogPostRepository {
|
export interface BlogPostRepository {
|
||||||
|
|||||||
@ -12,4 +12,14 @@ export interface ShipmentCounterPort {
|
|||||||
* Count all shipments (bookings + CSV bookings) created by an organization in a given year.
|
* Count all shipments (bookings + CSV bookings) created by an organization in a given year.
|
||||||
*/
|
*/
|
||||||
countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise<number>;
|
countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count only PAID shipments (fee paid / payment declared / accepted) created by
|
||||||
|
* an organization in a given year. Unpaid drafts (PENDING_PAYMENT), rejected and
|
||||||
|
* cancelled bookings are excluded.
|
||||||
|
*/
|
||||||
|
countPaidShipmentsForOrganizationInYear(
|
||||||
|
organizationId: string,
|
||||||
|
year: number
|
||||||
|
): Promise<number>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -233,8 +233,30 @@ describe('SubscriptionPlan Value Object', () => {
|
|||||||
|
|
||||||
it('should return correct prices for SILVER plan', () => {
|
it('should return correct prices for SILVER plan', () => {
|
||||||
const plan = SubscriptionPlan.silver();
|
const plan = SubscriptionPlan.silver();
|
||||||
expect(plan.monthlyPriceEur).toBe(249);
|
expect(plan.monthlyPriceEur).toBe(299);
|
||||||
expect(plan.yearlyPriceEur).toBe(2739);
|
// Annual billing = 11 months (1 month deducted)
|
||||||
|
expect(plan.yearlyPriceEur).toBe(plan.monthlyPriceEur * 11);
|
||||||
|
expect(plan.yearlyPriceEur).toBe(3289);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should bill the yearly price over 11 months for GOLD plan', () => {
|
||||||
|
const plan = SubscriptionPlan.gold();
|
||||||
|
expect(plan.monthlyPriceEur).toBe(799);
|
||||||
|
expect(plan.yearlyPriceEur).toBe(plan.monthlyPriceEur * 11);
|
||||||
|
expect(plan.yearlyPriceEur).toBe(8789);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should limit BRONZE to 5 bookings per year', () => {
|
||||||
|
expect(SubscriptionPlan.bronze().maxShipmentsPerYear).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expose per-booking fees per plan', () => {
|
||||||
|
expect(SubscriptionPlan.bronze().bookingFeeEur).toBe(15);
|
||||||
|
expect(SubscriptionPlan.silver().bookingFeeEur).toBe(10);
|
||||||
|
expect(SubscriptionPlan.gold().bookingFeeEur).toBe(5);
|
||||||
|
expect(SubscriptionPlan.platinium().bookingFeeEur).toBe(-1);
|
||||||
|
expect(SubscriptionPlan.platinium().hasCustomBookingFee()).toBe(true);
|
||||||
|
expect(SubscriptionPlan.gold().hasCustomBookingFee()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return features for GOLD plan', () => {
|
it('should return features for GOLD plan', () => {
|
||||||
|
|||||||
@ -2,10 +2,16 @@
|
|||||||
* Subscription Plan Value Object
|
* Subscription Plan Value Object
|
||||||
*
|
*
|
||||||
* Represents the different subscription plans available for organizations.
|
* Represents the different subscription plans available for organizations.
|
||||||
* Each plan has a maximum number of licenses, shipment limits, commission rates,
|
* Each plan has a maximum number of licenses, shipment limits, a per-booking fee,
|
||||||
* feature flags, and support levels.
|
* feature flags, and support levels.
|
||||||
*
|
*
|
||||||
* Plans: BRONZE (free), SILVER (249EUR/mo), GOLD (899EUR/mo), PLATINIUM (custom)
|
* Plans: BRONZE (free), SILVER (299EUR/mo), GOLD (799EUR/mo), PLATINIUM (custom)
|
||||||
|
*
|
||||||
|
* Annual billing: one month is deducted, so the yearly price is billed over
|
||||||
|
* 11 months (yearlyPriceEur = monthlyPriceEur * 11).
|
||||||
|
*
|
||||||
|
* Each plan also has a per-booking fee (bookingFeeEur). For BRONZE it applies to
|
||||||
|
* bookings beyond the yearly limit ("scaling"); for PLATINIUM it is custom (-1).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo';
|
import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo';
|
||||||
@ -29,9 +35,9 @@ interface PlanDetails {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly maxLicenses: number; // -1 means unlimited
|
readonly maxLicenses: number; // -1 means unlimited
|
||||||
readonly monthlyPriceEur: number;
|
readonly monthlyPriceEur: number;
|
||||||
readonly yearlyPriceEur: number;
|
readonly yearlyPriceEur: number; // monthlyPriceEur * 11 (annual billing = 11 months)
|
||||||
readonly maxShipmentsPerYear: number; // -1 means unlimited
|
readonly maxShipmentsPerYear: number; // -1 means unlimited
|
||||||
readonly commissionRatePercent: number;
|
readonly bookingFeeEur: number; // per-booking fee; -1 means custom (on quote)
|
||||||
readonly statusBadge: StatusBadge;
|
readonly statusBadge: StatusBadge;
|
||||||
readonly supportLevel: SupportLevel;
|
readonly supportLevel: SupportLevel;
|
||||||
readonly planFeatures: readonly PlanFeature[];
|
readonly planFeatures: readonly PlanFeature[];
|
||||||
@ -44,20 +50,25 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
|
|||||||
maxLicenses: 1,
|
maxLicenses: 1,
|
||||||
monthlyPriceEur: 0,
|
monthlyPriceEur: 0,
|
||||||
yearlyPriceEur: 0,
|
yearlyPriceEur: 0,
|
||||||
maxShipmentsPerYear: 12,
|
maxShipmentsPerYear: 5,
|
||||||
commissionRatePercent: 5,
|
bookingFeeEur: 15,
|
||||||
statusBadge: 'none',
|
statusBadge: 'none',
|
||||||
supportLevel: 'none',
|
supportLevel: 'none',
|
||||||
planFeatures: PLAN_FEATURES.BRONZE,
|
planFeatures: PLAN_FEATURES.BRONZE,
|
||||||
features: ['1 utilisateur', '12 expéditions par an', 'Recherche de tarifs basique'],
|
features: [
|
||||||
|
'1 utilisateur',
|
||||||
|
'5 bookings par an',
|
||||||
|
'Recherche de tarifs basique',
|
||||||
|
'Frais de booking pour scaler : 15 €',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
SILVER: {
|
SILVER: {
|
||||||
name: 'Silver',
|
name: 'Silver',
|
||||||
maxLicenses: 5,
|
maxLicenses: 5,
|
||||||
monthlyPriceEur: 249,
|
monthlyPriceEur: 299,
|
||||||
yearlyPriceEur: 2739,
|
yearlyPriceEur: 3289,
|
||||||
maxShipmentsPerYear: -1,
|
maxShipmentsPerYear: -1,
|
||||||
commissionRatePercent: 3,
|
bookingFeeEur: 10,
|
||||||
statusBadge: 'silver',
|
statusBadge: 'silver',
|
||||||
supportLevel: 'email',
|
supportLevel: 'email',
|
||||||
planFeatures: PLAN_FEATURES.SILVER,
|
planFeatures: PLAN_FEATURES.SILVER,
|
||||||
@ -69,15 +80,16 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
|
|||||||
'Gestion des utilisateurs',
|
'Gestion des utilisateurs',
|
||||||
'Import CSV',
|
'Import CSV',
|
||||||
'Support par email',
|
'Support par email',
|
||||||
|
'Forfait par booking : 10 €',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
GOLD: {
|
GOLD: {
|
||||||
name: 'Gold',
|
name: 'Gold',
|
||||||
maxLicenses: 20,
|
maxLicenses: 20,
|
||||||
monthlyPriceEur: 899,
|
monthlyPriceEur: 799,
|
||||||
yearlyPriceEur: 9889,
|
yearlyPriceEur: 8789,
|
||||||
maxShipmentsPerYear: -1,
|
maxShipmentsPerYear: -1,
|
||||||
commissionRatePercent: 2,
|
bookingFeeEur: 5,
|
||||||
statusBadge: 'gold',
|
statusBadge: 'gold',
|
||||||
supportLevel: 'direct',
|
supportLevel: 'direct',
|
||||||
planFeatures: PLAN_FEATURES.GOLD,
|
planFeatures: PLAN_FEATURES.GOLD,
|
||||||
@ -87,6 +99,7 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
|
|||||||
'Toutes les fonctionnalités Silver',
|
'Toutes les fonctionnalités Silver',
|
||||||
'Intégration API',
|
'Intégration API',
|
||||||
'Assistance commerciale directe',
|
'Assistance commerciale directe',
|
||||||
|
'Forfait par booking : 5 €',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
PLATINIUM: {
|
PLATINIUM: {
|
||||||
@ -95,7 +108,7 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
|
|||||||
monthlyPriceEur: 0, // custom pricing
|
monthlyPriceEur: 0, // custom pricing
|
||||||
yearlyPriceEur: 0, // custom pricing
|
yearlyPriceEur: 0, // custom pricing
|
||||||
maxShipmentsPerYear: -1,
|
maxShipmentsPerYear: -1,
|
||||||
commissionRatePercent: 1,
|
bookingFeeEur: -1, // custom / on quote
|
||||||
statusBadge: 'platinium',
|
statusBadge: 'platinium',
|
||||||
supportLevel: 'dedicated_kam',
|
supportLevel: 'dedicated_kam',
|
||||||
planFeatures: PLAN_FEATURES.PLATINIUM,
|
planFeatures: PLAN_FEATURES.PLATINIUM,
|
||||||
@ -105,6 +118,7 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
|
|||||||
'Key Account Manager dédié',
|
'Key Account Manager dédié',
|
||||||
'Interface personnalisable',
|
'Interface personnalisable',
|
||||||
'Contrats tarifaires cadre',
|
'Contrats tarifaires cadre',
|
||||||
|
'Forfait par booking : sur mesure',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -209,8 +223,12 @@ export class SubscriptionPlan {
|
|||||||
return PLAN_DETAILS[this.plan].maxShipmentsPerYear;
|
return PLAN_DETAILS[this.plan].maxShipmentsPerYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
get commissionRatePercent(): number {
|
get bookingFeeEur(): number {
|
||||||
return PLAN_DETAILS[this.plan].commissionRatePercent;
|
return PLAN_DETAILS[this.plan].bookingFeeEur;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasCustomBookingFee(): boolean {
|
||||||
|
return PLAN_DETAILS[this.plan].bookingFeeEur === -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
get statusBadge(): StatusBadge {
|
get statusBadge(): StatusBadge {
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export class BlogPostOrmEntity {
|
|||||||
content: string;
|
content: string;
|
||||||
|
|
||||||
@Column('varchar', { length: 500, nullable: true })
|
@Column('varchar', { length: 500, nullable: true })
|
||||||
cover_image_url?: string;
|
cover_image_url?: string | null;
|
||||||
|
|
||||||
@Column('varchar', { length: 50 })
|
@Column('varchar', { length: 50 })
|
||||||
category: string;
|
category: string;
|
||||||
@ -52,6 +52,23 @@ export class BlogPostOrmEntity {
|
|||||||
@Column('jsonb', { default: [] })
|
@Column('jsonb', { default: [] })
|
||||||
secondary_keywords: string[];
|
secondary_keywords: string[];
|
||||||
|
|
||||||
|
// GEO (Generative Engine Optimisation) fields
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
ai_summary?: string | null;
|
||||||
|
|
||||||
|
@Column('jsonb', { default: [] })
|
||||||
|
faq: { question: string; answer: string }[];
|
||||||
|
|
||||||
|
@Column('jsonb', { default: [] })
|
||||||
|
key_takeaways: string[];
|
||||||
|
|
||||||
|
@Column('jsonb', { default: [] })
|
||||||
|
ai_entities: string[];
|
||||||
|
|
||||||
|
// Soft delete (trash)
|
||||||
|
@Column('timestamp', { nullable: true })
|
||||||
|
deleted_at?: Date | null;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
|
|
||||||
|
|||||||
@ -169,6 +169,23 @@ export class CsvBookingOrmEntity {
|
|||||||
})
|
})
|
||||||
commissionAmountEur: number | null;
|
commissionAmountEur: number | null;
|
||||||
|
|
||||||
|
// Transport cost breakdown (informational — paid to the carrier, not Xpeditis).
|
||||||
|
// Freight and FOB may be quoted in different currencies.
|
||||||
|
@Column({ name: 'freight_total', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
freightTotal: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'freight_currency', type: 'varchar', length: 3, nullable: true })
|
||||||
|
freightCurrency: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'fob_total', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
fobTotal: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'fob_currency', type: 'varchar', length: 3, nullable: true })
|
||||||
|
fobCurrency: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'options', type: 'jsonb', default: {} })
|
||||||
|
options: Record<string, boolean>;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@ -45,7 +45,12 @@ export class CsvBookingMapper {
|
|||||||
ormEntity.bookingNumber ?? undefined,
|
ormEntity.bookingNumber ?? undefined,
|
||||||
ormEntity.commissionRate != null ? Number(ormEntity.commissionRate) : undefined,
|
ormEntity.commissionRate != null ? Number(ormEntity.commissionRate) : undefined,
|
||||||
ormEntity.commissionAmountEur != null ? Number(ormEntity.commissionAmountEur) : undefined,
|
ormEntity.commissionAmountEur != null ? Number(ormEntity.commissionAmountEur) : undefined,
|
||||||
ormEntity.stripePaymentIntentId ?? undefined
|
ormEntity.stripePaymentIntentId ?? undefined,
|
||||||
|
ormEntity.freightTotal != null ? Number(ormEntity.freightTotal) : undefined,
|
||||||
|
ormEntity.freightCurrency ?? undefined,
|
||||||
|
ormEntity.fobTotal != null ? Number(ormEntity.fobTotal) : undefined,
|
||||||
|
ormEntity.fobCurrency ?? undefined,
|
||||||
|
ormEntity.options ?? {}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +84,11 @@ export class CsvBookingMapper {
|
|||||||
stripePaymentIntentId: domain.stripePaymentIntentId ?? null,
|
stripePaymentIntentId: domain.stripePaymentIntentId ?? null,
|
||||||
commissionRate: domain.commissionRate ?? null,
|
commissionRate: domain.commissionRate ?? null,
|
||||||
commissionAmountEur: domain.commissionAmountEur ?? null,
|
commissionAmountEur: domain.commissionAmountEur ?? null,
|
||||||
|
freightTotal: domain.freightTotal ?? null,
|
||||||
|
freightCurrency: domain.freightCurrency ?? null,
|
||||||
|
fobTotal: domain.fobTotal ?? null,
|
||||||
|
fobCurrency: domain.fobCurrency ?? null,
|
||||||
|
options: domain.options ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,6 +97,25 @@ export class CsvBookingMapper {
|
|||||||
*/
|
*/
|
||||||
static toOrmUpdate(domain: CsvBooking): Partial<CsvBookingOrmEntity> {
|
static toOrmUpdate(domain: CsvBooking): Partial<CsvBookingOrmEntity> {
|
||||||
return {
|
return {
|
||||||
|
// Carrier + route + cargo + price are editable before payment
|
||||||
|
// (editDetails / editFromRate), so they must be persisted on update.
|
||||||
|
carrierName: domain.carrierName,
|
||||||
|
carrierEmail: domain.carrierEmail,
|
||||||
|
origin: domain.origin.getValue(),
|
||||||
|
destination: domain.destination.getValue(),
|
||||||
|
containerType: domain.containerType,
|
||||||
|
transitDays: domain.transitDays,
|
||||||
|
volumeCBM: domain.volumeCBM,
|
||||||
|
weightKG: domain.weightKG,
|
||||||
|
palletCount: domain.palletCount,
|
||||||
|
priceUSD: domain.priceUSD,
|
||||||
|
priceEUR: domain.priceEUR,
|
||||||
|
primaryCurrency: domain.primaryCurrency,
|
||||||
|
freightTotal: domain.freightTotal ?? null,
|
||||||
|
freightCurrency: domain.freightCurrency ?? null,
|
||||||
|
fobTotal: domain.fobTotal ?? null,
|
||||||
|
fobCurrency: domain.fobCurrency ?? null,
|
||||||
|
options: domain.options ?? {},
|
||||||
status: domain.status as CsvBookingOrmEntity['status'],
|
status: domain.status as CsvBookingOrmEntity['status'],
|
||||||
respondedAt: domain.respondedAt,
|
respondedAt: domain.respondedAt,
|
||||||
notes: domain.notes,
|
notes: domain.notes,
|
||||||
|
|||||||
@ -0,0 +1,32 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the transport cost breakdown (freight + FOB) to csv_bookings.
|
||||||
|
*
|
||||||
|
* These amounts are informational: they are paid to the carrier, not collected
|
||||||
|
* by Xpeditis (which only charges the flat per-booking fee). Freight and FOB may
|
||||||
|
* be quoted in different currencies, so each amount keeps its own currency.
|
||||||
|
*/
|
||||||
|
export class AddFreightFobToCsvBookings1740000000003 implements MigrationInterface {
|
||||||
|
name = 'AddFreightFobToCsvBookings1740000000003';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "csv_bookings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "freight_total" DECIMAL(12,2),
|
||||||
|
ADD COLUMN IF NOT EXISTS "freight_currency" VARCHAR(3),
|
||||||
|
ADD COLUMN IF NOT EXISTS "fob_total" DECIMAL(12,2),
|
||||||
|
ADD COLUMN IF NOT EXISTS "fob_currency" VARCHAR(3)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "csv_bookings"
|
||||||
|
DROP COLUMN IF EXISTS "fob_currency",
|
||||||
|
DROP COLUMN IF EXISTS "fob_total",
|
||||||
|
DROP COLUMN IF EXISTS "freight_currency",
|
||||||
|
DROP COLUMN IF EXISTS "freight_total"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddGeoAndTrashToBlogPosts1748000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.addColumns('blog_posts', [
|
||||||
|
// GEO (Generative Engine Optimisation) fields
|
||||||
|
new TableColumn({
|
||||||
|
name: 'ai_summary',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
}),
|
||||||
|
new TableColumn({
|
||||||
|
name: 'faq',
|
||||||
|
type: 'jsonb',
|
||||||
|
isNullable: false,
|
||||||
|
default: "'[]'",
|
||||||
|
}),
|
||||||
|
new TableColumn({
|
||||||
|
name: 'key_takeaways',
|
||||||
|
type: 'jsonb',
|
||||||
|
isNullable: false,
|
||||||
|
default: "'[]'",
|
||||||
|
}),
|
||||||
|
new TableColumn({
|
||||||
|
name: 'ai_entities',
|
||||||
|
type: 'jsonb',
|
||||||
|
isNullable: false,
|
||||||
|
default: "'[]'",
|
||||||
|
}),
|
||||||
|
// Soft delete (trash)
|
||||||
|
new TableColumn({
|
||||||
|
name: 'deleted_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
isNullable: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropColumns('blog_posts', [
|
||||||
|
'ai_summary',
|
||||||
|
'faq',
|
||||||
|
'key_takeaways',
|
||||||
|
'ai_entities',
|
||||||
|
'deleted_at',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddOptionsToCsvBookings1749000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.addColumn(
|
||||||
|
'csv_bookings',
|
||||||
|
new TableColumn({
|
||||||
|
name: 'options',
|
||||||
|
type: 'jsonb',
|
||||||
|
isNullable: false,
|
||||||
|
default: "'{}'",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropColumn('csv_bookings', 'options');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,4 +29,24 @@ export class TypeOrmShipmentCounterRepository implements ShipmentCounterPort {
|
|||||||
.andWhere('csv_booking.created_at < :end', { end: startOfNextYear })
|
.andWhere('csv_booking.created_at < :end', { end: startOfNextYear })
|
||||||
.getCount();
|
.getCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async countPaidShipmentsForOrganizationInYear(
|
||||||
|
organizationId: string,
|
||||||
|
year: number
|
||||||
|
): Promise<number> {
|
||||||
|
const startOfYear = new Date(year, 0, 1);
|
||||||
|
const startOfNextYear = new Date(year + 1, 0, 1);
|
||||||
|
|
||||||
|
// "Paid" = payment completed / declared / accepted. Unpaid drafts
|
||||||
|
// (PENDING_PAYMENT), rejected and cancelled bookings do not count.
|
||||||
|
const PAID_STATUSES = ['PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED'];
|
||||||
|
|
||||||
|
return this.csvBookingRepository
|
||||||
|
.createQueryBuilder('csv_booking')
|
||||||
|
.where('csv_booking.organization_id = :organizationId', { organizationId })
|
||||||
|
.andWhere('csv_booking.created_at >= :start', { start: startOfYear })
|
||||||
|
.andWhere('csv_booking.created_at < :end', { end: startOfNextYear })
|
||||||
|
.andWhere('csv_booking.status IN (:...statuses)', { statuses: PAID_STATUSES })
|
||||||
|
.getCount();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,12 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository {
|
|||||||
async findByFilters(filters: BlogPostFilters): Promise<BlogPost[]> {
|
async findByFilters(filters: BlogPostFilters): Promise<BlogPost[]> {
|
||||||
const query = this.ormRepository.createQueryBuilder('post');
|
const query = this.ormRepository.createQueryBuilder('post');
|
||||||
|
|
||||||
|
if (filters.trashed) {
|
||||||
|
query.andWhere('post.deleted_at IS NOT NULL');
|
||||||
|
} else {
|
||||||
|
query.andWhere('post.deleted_at IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) {
|
if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) {
|
||||||
query.andWhere(
|
query.andWhere(
|
||||||
"(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))"
|
"(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))"
|
||||||
@ -65,6 +71,12 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository {
|
|||||||
async count(filters: BlogPostFilters): Promise<number> {
|
async count(filters: BlogPostFilters): Promise<number> {
|
||||||
const query = this.ormRepository.createQueryBuilder('post');
|
const query = this.ormRepository.createQueryBuilder('post');
|
||||||
|
|
||||||
|
if (filters.trashed) {
|
||||||
|
query.andWhere('post.deleted_at IS NOT NULL');
|
||||||
|
} else {
|
||||||
|
query.andWhere('post.deleted_at IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) {
|
if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) {
|
||||||
query.andWhere(
|
query.andWhere(
|
||||||
"(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))"
|
"(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))"
|
||||||
@ -117,6 +129,11 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository {
|
|||||||
metaDescription: orm.meta_description,
|
metaDescription: orm.meta_description,
|
||||||
primaryKeyword: orm.primary_keyword,
|
primaryKeyword: orm.primary_keyword,
|
||||||
secondaryKeywords: orm.secondary_keywords ?? [],
|
secondaryKeywords: orm.secondary_keywords ?? [],
|
||||||
|
aiSummary: orm.ai_summary,
|
||||||
|
faq: orm.faq ?? [],
|
||||||
|
keyTakeaways: orm.key_takeaways ?? [],
|
||||||
|
aiEntities: orm.ai_entities ?? [],
|
||||||
|
deletedAt: orm.deleted_at,
|
||||||
createdAt: orm.created_at,
|
createdAt: orm.created_at,
|
||||||
updatedAt: orm.updated_at,
|
updatedAt: orm.updated_at,
|
||||||
});
|
});
|
||||||
@ -140,6 +157,11 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository {
|
|||||||
orm.meta_description = post.metaDescription;
|
orm.meta_description = post.metaDescription;
|
||||||
orm.primary_keyword = post.primaryKeyword;
|
orm.primary_keyword = post.primaryKeyword;
|
||||||
orm.secondary_keywords = post.secondaryKeywords;
|
orm.secondary_keywords = post.secondaryKeywords;
|
||||||
|
orm.ai_summary = post.aiSummary;
|
||||||
|
orm.faq = post.faq;
|
||||||
|
orm.key_takeaways = post.keyTakeaways;
|
||||||
|
orm.ai_entities = post.aiEntities;
|
||||||
|
orm.deleted_at = post.deletedAt;
|
||||||
orm.created_at = post.createdAt;
|
orm.created_at = post.createdAt;
|
||||||
orm.updated_at = post.updatedAt;
|
orm.updated_at = post.updatedAt;
|
||||||
return orm;
|
return orm;
|
||||||
|
|||||||
@ -192,7 +192,8 @@ export function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: bool
|
|||||||
// sites (cross-origin), otherwise the browser drops the auth cookies set in
|
// sites (cross-origin), otherwise the browser drops the auth cookies set in
|
||||||
// the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS).
|
// the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS).
|
||||||
// Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups.
|
// Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups.
|
||||||
const sameSite = (process.env.COOKIE_SAMESITE?.toLowerCase() as 'lax' | 'strict' | 'none') || 'lax';
|
const sameSite =
|
||||||
|
(process.env.COOKIE_SAMESITE?.toLowerCase() as 'lax' | 'strict' | 'none') || 'lax';
|
||||||
const secure = process.env.NODE_ENV === 'production' || sameSite === 'none';
|
const secure = process.env.NODE_ENV === 'production' || sameSite === 'none';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -138,7 +138,7 @@ export class StripeAdapter implements StripePort {
|
|||||||
currency: input.currency,
|
currency: input.currency,
|
||||||
unit_amount: input.amountCents,
|
unit_amount: input.amountCents,
|
||||||
product_data: {
|
product_data: {
|
||||||
name: 'Commission Xpeditis',
|
name: 'Frais de booking Xpeditis',
|
||||||
description: input.bookingDescription,
|
description: input.bookingDescription,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6,11 +6,14 @@ import {
|
|||||||
createBlogPost,
|
createBlogPost,
|
||||||
updateBlogPost,
|
updateBlogPost,
|
||||||
deleteBlogPost,
|
deleteBlogPost,
|
||||||
|
restoreBlogPost,
|
||||||
|
permanentlyDeleteBlogPost,
|
||||||
|
duplicateBlogPost,
|
||||||
type CreateBlogPostRequest,
|
type CreateBlogPostRequest,
|
||||||
type UpdateBlogPostRequest,
|
type UpdateBlogPostRequest,
|
||||||
} from '@/lib/api/admin';
|
} from '@/lib/api/admin';
|
||||||
import { upload } from '@/lib/api/client';
|
import { upload } from '@/lib/api/client';
|
||||||
import type { BlogPost, BlogPostCategory, BlogPostStatus } from '@/lib/api/blog';
|
import type { BlogPost, BlogPostCategory, BlogPostStatus, BlogFaqItem } from '@/lib/api/blog';
|
||||||
import { PageHeader } from '@/components/ui/PageHeader';
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
import { RichTextEditor } from '@/components/blog/RichTextEditor';
|
import { RichTextEditor } from '@/components/blog/RichTextEditor';
|
||||||
import {
|
import {
|
||||||
@ -29,6 +32,10 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
ArrowLeft,
|
||||||
|
Copy,
|
||||||
|
RotateCcw,
|
||||||
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
|
|
||||||
@ -70,6 +77,11 @@ interface FormData extends CreateBlogPostRequest {
|
|||||||
metaDescription: string;
|
metaDescription: string;
|
||||||
primaryKeyword: string;
|
primaryKeyword: string;
|
||||||
secondaryKeywordsInput: string;
|
secondaryKeywordsInput: string;
|
||||||
|
// GEO / AI optimisation
|
||||||
|
aiSummary: string;
|
||||||
|
faqItems: BlogFaqItem[];
|
||||||
|
keyTakeawaysInput: string;
|
||||||
|
aiEntitiesInput: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_FORM: FormData = {
|
const EMPTY_FORM: FormData = {
|
||||||
@ -85,6 +97,10 @@ const EMPTY_FORM: FormData = {
|
|||||||
metaDescription: '',
|
metaDescription: '',
|
||||||
primaryKeyword: '',
|
primaryKeyword: '',
|
||||||
secondaryKeywordsInput: '',
|
secondaryKeywordsInput: '',
|
||||||
|
aiSummary: '',
|
||||||
|
faqItems: [],
|
||||||
|
keyTakeawaysInput: '',
|
||||||
|
aiEntitiesInput: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
function generateSlug(title: string): string {
|
function generateSlug(title: string): string {
|
||||||
@ -144,16 +160,23 @@ export default function AdminBlogPage() {
|
|||||||
const [formData, setFormData] = useState<FormData>(EMPTY_FORM);
|
const [formData, setFormData] = useState<FormData>(EMPTY_FORM);
|
||||||
const [scheduledAt, setScheduledAt] = useState('');
|
const [scheduledAt, setScheduledAt] = useState('');
|
||||||
const [seoOpen, setSeoOpen] = useState(false);
|
const [seoOpen, setSeoOpen] = useState(false);
|
||||||
|
const [geoOpen, setGeoOpen] = useState(false);
|
||||||
|
const [isDraggingCover, setIsDraggingCover] = useState(false);
|
||||||
|
const [viewFilter, setViewFilter] = useState<'all' | 'published' | 'draft' | 'scheduled' | 'trash'>(
|
||||||
|
'all'
|
||||||
|
);
|
||||||
|
const [actioningId, setActioningId] = useState<string | null>(null);
|
||||||
const coverInputRef = useRef<HTMLInputElement>(null);
|
const coverInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPosts();
|
fetchPosts();
|
||||||
}, []);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [viewFilter]);
|
||||||
|
|
||||||
const fetchPosts = async () => {
|
const fetchPosts = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await getAllBlogPosts();
|
const res = await getAllBlogPosts({ trashed: viewFilter === 'trash' });
|
||||||
setPosts(res.posts);
|
setPosts(res.posts);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -163,6 +186,12 @@ export default function AdminBlogPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Client-side status filtering for the non-trash views (posts are already loaded).
|
||||||
|
const visiblePosts =
|
||||||
|
viewFilter === 'all' || viewFilter === 'trash'
|
||||||
|
? posts
|
||||||
|
: posts.filter(p => p.status === viewFilter);
|
||||||
|
|
||||||
const buildPayload = (withStatus?: BlogPostStatus): CreateBlogPostRequest => {
|
const buildPayload = (withStatus?: BlogPostStatus): CreateBlogPostRequest => {
|
||||||
const tags = tagsInput
|
const tags = tagsInput
|
||||||
? tagsInput
|
? tagsInput
|
||||||
@ -176,12 +205,29 @@ export default function AdminBlogPage() {
|
|||||||
.map(t => t.trim())
|
.map(t => t.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
const keyTakeaways = formData.keyTakeawaysInput
|
||||||
|
? formData.keyTakeawaysInput
|
||||||
|
.split('\n')
|
||||||
|
.map(t => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const aiEntities = formData.aiEntitiesInput
|
||||||
|
? formData.aiEntitiesInput
|
||||||
|
.split(',')
|
||||||
|
.map(t => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const faq = formData.faqItems
|
||||||
|
.map(item => ({ question: item.question.trim(), answer: item.answer.trim() }))
|
||||||
|
.filter(item => item.question && item.answer);
|
||||||
return {
|
return {
|
||||||
title: formData.title,
|
title: formData.title,
|
||||||
slug: formData.slug,
|
slug: formData.slug,
|
||||||
excerpt: formData.excerpt,
|
excerpt: formData.excerpt,
|
||||||
content: formData.content,
|
content: formData.content,
|
||||||
coverImageUrl: formData.coverImageUrl || undefined,
|
// Send explicit null (not undefined) when the cover was removed, so the
|
||||||
|
// backend clears the column instead of leaving the previous value.
|
||||||
|
coverImageUrl: formData.coverImageUrl?.trim() ? formData.coverImageUrl : null,
|
||||||
category: formData.category,
|
category: formData.category,
|
||||||
tags,
|
tags,
|
||||||
authorName: formData.authorName,
|
authorName: formData.authorName,
|
||||||
@ -189,6 +235,10 @@ export default function AdminBlogPage() {
|
|||||||
metaDescription: formData.metaDescription || undefined,
|
metaDescription: formData.metaDescription || undefined,
|
||||||
primaryKeyword: formData.primaryKeyword || undefined,
|
primaryKeyword: formData.primaryKeyword || undefined,
|
||||||
secondaryKeywords,
|
secondaryKeywords,
|
||||||
|
aiSummary: formData.aiSummary.trim() ? formData.aiSummary : null,
|
||||||
|
faq,
|
||||||
|
keyTakeaways,
|
||||||
|
aiEntities,
|
||||||
scheduledAt: scheduledAt || undefined,
|
scheduledAt: scheduledAt || undefined,
|
||||||
...(withStatus ? { status: withStatus } : {}),
|
...(withStatus ? { status: withStatus } : {}),
|
||||||
};
|
};
|
||||||
@ -259,9 +309,11 @@ export default function AdminBlogPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const uploadCoverFile = async (file: File) => {
|
||||||
const file = e.target.files?.[0];
|
if (!file.type.startsWith('image/')) {
|
||||||
if (!file) return;
|
alert('Veuillez sélectionner une image');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
alert('Image trop volumineuse (max 5 Mo)');
|
alert('Image trop volumineuse (max 5 Mo)');
|
||||||
return;
|
return;
|
||||||
@ -271,8 +323,9 @@ export default function AdminBlogPage() {
|
|||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('image', file);
|
fd.append('image', file);
|
||||||
|
// Dedicated endpoint: auto-crops to the public 16:9 card ratio.
|
||||||
const result = await upload<{ url: string; filename: string }>(
|
const result = await upload<{ url: string; filename: string }>(
|
||||||
'/api/v1/admin/blog/images',
|
'/api/v1/admin/blog/cover-images',
|
||||||
fd
|
fd
|
||||||
);
|
);
|
||||||
const coverUrl = result.url.startsWith('http') ? result.url : `${API_BASE_URL}${result.url}`;
|
const coverUrl = result.url.startsWith('http') ? result.url : `${API_BASE_URL}${result.url}`;
|
||||||
@ -285,6 +338,73 @@ export default function AdminBlogPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) await uploadCoverFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverDrop = async (e: React.DragEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDraggingCover(false);
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (file) await uploadCoverFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// FAQ (GEO) helpers
|
||||||
|
const addFaqItem = () => {
|
||||||
|
setFormData(prev => ({ ...prev, faqItems: [...prev.faqItems, { question: '', answer: '' }] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFaqItem = (index: number, field: 'question' | 'answer', value: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
faqItems: prev.faqItems.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFaqItem = (index: number) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
faqItems: prev.faqItems.filter((_, i) => i !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async (post: BlogPost) => {
|
||||||
|
setActioningId(post.id);
|
||||||
|
try {
|
||||||
|
await duplicateBlogPost(post.id);
|
||||||
|
await fetchPosts();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Erreur lors de la duplication');
|
||||||
|
} finally {
|
||||||
|
setActioningId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestore = async (post: BlogPost) => {
|
||||||
|
setActioningId(post.id);
|
||||||
|
try {
|
||||||
|
await restoreBlogPost(post.id);
|
||||||
|
await fetchPosts();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Erreur lors de la restauration');
|
||||||
|
} finally {
|
||||||
|
setActioningId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePermanentDelete = async () => {
|
||||||
|
if (!selectedPost) return;
|
||||||
|
try {
|
||||||
|
await permanentlyDeleteBlogPost(selectedPost.id);
|
||||||
|
await fetchPosts();
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setSelectedPost(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Erreur lors de la suppression définitive');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setFormData(EMPTY_FORM);
|
setFormData(EMPTY_FORM);
|
||||||
setTagsInput('');
|
setTagsInput('');
|
||||||
@ -292,6 +412,7 @@ export default function AdminBlogPage() {
|
|||||||
setEditStatus('draft');
|
setEditStatus('draft');
|
||||||
setSelectedPost(null);
|
setSelectedPost(null);
|
||||||
setSeoOpen(false);
|
setSeoOpen(false);
|
||||||
|
setGeoOpen(false);
|
||||||
setShowCreateModal(true);
|
setShowCreateModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -310,15 +431,29 @@ export default function AdminBlogPage() {
|
|||||||
metaDescription: post.metaDescription ?? '',
|
metaDescription: post.metaDescription ?? '',
|
||||||
primaryKeyword: post.primaryKeyword ?? '',
|
primaryKeyword: post.primaryKeyword ?? '',
|
||||||
secondaryKeywordsInput: (post.secondaryKeywords ?? []).join(', '),
|
secondaryKeywordsInput: (post.secondaryKeywords ?? []).join(', '),
|
||||||
|
aiSummary: post.aiSummary ?? '',
|
||||||
|
faqItems: (post.faq ?? []).map(item => ({ ...item })),
|
||||||
|
keyTakeawaysInput: (post.keyTakeaways ?? []).join('\n'),
|
||||||
|
aiEntitiesInput: (post.aiEntities ?? []).join(', '),
|
||||||
});
|
});
|
||||||
setTagsInput(post.tags.join(', '));
|
setTagsInput(post.tags.join(', '));
|
||||||
setScheduledAt(post.status === 'scheduled' ? toLocalDatetimeValue(post.publishedAt) : '');
|
setScheduledAt(post.status === 'scheduled' ? toLocalDatetimeValue(post.publishedAt) : '');
|
||||||
setEditStatus(post.status);
|
setEditStatus(post.status);
|
||||||
setSeoOpen(!!(post.metaTitle || post.metaDescription || post.primaryKeyword));
|
setSeoOpen(!!(post.metaTitle || post.metaDescription || post.primaryKeyword));
|
||||||
|
setGeoOpen(
|
||||||
|
!!(
|
||||||
|
post.aiSummary ||
|
||||||
|
(post.faq && post.faq.length) ||
|
||||||
|
(post.keyTakeaways && post.keyTakeaways.length) ||
|
||||||
|
(post.aiEntities && post.aiEntities.length)
|
||||||
|
)
|
||||||
|
);
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
// Pure UI reset — does not touch browser history. Used by the popstate
|
||||||
|
// handler (the browser already popped our entry at that point).
|
||||||
|
const resetEditorState = () => {
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setSelectedPost(null);
|
setSelectedPost(null);
|
||||||
@ -326,8 +461,31 @@ export default function AdminBlogPage() {
|
|||||||
setTagsInput('');
|
setTagsInput('');
|
||||||
setScheduledAt('');
|
setScheduledAt('');
|
||||||
setSeoOpen(false);
|
setSeoOpen(false);
|
||||||
|
setGeoOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Explicit close (X / Retour / after save): reset UI and pop the history
|
||||||
|
// entry we pushed when opening, so the browser Back button stays on /admin/blog.
|
||||||
|
const closeModal = () => {
|
||||||
|
resetEditorState();
|
||||||
|
if (typeof window !== 'undefined' && window.history.state?.xpBlogEditor) {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep the browser Back button inside the blog editor: pushing a history entry
|
||||||
|
// when the editor opens means Back closes the editor instead of navigating away
|
||||||
|
// (e.g. to the System Logs tab).
|
||||||
|
useEffect(() => {
|
||||||
|
const editorOpen = showCreateModal || showEditModal;
|
||||||
|
if (!editorOpen) return;
|
||||||
|
window.history.pushState({ xpBlogEditor: true }, '');
|
||||||
|
const handlePop = () => resetEditorState();
|
||||||
|
window.addEventListener('popstate', handlePop);
|
||||||
|
return () => window.removeEventListener('popstate', handlePop);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [showCreateModal, showEditModal]);
|
||||||
|
|
||||||
const handleTitleChange = (title: string) => {
|
const handleTitleChange = (title: string) => {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@ -380,10 +538,36 @@ export default function AdminBlogPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Status filters */}
|
||||||
|
<div className="mt-6 flex flex-wrap gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ value: 'all', label: 'Tous' },
|
||||||
|
{ value: 'published', label: 'Publiés' },
|
||||||
|
{ value: 'draft', label: 'Brouillons' },
|
||||||
|
{ value: 'scheduled', label: 'Planifiés' },
|
||||||
|
{ value: 'trash', label: 'Corbeille' },
|
||||||
|
] as const
|
||||||
|
).map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
onClick={() => setViewFilter(tab.value)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
viewFilter === tab.value
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.value === 'trash' && <Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-12 text-gray-500">Chargement des articles...</div>
|
<div className="text-center py-12 text-gray-500">Chargement des articles...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-6">
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-x-auto mt-4">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
@ -408,14 +592,16 @@ export default function AdminBlogPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200">
|
<tbody className="divide-y divide-gray-200">
|
||||||
{posts.length === 0 ? (
|
{visiblePosts.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||||
Aucun article. Créez votre premier article !
|
{viewFilter === 'trash'
|
||||||
|
? 'La corbeille est vide.'
|
||||||
|
: 'Aucun article. Créez votre premier article !'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
posts.map(post => (
|
visiblePosts.map(post => (
|
||||||
<tr key={post.id} className="hover:bg-gray-50 transition-colors">
|
<tr key={post.id} className="hover:bg-gray-50 transition-colors">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
@ -471,6 +657,33 @@ export default function AdminBlogPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center justify-end space-x-1">
|
<div className="flex items-center justify-end space-x-1">
|
||||||
|
{viewFilter === 'trash' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRestore(post)}
|
||||||
|
disabled={actioningId === post.id}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-green-600 transition-colors disabled:opacity-50"
|
||||||
|
title="Restaurer"
|
||||||
|
>
|
||||||
|
{actioningId === post.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPost(post);
|
||||||
|
setShowDeleteConfirm(true);
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors"
|
||||||
|
title="Supprimer définitivement"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{(post.status === 'published' || post.status === 'scheduled') && (
|
{(post.status === 'published' || post.status === 'scheduled') && (
|
||||||
<Link href={`/blog/${post.slug}`} target="_blank">
|
<Link href={`/blog/${post.slug}`} target="_blank">
|
||||||
<button
|
<button
|
||||||
@ -503,6 +716,18 @@ export default function AdminBlogPage() {
|
|||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDuplicate(post)}
|
||||||
|
disabled={actioningId === post.id}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors disabled:opacity-50"
|
||||||
|
title="Dupliquer"
|
||||||
|
>
|
||||||
|
{actioningId === post.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => openEdit(post)}
|
onClick={() => openEdit(post)}
|
||||||
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors"
|
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors"
|
||||||
@ -516,10 +741,12 @@ export default function AdminBlogPage() {
|
|||||||
setShowDeleteConfirm(true);
|
setShowDeleteConfirm(true);
|
||||||
}}
|
}}
|
||||||
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors"
|
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors"
|
||||||
title="Supprimer"
|
title="Mettre à la corbeille"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -535,9 +762,19 @@ export default function AdminBlogPage() {
|
|||||||
<div className="fixed inset-0 z-50 bg-gray-50 overflow-y-auto">
|
<div className="fixed inset-0 z-50 bg-gray-50 overflow-y-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm">
|
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="flex items-center gap-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg px-3 py-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
{showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`}
|
{showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`}
|
||||||
</h2>
|
</h2>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{showEditModal && (
|
{showEditModal && (
|
||||||
<select
|
<select
|
||||||
@ -715,6 +952,144 @@ export default function AdminBlogPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* GEO / AI Panel */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGeoOpen(o => !o)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-4 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="font-semibold text-gray-900 text-sm">
|
||||||
|
GEO — Optimisation pour l'IA
|
||||||
|
</span>
|
||||||
|
{(formData.aiSummary ||
|
||||||
|
formData.faqItems.length > 0 ||
|
||||||
|
formData.keyTakeawaysInput ||
|
||||||
|
formData.aiEntitiesInput) && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500 inline-block" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{geoOpen ? (
|
||||||
|
<ChevronUp className="w-4 h-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{geoOpen && (
|
||||||
|
<div className="px-5 pb-5 space-y-4 border-t border-gray-100">
|
||||||
|
<p className="text-xs text-gray-400 mt-4">
|
||||||
|
Ces champs aident les moteurs IA (ChatGPT, Perplexity…) et le référencement
|
||||||
|
structuré à comprendre et citer votre article.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* AI summary */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Résumé IA (TL;DR)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={formData.aiSummary}
|
||||||
|
onChange={e => setFormData(prev => ({ ...prev, aiSummary: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
||||||
|
placeholder="Un résumé concis de l'article, optimisé pour être cité par une IA."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FAQ */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
FAQ (questions / réponses)
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addFaqItem}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{formData.faqItems.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400">Aucune question ajoutée.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{formData.faqItems.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.question}
|
||||||
|
onChange={e => updateFaqItem(index, 'question', e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
placeholder="Question"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFaqItem(index)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-600"
|
||||||
|
title="Retirer"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={item.answer}
|
||||||
|
onChange={e => updateFaqItem(index, 'answer', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
||||||
|
placeholder="Réponse"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key takeaways */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Points clés à retenir{' '}
|
||||||
|
<span className="text-gray-400 font-normal">(un par ligne)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={formData.keyTakeawaysInput}
|
||||||
|
onChange={e =>
|
||||||
|
setFormData(prev => ({ ...prev, keyTakeawaysInput: e.target.value }))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
||||||
|
placeholder={'Le fret LCL est idéal pour les petits volumes\nComparez plusieurs transporteurs'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI entities */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Entités / sujets{' '}
|
||||||
|
<span className="text-gray-400 font-normal">(virgule)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.aiEntitiesInput}
|
||||||
|
onChange={e =>
|
||||||
|
setFormData(prev => ({ ...prev, aiEntitiesInput: e.target.value }))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
placeholder="ex: fret maritime, LCL, incoterms, Le Havre"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar — 1/3 */}
|
{/* Sidebar — 1/3 */}
|
||||||
@ -806,15 +1181,27 @@ export default function AdminBlogPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => coverInputRef.current?.click()}
|
onClick={() => coverInputRef.current?.click()}
|
||||||
disabled={uploadingCover}
|
disabled={uploadingCover}
|
||||||
className="w-full h-32 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center gap-2 text-gray-400 hover:border-blue-400 hover:text-blue-500 transition-colors disabled:opacity-50"
|
onDragOver={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDraggingCover(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setIsDraggingCover(false)}
|
||||||
|
onDrop={handleCoverDrop}
|
||||||
|
className={`w-full h-32 border-2 border-dashed rounded-lg flex flex-col items-center justify-center gap-2 transition-colors disabled:opacity-50 ${
|
||||||
|
isDraggingCover
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-600'
|
||||||
|
: 'border-gray-300 text-gray-400 hover:border-blue-400 hover:text-blue-500'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{uploadingCover ? (
|
{uploadingCover ? (
|
||||||
<Loader2 className="w-6 h-6 animate-spin" />
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ImageIcon className="w-6 h-6" />
|
<ImageIcon className="w-6 h-6" />
|
||||||
<span className="text-sm">Cliquer pour uploader</span>
|
<span className="text-sm">
|
||||||
<span className="text-xs">JPG, PNG, WebP — max 5 Mo</span>
|
{isDraggingCover ? "Déposez l'image ici" : 'Glisser-déposer ou cliquer'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">JPG, PNG, WebP — recadrée en 16:9</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -912,10 +1299,22 @@ export default function AdminBlogPage() {
|
|||||||
{showDeleteConfirm && selectedPost && (
|
{showDeleteConfirm && selectedPost && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md p-6">
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md p-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">Supprimer l'article</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{viewFilter === 'trash' ? 'Supprimer définitivement' : "Mettre à la corbeille"}
|
||||||
|
</h2>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
Êtes-vous sûr de vouloir supprimer <strong>« {selectedPost.title} »</strong>{' '}
|
{viewFilter === 'trash' ? (
|
||||||
? Cette action est irréversible.
|
<>
|
||||||
|
Êtes-vous sûr de vouloir supprimer définitivement{' '}
|
||||||
|
<strong>« {selectedPost.title} »</strong> ? Cette action est
|
||||||
|
irréversible.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<strong>« {selectedPost.title} »</strong> sera déplacé vers la corbeille.
|
||||||
|
Vous pourrez le restaurer plus tard.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end space-x-3">
|
<div className="flex justify-end space-x-3">
|
||||||
<button
|
<button
|
||||||
@ -928,10 +1327,10 @@ export default function AdminBlogPage() {
|
|||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={viewFilter === 'trash' ? handlePermanentDelete : handleDelete}
|
||||||
className="px-6 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
|
className="px-6 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
|
||||||
>
|
>
|
||||||
Supprimer
|
{viewFilter === 'trash' ? 'Supprimer définitivement' : 'Mettre à la corbeille'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,6 +19,11 @@ interface Booking {
|
|||||||
priceEUR?: number;
|
priceEUR?: number;
|
||||||
priceUSD?: number;
|
priceUSD?: number;
|
||||||
primaryCurrency?: string;
|
primaryCurrency?: string;
|
||||||
|
freightTotal?: number;
|
||||||
|
freightCurrency?: string;
|
||||||
|
fobTotal?: number;
|
||||||
|
fobCurrency?: string;
|
||||||
|
commissionAmountEur?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
requestedAt?: string;
|
requestedAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
@ -116,6 +121,9 @@ export default function AdminBookingsPage() {
|
|||||||
|
|
||||||
const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`;
|
const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`;
|
||||||
|
|
||||||
|
const formatMoney = (amount: number, currency: string) =>
|
||||||
|
new Intl.NumberFormat(dateLocale, { style: 'currency', currency }).format(amount);
|
||||||
|
|
||||||
const filteredBookings = bookings
|
const filteredBookings = bookings
|
||||||
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
|
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
|
||||||
.filter(booking => {
|
.filter(booking => {
|
||||||
@ -471,7 +479,7 @@ export default function AdminBookingsPage() {
|
|||||||
{/* Details Modal */}
|
{/* Details Modal */}
|
||||||
{showDetailsModal && selectedBooking && (
|
{showDetailsModal && selectedBooking && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto p-4">
|
||||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
|
<div className="bg-white rounded-lg p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-xl font-bold text-gray-900">{t('modal.title')}</h2>
|
<h2 className="text-xl font-bold text-gray-900">{t('modal.title')}</h2>
|
||||||
<button
|
<button
|
||||||
@ -514,6 +522,7 @@ export default function AdminBookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-4">
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-3">
|
<h3 className="text-sm font-medium text-gray-900 mb-3">
|
||||||
{t('modal.routeSection')}
|
{t('modal.routeSection')}
|
||||||
@ -592,11 +601,56 @@ export default function AdminBookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
|
{(selectedBooking.priceEUR != null ||
|
||||||
|
selectedBooking.priceUSD != null ||
|
||||||
|
selectedBooking.freightTotal != null ||
|
||||||
|
selectedBooking.fobTotal != null) && (
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-3">
|
<h3 className="text-sm font-medium text-gray-900 mb-3">
|
||||||
{t('modal.priceSection')}
|
{t('modal.priceSection')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{/* Transport breakdown — Fret + FOB (paid to carrier) */}
|
||||||
|
{(selectedBooking.freightTotal != null || selectedBooking.fobTotal != null) && (
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{selectedBooking.freightTotal != null && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">{t('modal.freight')}</span>
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{formatMoney(
|
||||||
|
selectedBooking.freightTotal,
|
||||||
|
selectedBooking.freightCurrency ||
|
||||||
|
selectedBooking.primaryCurrency ||
|
||||||
|
'USD'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedBooking.fobTotal != null && selectedBooking.fobTotal > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">{t('modal.fob')}</span>
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{formatMoney(
|
||||||
|
selectedBooking.fobTotal,
|
||||||
|
selectedBooking.fobCurrency || 'EUR'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedBooking.commissionAmountEur != null &&
|
||||||
|
selectedBooking.commissionAmountEur > 0 && (
|
||||||
|
<div className="flex justify-between text-sm border-t pt-2">
|
||||||
|
<span className="text-gray-500">{t('modal.bookingFee')}</span>
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{formatMoney(selectedBooking.commissionAmountEur, 'EUR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-400 pt-1">{t('modal.transportNote')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per-currency totals (also covers legacy bookings without a breakdown) */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{selectedBooking.priceEUR != null && (
|
{selectedBooking.priceEUR != null && (
|
||||||
<div>
|
<div>
|
||||||
@ -641,6 +695,7 @@ export default function AdminBookingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedBooking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
|
{selectedBooking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
|
|||||||
@ -214,6 +214,16 @@ export default function BlogPostContent({ slug }: { slug: string }) {
|
|||||||
{/* Content + Sidebar */}
|
{/* Content + Sidebar */}
|
||||||
<section className="py-12">
|
<section className="py-12">
|
||||||
<div className="max-w-4xl mx-auto px-6 lg:px-8">
|
<div className="max-w-4xl mx-auto px-6 lg:px-8">
|
||||||
|
{/* En bref (AI summary / TL;DR) */}
|
||||||
|
{post.aiSummary && (
|
||||||
|
<div className="mb-10 rounded-2xl border border-brand-turquoise/30 bg-brand-turquoise/5 p-6">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-brand-turquoise mb-2">
|
||||||
|
En bref
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700 leading-relaxed">{post.aiSummary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="lg:grid lg:grid-cols-[1fr_80px] lg:gap-8 items-start">
|
<div className="lg:grid lg:grid-cols-[1fr_80px] lg:gap-8 items-start">
|
||||||
{/* Article body */}
|
{/* Article body */}
|
||||||
<motion.article
|
<motion.article
|
||||||
@ -245,6 +255,44 @@ export default function BlogPostContent({ slug }: { slug: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* À retenir (key takeaways) */}
|
||||||
|
{post.keyTakeaways && post.keyTakeaways.length > 0 && (
|
||||||
|
<div className="mt-12 rounded-2xl border border-gray-200 bg-gray-50 p-6">
|
||||||
|
<h2 className="text-lg font-bold text-brand-navy mb-4">À retenir</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{post.keyTakeaways.map((item, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-3 text-gray-700">
|
||||||
|
<span className="mt-2 w-1.5 h-1.5 rounded-full bg-brand-turquoise flex-shrink-0" />
|
||||||
|
<span className="leading-relaxed">{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAQ */}
|
||||||
|
{post.faq && post.faq.length > 0 && (
|
||||||
|
<div className="mt-12">
|
||||||
|
<h2 className="text-2xl font-bold text-brand-navy mb-6">Questions fréquentes</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{post.faq.map((item, i) => (
|
||||||
|
<details
|
||||||
|
key={i}
|
||||||
|
className="group rounded-xl border border-gray-200 bg-white p-5 open:shadow-sm"
|
||||||
|
>
|
||||||
|
<summary className="cursor-pointer font-semibold text-brand-navy list-none flex items-center justify-between gap-4">
|
||||||
|
<span>{item.question}</span>
|
||||||
|
<span className="text-brand-turquoise transition-transform group-open:rotate-45 text-xl leading-none">
|
||||||
|
+
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<p className="mt-3 text-gray-700 leading-relaxed">{item.answer}</p>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{post.tags.length > 0 && (
|
{post.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mt-12 pt-8 border-t border-gray-100">
|
<div className="flex flex-wrap gap-2 mt-12 pt-8 border-t border-gray-100">
|
||||||
|
|||||||
@ -3,14 +3,14 @@ import BlogPostContent from './BlogPostContent';
|
|||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
async function fetchPostMeta(slug: string) {
|
interface BlogFaqItem {
|
||||||
try {
|
question: string;
|
||||||
const res = await fetch(`${API_BASE_URL}/api/v1/blog/${slug}`, {
|
answer: string;
|
||||||
cache: 'no-store',
|
}
|
||||||
});
|
|
||||||
if (!res.ok) return null;
|
interface BlogPostMeta {
|
||||||
return res.json() as Promise<{
|
|
||||||
title: string;
|
title: string;
|
||||||
|
slug: string;
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
@ -20,12 +20,62 @@ async function fetchPostMeta(slug: string) {
|
|||||||
authorName: string;
|
authorName: string;
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}>;
|
aiSummary?: string | null;
|
||||||
|
faq?: BlogFaqItem[];
|
||||||
|
keyTakeaways?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPostMeta(slug: string): Promise<BlogPostMeta | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/blog/${slug}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return (await res.json()) as BlogPostMeta;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildJsonLd(post: BlogPostMeta, slug: string) {
|
||||||
|
const coverImage = post.coverImageUrl
|
||||||
|
? post.coverImageUrl.startsWith('http')
|
||||||
|
? post.coverImageUrl
|
||||||
|
: `${API_BASE_URL}${post.coverImageUrl}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const article = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: post.metaTitle || post.title,
|
||||||
|
description: post.metaDescription || post.aiSummary || post.excerpt,
|
||||||
|
image: coverImage ? [coverImage] : undefined,
|
||||||
|
datePublished: post.publishedAt,
|
||||||
|
dateModified: post.updatedAt,
|
||||||
|
author: { '@type': 'Person', name: post.authorName },
|
||||||
|
publisher: { '@type': 'Organization', name: 'Xpeditis' },
|
||||||
|
mainEntityOfPage: { '@type': 'WebPage', '@id': `https://xpeditis.com/blog/${slug}` },
|
||||||
|
keywords: [post.primaryKeyword, ...(post.secondaryKeywords ?? [])]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', '),
|
||||||
|
};
|
||||||
|
|
||||||
|
const faqPage =
|
||||||
|
post.faq && post.faq.length > 0
|
||||||
|
? {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: post.faq.map(item => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: item.question,
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { article, faqPage };
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@ -72,10 +122,31 @@ export async function generateMetadata({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BlogPostPage({
|
export default async function BlogPostPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { slug: string; locale: string };
|
params: { slug: string; locale: string };
|
||||||
}) {
|
}) {
|
||||||
return <BlogPostContent slug={params.slug} />;
|
const post = await fetchPostMeta(params.slug);
|
||||||
|
const jsonLd = post ? buildJsonLd(post, params.slug) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{jsonLd && (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd.article) }}
|
||||||
|
/>
|
||||||
|
{jsonLd.faqPage && (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd.faqPage) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<BlogPostContent slug={params.slug} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -277,7 +277,30 @@ export default function CarrierDocumentsPage() {
|
|||||||
setDownloading(doc.id);
|
setDownloading(doc.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
window.open(doc.downloadUrl, '_blank');
|
// Stream the document through the API instead of relying on a presigned
|
||||||
|
// storage URL (whose internal host is unreachable from outside).
|
||||||
|
const downloadUrl = `${apiUrl}/api/v1/csv-booking-actions/documents/${token}/${doc.id}/download`;
|
||||||
|
const usePassword = !!requirements?.requiresPassword && !!password;
|
||||||
|
|
||||||
|
const response = await fetch(downloadUrl, {
|
||||||
|
method: usePassword ? 'POST' : 'GET',
|
||||||
|
headers: usePassword ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: usePassword ? JSON.stringify({ password }) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = doc.fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(objectUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error downloading document:', err);
|
console.error('Error downloading document:', err);
|
||||||
alert(t('downloadError'));
|
alert(t('downloadError'));
|
||||||
|
|||||||
@ -44,6 +44,10 @@ interface BookingData {
|
|||||||
status: string;
|
status: string;
|
||||||
commissionRate?: number;
|
commissionRate?: number;
|
||||||
commissionAmountEur?: number;
|
commissionAmountEur?: number;
|
||||||
|
freightTotal?: number;
|
||||||
|
freightCurrency?: string;
|
||||||
|
fobTotal?: number;
|
||||||
|
fobCurrency?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrgData {
|
interface OrgData {
|
||||||
@ -188,26 +192,63 @@ export default function PayCommissionPage() {
|
|||||||
|
|
||||||
if (!booking) return null;
|
if (!booking) return null;
|
||||||
|
|
||||||
const commissionAmount = booking.commissionAmountEur || 0;
|
const bookingFeeAmount = booking.commissionAmountEur || 0;
|
||||||
const commissionRate = booking.commissionRate || 0;
|
|
||||||
const reference = booking.bookingNumber || booking.id.slice(0, 8).toUpperCase();
|
const reference = booking.bookingNumber || booking.id.slice(0, 8).toUpperCase();
|
||||||
|
|
||||||
|
// Transport cost breakdown (informational — paid to the carrier, not collected here).
|
||||||
|
// Freight and FOB may be in different currencies, so they are shown separately.
|
||||||
|
const freightTotal = booking.freightTotal ?? null;
|
||||||
|
const fobTotal = booking.fobTotal ?? null;
|
||||||
|
const freightCurrency = booking.freightCurrency || booking.primaryCurrency || 'USD';
|
||||||
|
const fobCurrency = booking.fobCurrency || 'EUR';
|
||||||
|
const hasBreakdown = freightTotal != null || fobTotal != null;
|
||||||
|
// Single transport total, always shown in USD = freight + FOB summed.
|
||||||
|
// New bookings store the freight/FOB breakdown; legacy ones only keep the
|
||||||
|
// per-currency price sums. In both cases the charges are added together.
|
||||||
|
const transportTotalUSD = hasBreakdown
|
||||||
|
? (freightTotal ?? 0) + (fobTotal ?? 0)
|
||||||
|
: (booking.priceUSD || 0) + (booking.priceEUR || 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
{/* Back button */}
|
{/* Back button — returns to the previous context (list or booking detail)
|
||||||
|
instead of always jumping to the global list. */}
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/dashboard/bookings')}
|
onClick={() => router.back()}
|
||||||
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
Retour aux bookings
|
Retour
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement de la commission</h1>
|
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement du forfait booking</h1>
|
||||||
<p className="text-gray-500 mb-8">
|
<p className="text-gray-500 mb-4">
|
||||||
Finalisez votre booking en réglant la commission de service
|
Finalisez votre booking en réglant le forfait par booking
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const opts = (booking as any).options || {};
|
||||||
|
const enabledOptions = Object.keys(opts)
|
||||||
|
.filter(k => opts[k])
|
||||||
|
.join(',');
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
editBookingId: bookingId,
|
||||||
|
origin: booking.origin || '',
|
||||||
|
destination: booking.destination || '',
|
||||||
|
volumeCBM: String(booking.volumeCBM ?? ''),
|
||||||
|
weightKG: String(booking.weightKG ?? ''),
|
||||||
|
palletCount: String(booking.palletCount ?? ''),
|
||||||
|
hasDangerousGoods: String(!!opts.dangerousGoods),
|
||||||
|
});
|
||||||
|
if (enabledOptions) params.set('options', enabledOptions);
|
||||||
|
router.push(`/dashboard/search-advanced?${params.toString()}`);
|
||||||
|
}}
|
||||||
|
className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Modifier la réservation avant de payer
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* SIRET/SIREN non vérifié — bannière de demande */}
|
{/* SIRET/SIREN non vérifié — bannière de demande */}
|
||||||
{siretNotVerified && (
|
{siretNotVerified && (
|
||||||
@ -374,7 +415,7 @@ export default function PayCommissionPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CreditCard className="h-5 w-5" />
|
<CreditCard className="h-5 w-5" />
|
||||||
<span>Payer {formatPrice(commissionAmount, 'EUR')} par carte</span>
|
<span>Payer {formatPrice(bookingFeeAmount, 'EUR')} par carte</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -397,7 +438,7 @@ export default function PayCommissionPage() {
|
|||||||
{ label: 'BIC / SWIFT', value: BANK_DETAILS.bic, key: 'bic', mono: true },
|
{ label: 'BIC / SWIFT', value: BANK_DETAILS.bic, key: 'bic', mono: true },
|
||||||
{
|
{
|
||||||
label: 'Montant',
|
label: 'Montant',
|
||||||
value: formatPrice(commissionAmount, 'EUR'),
|
value: formatPrice(bookingFeeAmount, 'EUR'),
|
||||||
key: 'amount',
|
key: 'amount',
|
||||||
bold: true,
|
bold: true,
|
||||||
},
|
},
|
||||||
@ -500,22 +541,45 @@ export default function PayCommissionPage() {
|
|||||||
<span className="text-gray-500">Transit</span>
|
<span className="text-gray-500">Transit</span>
|
||||||
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
|
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Transport cost breakdown — Fret + FOB (paid to the carrier) */}
|
||||||
|
{hasBreakdown && (
|
||||||
|
<>
|
||||||
|
{freightTotal != null && (
|
||||||
<div className="border-t pt-3 flex justify-between text-sm">
|
<div className="border-t pt-3 flex justify-between text-sm">
|
||||||
<span className="text-gray-500">Prix transport</span>
|
<span className="text-gray-500">Fret</span>
|
||||||
<span className="font-bold text-gray-900">
|
<span className="font-semibold text-gray-900">
|
||||||
{formatPrice(booking.priceEUR, 'EUR')}
|
{formatPrice(freightTotal, freightCurrency)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{fobTotal != null && fobTotal > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Frais FOB</span>
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{formatPrice(fobTotal, fobCurrency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Single transport total — always in USD (fret + FOB) */}
|
||||||
|
<div className="flex justify-between text-sm border-t pt-3">
|
||||||
|
<span className="text-gray-700 font-medium">Total</span>
|
||||||
|
<span className="font-bold text-gray-900">
|
||||||
|
{formatPrice(transportTotalUSD, 'USD')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 pt-1">
|
||||||
|
Fret et frais FOB sont réglés directement au transporteur.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Commission box */}
|
{/* Booking fee box */}
|
||||||
<div className="bg-blue-600 rounded-xl p-5 text-white">
|
<div className="bg-blue-600 rounded-xl p-5 text-white">
|
||||||
<p className="text-sm text-blue-100 mb-1">
|
<p className="text-sm text-blue-100 mb-1">Forfait par booking — à régler maintenant</p>
|
||||||
Commission ({commissionRate}% du prix transport)
|
<p className="text-3xl font-bold">{formatPrice(bookingFeeAmount, 'EUR')}</p>
|
||||||
</p>
|
|
||||||
<p className="text-3xl font-bold">{formatPrice(commissionAmount, 'EUR')}</p>
|
|
||||||
<p className="text-xs text-blue-200 mt-1">
|
<p className="text-xs text-blue-200 mt-1">
|
||||||
{formatPrice(booking.priceEUR, 'EUR')} × {commissionRate}%
|
Montant fixe par booking (hors fret et frais FOB)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -81,8 +81,8 @@ export default function PaymentSuccessPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">Paiement confirme !</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-3">Paiement confirme !</h2>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
Votre commission a ete payee avec succes. Un email a ete envoye au transporteur avec
|
Votre forfait booking a ete paye avec succes. Un email a ete envoye au transporteur
|
||||||
votre demande de booking.
|
avec votre demande de booking.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
|
|||||||
@ -181,6 +181,11 @@ function NewBookingPageContent() {
|
|||||||
formDataToSend.append('priceUSD', priceUSD.toString());
|
formDataToSend.append('priceUSD', priceUSD.toString());
|
||||||
formDataToSend.append('priceEUR', priceEUR.toString());
|
formDataToSend.append('priceEUR', priceEUR.toString());
|
||||||
formDataToSend.append('primaryCurrency', formData.primaryCurrency);
|
formDataToSend.append('primaryCurrency', formData.primaryCurrency);
|
||||||
|
// Transport cost breakdown (informational — paid to the carrier)
|
||||||
|
formDataToSend.append('freightTotal', formData.freightTotal.toString());
|
||||||
|
formDataToSend.append('freightCurrency', formData.freightCurrency);
|
||||||
|
formDataToSend.append('fobTotal', formData.fobTotal.toString());
|
||||||
|
formDataToSend.append('fobCurrency', formData.fobCurrency);
|
||||||
formDataToSend.append('transitDays', formData.transitDays.toString());
|
formDataToSend.append('transitDays', formData.transitDays.toString());
|
||||||
formDataToSend.append('containerType', formData.containerType);
|
formDataToSend.append('containerType', formData.containerType);
|
||||||
|
|
||||||
@ -188,6 +193,17 @@ function NewBookingPageContent() {
|
|||||||
formDataToSend.append('notes', formData.notes);
|
formDataToSend.append('notes', formData.notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Options & services selected in the search form (persisted with the booking)
|
||||||
|
const optionsParam = searchParams.get('options') || '';
|
||||||
|
if (optionsParam) {
|
||||||
|
const optionsObj = optionsParam
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.reduce((acc, k) => ({ ...acc, [k]: true }), {} as Record<string, boolean>);
|
||||||
|
formDataToSend.append('options', JSON.stringify(optionsObj));
|
||||||
|
}
|
||||||
|
|
||||||
// Append documents
|
// Append documents
|
||||||
formData.documents.forEach(file => {
|
formData.documents.forEach(file => {
|
||||||
formDataToSend.append('documents', file);
|
formDataToSend.append('documents', file);
|
||||||
|
|||||||
@ -4,14 +4,18 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { listCsvBookings } from '@/lib/api';
|
import { listCsvBookings } from '@/lib/api';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { Plus, Clock, Eye, X, CreditCard } from 'lucide-react';
|
import { Plus, Clock, Eye, X, CreditCard, Pencil, MoreVertical, Lock, Sparkles } from 'lucide-react';
|
||||||
import ExportButton from '@/components/ExportButton';
|
import ExportButton from '@/components/ExportButton';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { PageHeader } from '@/components/ui/PageHeader';
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
import { useReservationQuota } from '@/hooks/useReservationQuota';
|
||||||
|
|
||||||
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
||||||
|
|
||||||
|
// Statuses considered "not finalized": booking started but not yet paid.
|
||||||
|
const TO_FINALIZE_STATUSES = ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER'];
|
||||||
|
|
||||||
export default function BookingsListPage() {
|
export default function BookingsListPage() {
|
||||||
const t = useTranslations('dashboard.bookingsList');
|
const t = useTranslations('dashboard.bookingsList');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
@ -23,8 +27,55 @@ export default function BookingsListPage() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [showTransferBanner, setShowTransferBanner] = useState(false);
|
const [showTransferBanner, setShowTransferBanner] = useState(false);
|
||||||
const [selectedBooking, setSelectedBooking] = useState<any | null>(null);
|
const [selectedBooking, setSelectedBooking] = useState<any | null>(null);
|
||||||
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||||
|
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null);
|
||||||
const ITEMS_PER_PAGE = 20;
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
|
// "Modifier" reopens the existing booking flow (search → carrier choice →
|
||||||
|
// payment) pre-filled with the booking's data, so a change re-quotes the price.
|
||||||
|
const buildEditUrl = (b: any) => {
|
||||||
|
const enabledOptions = Object.keys(b.options || {})
|
||||||
|
.filter(k => b.options[k])
|
||||||
|
.join(',');
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
editBookingId: b.id,
|
||||||
|
origin: b.origin || b.originCity || '',
|
||||||
|
destination: b.destination || b.destinationCity || '',
|
||||||
|
volumeCBM: String(b.volumeCBM ?? ''),
|
||||||
|
weightKG: String(b.weightKG ?? ''),
|
||||||
|
palletCount: String(b.palletCount ?? ''),
|
||||||
|
hasDangerousGoods: String(!!(b.options && b.options.dangerousGoods)),
|
||||||
|
});
|
||||||
|
if (enabledOptions) params.set('options', enabledOptions);
|
||||||
|
return `/dashboard/search-advanced?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kebab (⋮) actions menu — anchored with fixed positioning so it is never
|
||||||
|
// clipped by the scrollable table container.
|
||||||
|
const toggleMenu = (e: React.MouseEvent, bookingId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (openMenuId === bookingId) {
|
||||||
|
setOpenMenuId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
setMenuPos({ top: rect.bottom + 6, right: window.innerWidth - rect.right });
|
||||||
|
setOpenMenuId(bookingId);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openMenuId) return;
|
||||||
|
const close = () => setOpenMenuId(null);
|
||||||
|
window.addEventListener('click', close);
|
||||||
|
window.addEventListener('scroll', close, true);
|
||||||
|
window.addEventListener('resize', close);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('click', close);
|
||||||
|
window.removeEventListener('scroll', close, true);
|
||||||
|
window.removeEventListener('resize', close);
|
||||||
|
};
|
||||||
|
}, [openMenuId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get('transfer') === 'declared') {
|
if (searchParams.get('transfer') === 'declared') {
|
||||||
setShowTransferBanner(true);
|
setShowTransferBanner(true);
|
||||||
@ -46,12 +97,41 @@ export default function BookingsListPage() {
|
|||||||
|
|
||||||
if (csvError) console.error('CSV bookings error:', csvError);
|
if (csvError) console.error('CSV bookings error:', csvError);
|
||||||
|
|
||||||
|
const quota = useReservationQuota();
|
||||||
|
|
||||||
|
// When the free-plan reservation limit is reached, the "New reservation"
|
||||||
|
// entry point is replaced by an upgrade call-to-action.
|
||||||
|
const newReservationAction = quota.blocked ? (
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/subscription"
|
||||||
|
className="inline-flex items-center gap-2 px-3 sm:px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-500 hover:bg-amber-600"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{t('limit.upgrade')}</span>
|
||||||
|
<span className="sm:hidden">{t('limit.upgradeShort')}</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/dashboard/search-advanced"
|
||||||
|
className="inline-flex items-center px-3 sm:px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 sm:mr-2" />
|
||||||
|
<span className="hidden sm:inline">{t('new')}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
const filterBookings = (bookings: any[]) => {
|
const filterBookings = (bookings: any[]) => {
|
||||||
let filtered = bookings;
|
let filtered = bookings;
|
||||||
|
|
||||||
if (statusFilter) {
|
if (statusFilter) {
|
||||||
|
if (statusFilter === 'to_finalize') {
|
||||||
|
filtered = filtered.filter((booking: any) =>
|
||||||
|
TO_FINALIZE_STATUSES.includes(booking.status)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
filtered = filtered.filter((booking: any) => booking.status === statusFilter);
|
filtered = filtered.filter((booking: any) => booking.status === statusFilter);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (searchTerm.trim()) {
|
if (searchTerm.trim()) {
|
||||||
const term = searchTerm.toLowerCase();
|
const term = searchTerm.toLowerCase();
|
||||||
@ -101,9 +181,13 @@ export default function BookingsListPage() {
|
|||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ value: '', label: t('statusFilter.all') },
|
{ value: '', label: t('statusFilter.all') },
|
||||||
|
{ value: 'to_finalize', label: t('statusFilter.toFinalize') },
|
||||||
|
{ value: 'PENDING_PAYMENT', label: t('status.pendingPayment') },
|
||||||
|
{ value: 'PENDING_BANK_TRANSFER', label: t('status.pendingBankTransfer') },
|
||||||
{ value: 'PENDING', label: t('status.pending') },
|
{ value: 'PENDING', label: t('status.pending') },
|
||||||
{ value: 'ACCEPTED', label: t('status.accepted') },
|
{ value: 'ACCEPTED', label: t('status.accepted') },
|
||||||
{ value: 'REJECTED', label: t('status.rejected') },
|
{ value: 'REJECTED', label: t('status.rejected') },
|
||||||
|
{ value: 'CANCELLED', label: t('status.cancelled') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const searchTypeOptions: { value: SearchType; label: string }[] = [
|
const searchTypeOptions: { value: SearchType; label: string }[] = [
|
||||||
@ -129,18 +213,24 @@ export default function BookingsListPage() {
|
|||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
|
PENDING_PAYMENT: 'bg-orange-100 text-orange-800',
|
||||||
|
PENDING_BANK_TRANSFER: 'bg-blue-100 text-blue-800',
|
||||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||||
ACCEPTED: 'bg-green-100 text-green-800',
|
ACCEPTED: 'bg-green-100 text-green-800',
|
||||||
REJECTED: 'bg-red-100 text-red-800',
|
REJECTED: 'bg-red-100 text-red-800',
|
||||||
|
CANCELLED: 'bg-gray-100 text-gray-800',
|
||||||
};
|
};
|
||||||
return colors[status] || 'bg-gray-100 text-gray-800';
|
return colors[status] || 'bg-gray-100 text-gray-800';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
const getStatusLabel = (status: string) => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
|
PENDING_PAYMENT: t('status.pendingPayment'),
|
||||||
|
PENDING_BANK_TRANSFER: t('status.pendingBankTransfer'),
|
||||||
PENDING: t('status.pending'),
|
PENDING: t('status.pending'),
|
||||||
ACCEPTED: t('status.accepted'),
|
ACCEPTED: t('status.accepted'),
|
||||||
REJECTED: t('status.rejected'),
|
REJECTED: t('status.rejected'),
|
||||||
|
CANCELLED: t('status.cancelled'),
|
||||||
};
|
};
|
||||||
return map[status] || status;
|
return map[status] || status;
|
||||||
};
|
};
|
||||||
@ -188,17 +278,30 @@ export default function BookingsListPage() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Link
|
{newReservationAction}
|
||||||
href="/dashboard/search-advanced"
|
|
||||||
className="inline-flex items-center px-3 sm:px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 sm:mr-2" />
|
|
||||||
<span className="hidden sm:inline">{t('new')}</span>
|
|
||||||
</Link>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{quota.blocked && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<Lock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-amber-800">{t('limit.title')}</p>
|
||||||
|
<p className="text-sm text-amber-700 mt-0.5">
|
||||||
|
{t('limit.message', { max: quota.max })}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/subscription"
|
||||||
|
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-amber-500 text-white text-sm font-medium rounded-lg hover:bg-amber-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
{t('limit.upgrade')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@ -350,26 +453,16 @@ export default function BookingsListPage() {
|
|||||||
})
|
})
|
||||||
: t('mobile.booking', { number: booking.bookingNumber || '-' })}
|
: t('mobile.booking', { number: booking.bookingNumber || '-' })}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{booking.status === 'PENDING_PAYMENT' && (
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/booking/${booking.id}/pay`}
|
|
||||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<CreditCard className="h-3.5 w-3.5" />
|
|
||||||
{t('actions.pay')}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedBooking(booking)}
|
onClick={e => toggleMenu(e, booking.id)}
|
||||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
|
className="p-2 -mr-1 rounded-full text-gray-500 hover:bg-gray-100 transition-colors"
|
||||||
|
title={t('columns.actions')}
|
||||||
|
aria-label={t('columns.actions')}
|
||||||
>
|
>
|
||||||
<Eye className="h-3.5 w-3.5" />
|
<MoreVertical className="h-5 w-5" />
|
||||||
{t('actions.view')}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -378,61 +471,40 @@ export default function BookingsListPage() {
|
|||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{t('columns.palletsPackages')}
|
{t('columns.reference')}
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{t('columns.weight')}
|
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{t('columns.route')}
|
{t('columns.route')}
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('columns.cargo')}
|
||||||
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{t('columns.status')}
|
{t('columns.status')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{t('columns.date')}
|
{t('columns.date')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{t('columns.quoteNumber')}
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{t('columns.bookingNumber')}
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{t('columns.actions')}
|
{t('columns.actions')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{paginatedBookings.map((booking: any) => (
|
{paginatedBookings.map((booking: any) => {
|
||||||
<tr key={`${booking.type}-${booking.id}`} className="hover:bg-gray-50">
|
const reference =
|
||||||
|
booking.type === 'csv'
|
||||||
|
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
|
||||||
|
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`;
|
||||||
|
return (
|
||||||
|
<tr key={`${booking.type}-${booking.id}`} className="group hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-semibold text-brand-navy">
|
||||||
{booking.type === 'csv'
|
{booking.bookingNumber || reference}
|
||||||
? t('units.palletsCount', { count: booking.palletCount })
|
|
||||||
: t('units.containersCount', {
|
|
||||||
count: booking.containers?.length || 0,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm font-medium text-gray-900">
|
|
||||||
{booking.type === 'csv'
|
|
||||||
? t('units.kg', { value: booking.weightKG })
|
|
||||||
: booking.totalWeight
|
|
||||||
? t('units.kg', { value: booking.totalWeight })
|
|
||||||
: 'N/A'}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{booking.type === 'csv'
|
|
||||||
? t('units.cbm', { value: booking.volumeCBM })
|
|
||||||
: booking.totalVolume
|
|
||||||
? t('units.cbm', { value: booking.totalVolume })
|
|
||||||
: ''}
|
|
||||||
</div>
|
</div>
|
||||||
|
{booking.bookingNumber && (
|
||||||
|
<div className="text-xs text-gray-400">{reference}</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="text-sm text-gray-900">
|
<div className="text-sm text-gray-900">
|
||||||
@ -446,6 +518,24 @@ export default function BookingsListPage() {
|
|||||||
: booking.carrier || ''}
|
: booking.carrier || ''}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{booking.type === 'csv'
|
||||||
|
? t('units.palletsCount', { count: booking.palletCount })
|
||||||
|
: t('units.containersCount', {
|
||||||
|
count: booking.containers?.length || 0,
|
||||||
|
})}
|
||||||
|
{' · '}
|
||||||
|
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{booking.type === 'csv'
|
||||||
|
? `${t('units.kg', { value: booking.weightKG })} · ${t('units.cbm', { value: booking.volumeCBM })}`
|
||||||
|
: booking.totalWeight
|
||||||
|
? t('units.kg', { value: booking.totalWeight })
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
|
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
|
||||||
@ -457,46 +547,28 @@ export default function BookingsListPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{booking.createdAt || booking.requestedAt
|
{booking.createdAt || booking.requestedAt
|
||||||
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(
|
? new Date(
|
||||||
dateLocale,
|
booking.createdAt || booking.requestedAt
|
||||||
{
|
).toLocaleDateString(dateLocale, {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
}
|
})
|
||||||
)
|
|
||||||
: 'N/A'}
|
: 'N/A'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-left text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
{booking.type === 'csv'
|
|
||||||
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
|
|
||||||
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy">
|
|
||||||
{booking.bookingNumber || '-'}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
{booking.status === 'PENDING_PAYMENT' && (
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/booking/${booking.id}/pay`}
|
|
||||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<CreditCard className="h-3.5 w-3.5" />
|
|
||||||
{t('actions.pay')}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedBooking(booking)}
|
onClick={e => toggleMenu(e, booking.id)}
|
||||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
|
className="p-2 rounded-full text-gray-500 hover:bg-gray-100 transition-colors"
|
||||||
|
title={t('columns.actions')}
|
||||||
|
aria-label={t('columns.actions')}
|
||||||
>
|
>
|
||||||
<Eye className="h-3.5 w-3.5" />
|
<MoreVertical className="h-5 w-5" />
|
||||||
{t('actions.view')}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -634,6 +706,15 @@ export default function BookingsListPage() {
|
|||||||
{searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings')}
|
{searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
{quota.blocked ? (
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/subscription"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-500 hover:bg-amber-600"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
{t('limit.upgrade')}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/search-advanced"
|
href="/dashboard/search-advanced"
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||||
@ -641,11 +722,56 @@ export default function BookingsListPage() {
|
|||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
{t('new')}
|
{t('new')}
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Actions menu (⋮) — fixed-positioned to avoid clipping */}
|
||||||
|
{openMenuId &&
|
||||||
|
menuPos &&
|
||||||
|
(() => {
|
||||||
|
const b = paginatedBookings.find((x: any) => x.id === openMenuId);
|
||||||
|
if (!b) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 w-44 bg-white rounded-lg shadow-lg border border-gray-200 py-1"
|
||||||
|
style={{ top: menuPos.top, right: menuPos.right }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{b.status === 'PENDING_PAYMENT' && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href={buildEditUrl(b)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4 text-gray-500" />
|
||||||
|
{t('actions.edit')}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/booking/${b.id}/pay`}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<CreditCard className="h-4 w-4 text-orange-500" />
|
||||||
|
{t('actions.pay')}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOpenMenuId(null);
|
||||||
|
setSelectedBooking(b);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 text-blue-600" />
|
||||||
|
{t('actions.view')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Booking Detail Modal */}
|
{/* Booking Detail Modal */}
|
||||||
{selectedBooking && (
|
{selectedBooking && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
@ -744,15 +870,25 @@ export default function BookingsListPage() {
|
|||||||
<p className="text-sm text-orange-700 bg-orange-50 rounded-lg px-3 py-2">
|
<p className="text-sm text-orange-700 bg-orange-50 rounded-lg px-3 py-2">
|
||||||
{t('detail.pendingPaymentNotice')}
|
{t('detail.pendingPaymentNotice')}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={buildEditUrl(selectedBooking)}
|
||||||
|
onClick={() => setSelectedBooking(null)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
{t('actions.edit')}
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/booking/${selectedBooking.id}/pay`}
|
href={`/dashboard/booking/${selectedBooking.id}/pay`}
|
||||||
onClick={() => setSelectedBooking(null)}
|
onClick={() => setSelectedBooking(null)}
|
||||||
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors"
|
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors"
|
||||||
>
|
>
|
||||||
<CreditCard className="h-4 w-4" />
|
<CreditCard className="h-4 w-4" />
|
||||||
{t('detail.payNow')}
|
{t('detail.payNow')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
|
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
|
||||||
|
|||||||
@ -3,7 +3,14 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
||||||
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
import {
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
FileEdit,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Paperclip,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import ExportButton from '@/components/ExportButton';
|
import ExportButton from '@/components/ExportButton';
|
||||||
import { PageHeader } from '@/components/ui/PageHeader';
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
@ -56,7 +63,12 @@ export default function UserDocumentsPage() {
|
|||||||
const [replaceFile, setReplaceFile] = useState<File | null>(null);
|
const [replaceFile, setReplaceFile] = useState<File | null>(null);
|
||||||
const replaceFileInputRef = useRef<HTMLInputElement>(null);
|
const replaceFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [documentToDelete, setDocumentToDelete] = useState<DocumentWithBooking | null>(null);
|
||||||
|
const [deletingFile, setDeletingFile] = useState(false);
|
||||||
|
|
||||||
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||||
|
const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number } | null>(null);
|
||||||
|
|
||||||
const getQuoteNumber = (booking: CsvBookingResponse): string => {
|
const getQuoteNumber = (booking: CsvBookingResponse): string => {
|
||||||
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
|
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
|
||||||
@ -298,15 +310,29 @@ export default function UserDocumentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleDropdown = (docId: string) => {
|
const toggleDropdown = (docId: string, e: React.MouseEvent) => {
|
||||||
setOpenDropdownId(openDropdownId === docId ? null : docId);
|
if (openDropdownId === docId) {
|
||||||
|
setOpenDropdownId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
// Anchor the menu with fixed positioning so it is never clipped by the
|
||||||
|
// horizontally scrollable table container.
|
||||||
|
setDropdownPos({ top: rect.bottom + 6, right: window.innerWidth - rect.right });
|
||||||
|
setOpenDropdownId(docId);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = () => setOpenDropdownId(null);
|
const handleClickOutside = () => setOpenDropdownId(null);
|
||||||
if (openDropdownId) {
|
if (openDropdownId) {
|
||||||
document.addEventListener('click', handleClickOutside);
|
document.addEventListener('click', handleClickOutside);
|
||||||
return () => document.removeEventListener('click', handleClickOutside);
|
window.addEventListener('scroll', handleClickOutside, true);
|
||||||
|
window.addEventListener('resize', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
window.removeEventListener('scroll', handleClickOutside, true);
|
||||||
|
window.removeEventListener('resize', handleClickOutside);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [openDropdownId]);
|
}, [openDropdownId]);
|
||||||
|
|
||||||
@ -355,6 +381,43 @@ export default function UserDocumentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (doc: DocumentWithBooking) => {
|
||||||
|
setOpenDropdownId(null);
|
||||||
|
setDocumentToDelete(doc);
|
||||||
|
setShowDeleteModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDeleteModal = () => {
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
setDocumentToDelete(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteDocument = async () => {
|
||||||
|
if (!documentToDelete) return;
|
||||||
|
|
||||||
|
setDeletingFile(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToDelete.bookingId}/documents/${documentToDelete.id}`,
|
||||||
|
{ method: 'DELETE', credentials: 'include' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || t('deleteDocument.errorMessage'));
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(t('deleteDocument.successMessage'));
|
||||||
|
handleCloseDeleteModal();
|
||||||
|
fetchBookingsAndDocuments();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting document:', err);
|
||||||
|
alert(`${t('deleteDocument.errorMessage')}: ${err instanceof Error ? err.message : ''}`);
|
||||||
|
} finally {
|
||||||
|
setDeletingFile(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
@ -485,7 +548,7 @@ export default function UserDocumentsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Documents Table */}
|
{/* Documents Table */}
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
<div className="bg-white rounded-lg shadow overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
@ -552,7 +615,7 @@ export default function UserDocumentsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleDropdown(`${doc.bookingId}-${doc.id}`);
|
toggleDropdown(`${doc.bookingId}-${doc.id}`, e);
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center justify-center w-8 h-8 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
className="inline-flex items-center justify-center w-8 h-8 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
||||||
title={t('table.actions')}
|
title={t('table.actions')}
|
||||||
@ -564,9 +627,10 @@ export default function UserDocumentsPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{openDropdownId === `${doc.bookingId}-${doc.id}` && (
|
{openDropdownId === `${doc.bookingId}-${doc.id}` && dropdownPos && (
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"
|
className="fixed w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"
|
||||||
|
style={{ top: dropdownPos.top, right: dropdownPos.right }}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
@ -611,6 +675,13 @@ export default function UserDocumentsPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
{t('actions.replace')}
|
{t('actions.replace')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteClick(doc)}
|
||||||
|
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-3" />
|
||||||
|
{t('actions.delete')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1049,6 +1120,50 @@ export default function UserDocumentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete Document Modal */}
|
||||||
|
{showDeleteModal && documentToDelete && (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-center min-h-screen px-4 py-8">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||||
|
onClick={handleCloseDeleteModal}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-md p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<Trash2 className="w-5 h-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
{t('deleteDocument.title')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
{t('deleteDocument.message', { fileName: documentToDelete.fileName })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseDeleteModal}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{t('deleteDocument.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteDocument}
|
||||||
|
disabled={deletingFile}
|
||||||
|
className="px-6 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{deletingFile ? t('deleteDocument.deleting') : t('deleteDocument.confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { Search, Loader2 } from 'lucide-react';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { Search, Loader2, Lock, Sparkles } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { getAvailableOrigins, getAvailableDestinations, RoutePortInfo } from '@/lib/api/rates';
|
import { getAvailableOrigins, getAvailableDestinations, RoutePortInfo } from '@/lib/api/rates';
|
||||||
|
import { useReservationQuota } from '@/hooks/useReservationQuota';
|
||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
const PortRouteMapLoader = () => {
|
const PortRouteMapLoader = () => {
|
||||||
@ -50,9 +53,29 @@ interface SearchForm {
|
|||||||
t1Document: boolean;
|
t1Document: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Options & services persisted with the booking (order irrelevant).
|
||||||
|
const OPTION_KEYS: (keyof SearchForm)[] = [
|
||||||
|
'eurDocument',
|
||||||
|
't1Document',
|
||||||
|
'customsStop',
|
||||||
|
'exportAssistance',
|
||||||
|
'dangerousGoods',
|
||||||
|
'regulatedProducts',
|
||||||
|
'specialHandling',
|
||||||
|
'tailgate',
|
||||||
|
'straps',
|
||||||
|
'thermalCover',
|
||||||
|
'appointment',
|
||||||
|
'insurance',
|
||||||
|
];
|
||||||
|
|
||||||
export default function AdvancedSearchPage() {
|
export default function AdvancedSearchPage() {
|
||||||
const t = useTranslations('dashboard.rateSearch');
|
const t = useTranslations('dashboard.rateSearch');
|
||||||
|
const tLimit = useTranslations('dashboard.bookingsList.limit');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const quota = useReservationQuota();
|
||||||
|
const [editBookingId, setEditBookingId] = useState<string | null>(null);
|
||||||
const [searchForm, setSearchForm] = useState<SearchForm>({
|
const [searchForm, setSearchForm] = useState<SearchForm>({
|
||||||
origin: '',
|
origin: '',
|
||||||
destination: '',
|
destination: '',
|
||||||
@ -137,6 +160,106 @@ export default function AdvancedSearchPage() {
|
|||||||
}
|
}
|
||||||
}, [searchForm.origin, destinationsData]);
|
}, [searchForm.origin, destinationsData]);
|
||||||
|
|
||||||
|
// Prefill the form when re-editing an existing unpaid booking (or from any
|
||||||
|
// deep link with query params). Runs once on mount.
|
||||||
|
useEffect(() => {
|
||||||
|
const originParam = searchParams.get('origin');
|
||||||
|
const destinationParam = searchParams.get('destination');
|
||||||
|
const editId = searchParams.get('editBookingId');
|
||||||
|
if (!originParam && !destinationParam && !editId) return;
|
||||||
|
|
||||||
|
if (editId) setEditBookingId(editId);
|
||||||
|
|
||||||
|
const vol = parseFloat(searchParams.get('volumeCBM') || '0');
|
||||||
|
const wgt = parseFloat(searchParams.get('weightKG') || '0');
|
||||||
|
const pallets = parseInt(searchParams.get('palletCount') || '0', 10);
|
||||||
|
const dg = searchParams.get('hasDangerousGoods') === 'true';
|
||||||
|
|
||||||
|
// Prefill the options & services from the "options" CSV param.
|
||||||
|
const enabledOptions = (searchParams.get('options') || '')
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const optionFlags = OPTION_KEYS.reduce(
|
||||||
|
(acc, k) => ({ ...acc, [k]: enabledOptions.includes(k) }),
|
||||||
|
{} as Record<string, boolean>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reconstruct a single representative package that reproduces the aggregate
|
||||||
|
// totals (the booking only stores totals, not individual packages).
|
||||||
|
const q = pallets > 0 ? pallets : 1;
|
||||||
|
const perVolM3 = vol > 0 ? vol / q : 0;
|
||||||
|
const isPalette = pallets > 0;
|
||||||
|
|
||||||
|
// The booking only stores aggregate totals, not per-package dimensions. For
|
||||||
|
// pallets we reconstruct the standard EUR pallet footprint (120×80 cm) and
|
||||||
|
// derive the height from the volume — so a default 0.96 CBM pallet comes back
|
||||||
|
// as 120×80×100. For loose parcels we fall back to a cube of equal volume.
|
||||||
|
let length: number;
|
||||||
|
let width: number;
|
||||||
|
let height: number;
|
||||||
|
if (isPalette) {
|
||||||
|
length = 120;
|
||||||
|
width = 80;
|
||||||
|
const baseM2 = (length / 100) * (width / 100); // 0.96 m²
|
||||||
|
height = perVolM3 > 0 ? Math.round((perVolM3 / baseM2) * 100 * 100) / 100 : 100;
|
||||||
|
} else {
|
||||||
|
const sideCm = perVolM3 > 0 ? Math.round(Math.cbrt(perVolM3) * 100 * 100) / 100 : 0;
|
||||||
|
length = sideCm;
|
||||||
|
width = sideCm;
|
||||||
|
height = sideCm;
|
||||||
|
}
|
||||||
|
const pkg = {
|
||||||
|
type: (isPalette ? 'palette' : 'colis') as Package['type'],
|
||||||
|
quantity: q,
|
||||||
|
length,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
weight: wgt > 0 ? Math.round((wgt / q) * 100) / 100 : 0,
|
||||||
|
stackable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSearchForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
...optionFlags,
|
||||||
|
origin: originParam || prev.origin,
|
||||||
|
destination: destinationParam || prev.destination,
|
||||||
|
dangerousGoods: dg || !!optionFlags.dangerousGoods,
|
||||||
|
packages: vol > 0 || wgt > 0 || pallets > 0 ? [pkg] : prev.packages,
|
||||||
|
}));
|
||||||
|
if (originParam) setOriginSearch(originParam);
|
||||||
|
if (destinationParam) setDestinationSearch(destinationParam);
|
||||||
|
// Start at the cargo (packages) step as requested.
|
||||||
|
setCurrentStep(2);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Once port lists load, resolve the prefilled codes to full port objects so
|
||||||
|
// the selection UI reflects them (and destination validation passes).
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchForm.origin && !selectedOriginPort) {
|
||||||
|
const p = (originsData?.origins || []).find(o => o.code === searchForm.origin);
|
||||||
|
if (p) {
|
||||||
|
setSelectedOriginPort(p);
|
||||||
|
setOriginSearch(p.displayName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [originsData, searchForm.origin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchForm.destination && !selectedDestinationPort) {
|
||||||
|
const p = (destinationsData?.destinations || []).find(
|
||||||
|
d => d.code === searchForm.destination
|
||||||
|
);
|
||||||
|
if (p) {
|
||||||
|
setSelectedDestinationPort(p);
|
||||||
|
setDestinationSearch(p.displayName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [destinationsData, searchForm.destination]);
|
||||||
|
|
||||||
const calculateTotals = () => {
|
const calculateTotals = () => {
|
||||||
let totalVolumeCBM = 0;
|
let totalVolumeCBM = 0;
|
||||||
let totalWeightKG = 0;
|
let totalWeightKG = 0;
|
||||||
@ -172,6 +295,14 @@ export default function AdvancedSearchPage() {
|
|||||||
requiresAppointment: searchForm.appointment.toString(),
|
requiresAppointment: searchForm.appointment.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (editBookingId) {
|
||||||
|
params.set('editBookingId', editBookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carry the selected options & services so they can be persisted.
|
||||||
|
const enabledOptions = OPTION_KEYS.filter(k => searchForm[k]).join(',');
|
||||||
|
if (enabledOptions) params.set('options', enabledOptions);
|
||||||
|
|
||||||
router.push(`/dashboard/search-advanced/results?${params.toString()}`);
|
router.push(`/dashboard/search-advanced/results?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -690,6 +821,39 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Block starting a NEW reservation when the free-plan yearly limit is reached.
|
||||||
|
// Editing an existing unpaid booking (editBookingId) does not create a new
|
||||||
|
// shipment, so it is never blocked.
|
||||||
|
const isEditing = !!searchParams.get('editBookingId');
|
||||||
|
if (quota.blocked && !isEditing && !quota.loading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-10 px-4">
|
||||||
|
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-8 text-center">
|
||||||
|
<div className="mx-auto w-14 h-14 rounded-full bg-amber-100 flex items-center justify-center mb-4">
|
||||||
|
<Lock className="h-7 w-7 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">{tLimit('title')}</h1>
|
||||||
|
<p className="text-gray-600 mb-6">{tLimit('message', { max: quota.max })}</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/subscription"
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-amber-500 text-white rounded-lg font-semibold hover:bg-amber-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-5 w-5" />
|
||||||
|
{tLimit('upgrade')}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/bookings"
|
||||||
|
className="inline-flex items-center justify-center px-6 py-3 bg-white border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
{tLimit('back')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useSearchParams } from 'next/navigation';
|
|||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
|
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
|
||||||
|
import { updateCsvBookingRate } from '@/lib/api/bookings';
|
||||||
import type { CsvRateSearchResult } from '@/types/rates';
|
import type { CsvRateSearchResult } from '@/types/rates';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
@ -38,6 +39,69 @@ export default function SearchResultsPage() {
|
|||||||
const volumeCBM = parseFloat(searchParams.get('volumeCBM') || '0');
|
const volumeCBM = parseFloat(searchParams.get('volumeCBM') || '0');
|
||||||
const weightKG = parseFloat(searchParams.get('weightKG') || '0');
|
const weightKG = parseFloat(searchParams.get('weightKG') || '0');
|
||||||
const palletCount = parseInt(searchParams.get('palletCount') || '0');
|
const palletCount = parseInt(searchParams.get('palletCount') || '0');
|
||||||
|
// When present, we are re-selecting a rate for an existing unpaid booking:
|
||||||
|
// update it in place and go straight to payment (skip the new-booking form).
|
||||||
|
const editBookingId = searchParams.get('editBookingId');
|
||||||
|
const optionsParam = searchParams.get('options') || '';
|
||||||
|
const optionsObj: Record<string, boolean> = optionsParam
|
||||||
|
? optionsParam
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.reduce((acc, k) => ({ ...acc, [k]: true }), {})
|
||||||
|
: {};
|
||||||
|
const [submittingId, setSubmittingId] = useState<string | null>(null);
|
||||||
|
const [selectError, setSelectError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSelect = async (option: CsvRateSearchResult, key: string) => {
|
||||||
|
if (editBookingId) {
|
||||||
|
setSubmittingId(key);
|
||||||
|
setSelectError(null);
|
||||||
|
try {
|
||||||
|
const pb = option.priceBreakdown;
|
||||||
|
const freightTotal = pb.totalFreight;
|
||||||
|
const freightCurrency = pb.freightCurrency;
|
||||||
|
const fobTotal = pb.totalFob;
|
||||||
|
const fobCurrency = pb.fobCurrency;
|
||||||
|
await updateCsvBookingRate(editBookingId, {
|
||||||
|
carrierName: option.companyName,
|
||||||
|
carrierEmail: option.companyEmail,
|
||||||
|
origin: option.origin,
|
||||||
|
destination: option.destination,
|
||||||
|
containerType: option.containerType,
|
||||||
|
transitDays: option.adjustedTransitDays ?? option.transitDays,
|
||||||
|
volumeCBM,
|
||||||
|
weightKG,
|
||||||
|
palletCount,
|
||||||
|
priceUSD:
|
||||||
|
(freightCurrency === 'USD' ? freightTotal : 0) +
|
||||||
|
(fobCurrency === 'USD' ? fobTotal : 0),
|
||||||
|
priceEUR:
|
||||||
|
(freightCurrency === 'EUR' ? freightTotal : 0) +
|
||||||
|
(fobCurrency === 'EUR' ? fobTotal : 0),
|
||||||
|
primaryCurrency: pb.primaryCurrency || 'USD',
|
||||||
|
freightTotal,
|
||||||
|
freightCurrency,
|
||||||
|
fobTotal,
|
||||||
|
fobCurrency,
|
||||||
|
options: optionsObj,
|
||||||
|
});
|
||||||
|
router.push(`/dashboard/booking/${editBookingId}/pay`);
|
||||||
|
} catch (err) {
|
||||||
|
setSelectError(
|
||||||
|
err instanceof Error ? err.message : 'Erreur lors de la mise à jour de la réservation'
|
||||||
|
);
|
||||||
|
setSubmittingId(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateData = encodeURIComponent(JSON.stringify(option));
|
||||||
|
const optionsQuery = optionsParam ? `&options=${encodeURIComponent(optionsParam)}` : '';
|
||||||
|
router.push(
|
||||||
|
`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}${optionsQuery}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const performSearch = useCallback(async () => {
|
const performSearch = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -260,6 +324,17 @@ export default function SearchResultsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editBookingId && (
|
||||||
|
<div className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">
|
||||||
|
{t('editMode')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectError && (
|
||||||
|
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{selectError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Best Options */}
|
{/* Best Options */}
|
||||||
{bestOptions && (
|
{bestOptions && (
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
@ -323,15 +398,15 @@ export default function SearchResultsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => handleSelect(card.option!, card.type)}
|
||||||
const rateData = encodeURIComponent(JSON.stringify(card.option));
|
disabled={submittingId === card.type}
|
||||||
router.push(
|
className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors disabled:opacity-60`}
|
||||||
`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors`}
|
|
||||||
>
|
>
|
||||||
{t('select')}
|
{submittingId === card.type
|
||||||
|
? '...'
|
||||||
|
: editBookingId
|
||||||
|
? t('selectAndPay')
|
||||||
|
: t('select')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -416,15 +491,15 @@ export default function SearchResultsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => handleSelect(result, `all-${index}`)}
|
||||||
const rateData = encodeURIComponent(JSON.stringify(result));
|
disabled={submittingId === `all-${index}`}
|
||||||
router.push(
|
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-60"
|
||||||
`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
>
|
||||||
{t('selectShort')}
|
{submittingId === `all-${index}`
|
||||||
|
? '...'
|
||||||
|
: editBookingId
|
||||||
|
? t('selectAndPay')
|
||||||
|
: t('selectShort')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -209,7 +209,7 @@ export default function LandingPage() {
|
|||||||
monthlyPrice: 0,
|
monthlyPrice: 0,
|
||||||
yearlyPrice: 0,
|
yearlyPrice: 0,
|
||||||
yearlyMonthly: 0,
|
yearlyMonthly: 0,
|
||||||
commission: '5%',
|
bookingFee: 15,
|
||||||
ctaLink: '/register',
|
ctaLink: '/register',
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
accentColor: 'from-amber-600 to-yellow-500',
|
accentColor: 'from-amber-600 to-yellow-500',
|
||||||
@ -219,10 +219,10 @@ export default function LandingPage() {
|
|||||||
{
|
{
|
||||||
key: 'silver',
|
key: 'silver',
|
||||||
badge: 'popular',
|
badge: 'popular',
|
||||||
monthlyPrice: 249,
|
monthlyPrice: 299,
|
||||||
yearlyPrice: 2739,
|
yearlyPrice: 3289,
|
||||||
yearlyMonthly: 228,
|
yearlyMonthly: 274,
|
||||||
commission: '3%',
|
bookingFee: 10,
|
||||||
ctaLink: '/register',
|
ctaLink: '/register',
|
||||||
highlighted: true,
|
highlighted: true,
|
||||||
accentColor: 'from-slate-400 to-slate-500',
|
accentColor: 'from-slate-400 to-slate-500',
|
||||||
@ -232,10 +232,10 @@ export default function LandingPage() {
|
|||||||
{
|
{
|
||||||
key: 'gold',
|
key: 'gold',
|
||||||
badge: null,
|
badge: null,
|
||||||
monthlyPrice: 899,
|
monthlyPrice: 799,
|
||||||
yearlyPrice: 9889,
|
yearlyPrice: 8789,
|
||||||
yearlyMonthly: 824,
|
yearlyMonthly: 732,
|
||||||
commission: '2%',
|
bookingFee: 5,
|
||||||
ctaLink: '/register',
|
ctaLink: '/register',
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
accentColor: 'from-yellow-400 to-amber-400',
|
accentColor: 'from-yellow-400 to-amber-400',
|
||||||
@ -248,7 +248,7 @@ export default function LandingPage() {
|
|||||||
monthlyPrice: null,
|
monthlyPrice: null,
|
||||||
yearlyPrice: null,
|
yearlyPrice: null,
|
||||||
yearlyMonthly: null,
|
yearlyMonthly: null,
|
||||||
commission: '1%',
|
bookingFee: -1,
|
||||||
ctaLink: '/contact',
|
ctaLink: '/contact',
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
accentColor: 'from-brand-navy to-brand-turquoise',
|
accentColor: 'from-brand-navy to-brand-turquoise',
|
||||||
@ -651,6 +651,10 @@ export default function LandingPage() {
|
|||||||
const planDescription = t(`pricing.plans.${plan.key}.description` as any);
|
const planDescription = t(`pricing.plans.${plan.key}.description` as any);
|
||||||
const planUsers = t(`pricing.plans.${plan.key}.users` as any);
|
const planUsers = t(`pricing.plans.${plan.key}.users` as any);
|
||||||
const planShipments = t(`pricing.plans.${plan.key}.shipments` as any);
|
const planShipments = t(`pricing.plans.${plan.key}.shipments` as any);
|
||||||
|
const planBookingFee =
|
||||||
|
plan.bookingFee === -1
|
||||||
|
? t('pricing.bookingFeeCustom')
|
||||||
|
: t('pricing.bookingFee', { price: `${plan.bookingFee}€` });
|
||||||
const planCta = t(`pricing.plans.${plan.key}.cta` as any);
|
const planCta = t(`pricing.plans.${plan.key}.cta` as any);
|
||||||
const planFeatures = planFeaturesByKey[plan.key] ?? [];
|
const planFeatures = planFeaturesByKey[plan.key] ?? [];
|
||||||
|
|
||||||
@ -777,11 +781,11 @@ export default function LandingPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BarChart3 className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
|
<Package className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}
|
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}
|
||||||
>
|
>
|
||||||
{t('pricing.commission', { rate: plan.commission })}
|
{planBookingFee}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,7 +22,7 @@ interface PlanConfig {
|
|||||||
maxUsersKey: 'unlimited' | null;
|
maxUsersKey: 'unlimited' | null;
|
||||||
maxUsers?: number;
|
maxUsers?: number;
|
||||||
shipmentsKey: 'shipmentsPerYear' | 'shipmentsUnlimited';
|
shipmentsKey: 'shipmentsPerYear' | 'shipmentsUnlimited';
|
||||||
commission: string;
|
bookingFee: number; // -1 means custom / on quote
|
||||||
supportKey: 'supportNone' | 'supportEmail' | 'supportDirect' | 'supportKam';
|
supportKey: 'supportNone' | 'supportEmail' | 'supportDirect' | 'supportKam';
|
||||||
badge: 'silver' | 'gold' | 'platinium' | null;
|
badge: 'silver' | 'gold' | 'platinium' | null;
|
||||||
ctaStyle: string;
|
ctaStyle: string;
|
||||||
@ -38,7 +38,7 @@ const PLANS: PlanConfig[] = [
|
|||||||
maxUsers: 1,
|
maxUsers: 1,
|
||||||
maxUsersKey: null,
|
maxUsersKey: null,
|
||||||
shipmentsKey: 'shipmentsPerYear',
|
shipmentsKey: 'shipmentsPerYear',
|
||||||
commission: '5%',
|
bookingFee: 15,
|
||||||
supportKey: 'supportNone',
|
supportKey: 'supportNone',
|
||||||
badge: null,
|
badge: null,
|
||||||
ctaStyle: 'bg-gray-900 text-white hover:bg-gray-800',
|
ctaStyle: 'bg-gray-900 text-white hover:bg-gray-800',
|
||||||
@ -57,12 +57,12 @@ const PLANS: PlanConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'silver',
|
key: 'silver',
|
||||||
monthlyPrice: 249,
|
monthlyPrice: 299,
|
||||||
yearlyPrice: 2739,
|
yearlyPrice: 3289,
|
||||||
maxUsers: 5,
|
maxUsers: 5,
|
||||||
maxUsersKey: null,
|
maxUsersKey: null,
|
||||||
shipmentsKey: 'shipmentsUnlimited',
|
shipmentsKey: 'shipmentsUnlimited',
|
||||||
commission: '3%',
|
bookingFee: 10,
|
||||||
supportKey: 'supportEmail',
|
supportKey: 'supportEmail',
|
||||||
badge: 'silver',
|
badge: 'silver',
|
||||||
ctaStyle: 'bg-brand-turquoise text-white hover:opacity-90',
|
ctaStyle: 'bg-brand-turquoise text-white hover:opacity-90',
|
||||||
@ -81,12 +81,12 @@ const PLANS: PlanConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'gold',
|
key: 'gold',
|
||||||
monthlyPrice: 899,
|
monthlyPrice: 799,
|
||||||
yearlyPrice: 9889,
|
yearlyPrice: 8789,
|
||||||
maxUsers: 20,
|
maxUsers: 20,
|
||||||
maxUsersKey: null,
|
maxUsersKey: null,
|
||||||
shipmentsKey: 'shipmentsUnlimited',
|
shipmentsKey: 'shipmentsUnlimited',
|
||||||
commission: '2%',
|
bookingFee: 5,
|
||||||
supportKey: 'supportDirect',
|
supportKey: 'supportDirect',
|
||||||
badge: 'gold',
|
badge: 'gold',
|
||||||
ctaStyle: 'bg-yellow-500 text-white hover:bg-yellow-600',
|
ctaStyle: 'bg-yellow-500 text-white hover:bg-yellow-600',
|
||||||
@ -109,7 +109,7 @@ const PLANS: PlanConfig[] = [
|
|||||||
yearlyPrice: -1,
|
yearlyPrice: -1,
|
||||||
maxUsersKey: 'unlimited',
|
maxUsersKey: 'unlimited',
|
||||||
shipmentsKey: 'shipmentsUnlimited',
|
shipmentsKey: 'shipmentsUnlimited',
|
||||||
commission: '1%',
|
bookingFee: -1,
|
||||||
supportKey: 'supportKam',
|
supportKey: 'supportKam',
|
||||||
badge: 'platinium',
|
badge: 'platinium',
|
||||||
ctaStyle: 'bg-purple-600 text-white hover:bg-purple-700',
|
ctaStyle: 'bg-purple-600 text-white hover:bg-purple-700',
|
||||||
@ -283,8 +283,12 @@ export default function PricingPage() {
|
|||||||
<span className="font-medium">{t(`values.${plan.shipmentsKey}`)}</span>
|
<span className="font-medium">{t(`values.${plan.shipmentsKey}`)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">{t('stats.commission')}</span>
|
<span className="text-gray-500">{t('stats.bookingFee')}</span>
|
||||||
<span className="font-medium">{plan.commission}</span>
|
<span className="font-medium">
|
||||||
|
{plan.bookingFee === -1
|
||||||
|
? t('values.bookingFeeCustom')
|
||||||
|
: t('values.bookingFeePerBooking', { price: formatPrice(plan.bookingFee) })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">{t('stats.support')}</span>
|
<span className="text-gray-500">{t('stats.support')}</span>
|
||||||
|
|||||||
@ -203,7 +203,8 @@
|
|||||||
"perMonth": "/month",
|
"perMonth": "/month",
|
||||||
"billedYearly": "Billed {price}€/year",
|
"billedYearly": "Billed {price}€/year",
|
||||||
"saveWithYearly": "Save 1 month with yearly",
|
"saveWithYearly": "Save 1 month with yearly",
|
||||||
"commission": "Commission {rate}",
|
"bookingFee": "{price} / booking",
|
||||||
|
"bookingFeeCustom": "Custom booking fee",
|
||||||
"noCommitment": "No commitment · Cancel at any time",
|
"noCommitment": "No commitment · Cancel at any time",
|
||||||
"questions": "Questions?",
|
"questions": "Questions?",
|
||||||
"contactSales": "Contact our sales team",
|
"contactSales": "Contact our sales team",
|
||||||
@ -214,7 +215,7 @@
|
|||||||
"name": "Bronze",
|
"name": "Bronze",
|
||||||
"description": "To discover the platform",
|
"description": "To discover the platform",
|
||||||
"users": "1 user",
|
"users": "1 user",
|
||||||
"shipments": "12 shipments / year",
|
"shipments": "5 bookings / year",
|
||||||
"support": "No support",
|
"support": "No support",
|
||||||
"cta": "Start for free"
|
"cta": "Start for free"
|
||||||
},
|
},
|
||||||
@ -397,6 +398,13 @@
|
|||||||
"title": "Bookings",
|
"title": "Bookings",
|
||||||
"description": "Manage and track your shipments",
|
"description": "Manage and track your shipments",
|
||||||
"new": "New Booking",
|
"new": "New Booking",
|
||||||
|
"limit": {
|
||||||
|
"title": "Reservation limit reached",
|
||||||
|
"message": "Your free plan is limited to {max} reservations per year. Upgrade your plan to keep booking.",
|
||||||
|
"upgrade": "Upgrade my plan",
|
||||||
|
"upgradeShort": "Upgrade",
|
||||||
|
"back": "Back to bookings"
|
||||||
|
},
|
||||||
"transferBanner": {
|
"transferBanner": {
|
||||||
"title": "Transfer declared",
|
"title": "Transfer declared",
|
||||||
"message": "Your transfer has been recorded. An administrator will verify receipt and activate your booking. You will be notified once validated."
|
"message": "Your transfer has been recorded. An administrator will verify receipt and activate your booking. You will be notified once validated."
|
||||||
@ -433,12 +441,16 @@
|
|||||||
},
|
},
|
||||||
"statusFilter": {
|
"statusFilter": {
|
||||||
"label": "Status",
|
"label": "Status",
|
||||||
"all": "All statuses"
|
"all": "All statuses",
|
||||||
|
"toFinalize": "To finalize"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
|
"pendingPayment": "Payment pending",
|
||||||
|
"pendingBankTransfer": "Transfer pending",
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"accepted": "Accepted",
|
"accepted": "Accepted",
|
||||||
"rejected": "Rejected"
|
"rejected": "Rejected",
|
||||||
|
"cancelled": "Cancelled"
|
||||||
},
|
},
|
||||||
"loading": "Loading bookings...",
|
"loading": "Loading bookings...",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
@ -446,6 +458,8 @@
|
|||||||
"palletsPackages": "Pallets/Packages",
|
"palletsPackages": "Pallets/Packages",
|
||||||
"weight": "Weight",
|
"weight": "Weight",
|
||||||
"route": "Route",
|
"route": "Route",
|
||||||
|
"cargo": "Cargo",
|
||||||
|
"reference": "Reference",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"quoteNumber": "Quote No.",
|
"quoteNumber": "Quote No.",
|
||||||
@ -454,7 +468,8 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"pay": "Pay"
|
"pay": "Pay",
|
||||||
|
"edit": "Edit"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Booking",
|
"title": "Booking",
|
||||||
@ -611,7 +626,9 @@
|
|||||||
},
|
},
|
||||||
"validUntil": "✓ Valid until {date}",
|
"validUntil": "✓ Valid until {date}",
|
||||||
"surcharges": "Applicable surcharges",
|
"surcharges": "Applicable surcharges",
|
||||||
"selectShort": "Select"
|
"selectShort": "Select",
|
||||||
|
"selectAndPay": "Select and pay",
|
||||||
|
"editMode": "Editing an existing booking: pick a carrier to update your booking, then proceed to payment."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notificationsPage": {
|
"notificationsPage": {
|
||||||
@ -1050,6 +1067,10 @@
|
|||||||
"weight": "Weight",
|
"weight": "Weight",
|
||||||
"volume": "Volume",
|
"volume": "Volume",
|
||||||
"priceSection": "Price",
|
"priceSection": "Price",
|
||||||
|
"freight": "Freight",
|
||||||
|
"fob": "FOB charges",
|
||||||
|
"bookingFee": "Xpeditis fee",
|
||||||
|
"transportNote": "Freight and FOB are paid to the carrier; the fee is collected by Xpeditis.",
|
||||||
"datesSection": "Dates",
|
"datesSection": "Dates",
|
||||||
"createdAt": "Created on",
|
"createdAt": "Created on",
|
||||||
"updatedAt": "Updated",
|
"updatedAt": "Updated",
|
||||||
@ -1519,7 +1540,8 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"replace": "Replace"
|
"replace": "Replace",
|
||||||
|
"delete": "Delete"
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"fileName": "File name",
|
"fileName": "File name",
|
||||||
@ -1561,6 +1583,15 @@
|
|||||||
"errorMessage": "Error replacing document",
|
"errorMessage": "Error replacing document",
|
||||||
"noFileError": "Please select a replacement file"
|
"noFileError": "Please select a replacement file"
|
||||||
},
|
},
|
||||||
|
"deleteDocument": {
|
||||||
|
"title": "Delete document",
|
||||||
|
"message": "Are you sure you want to delete \"{fileName}\"? This action cannot be undone.",
|
||||||
|
"confirm": "Delete",
|
||||||
|
"deleting": "Deleting...",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"successMessage": "Document deleted successfully!",
|
||||||
|
"errorMessage": "Error deleting document"
|
||||||
|
},
|
||||||
"downloadError": "Error downloading document"
|
"downloadError": "Error downloading document"
|
||||||
},
|
},
|
||||||
"wikiPages": {
|
"wikiPages": {
|
||||||
@ -3405,7 +3436,7 @@
|
|||||||
"stats": {
|
"stats": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"shipments": "Shipments",
|
"shipments": "Shipments",
|
||||||
"commission": "Commission",
|
"bookingFee": "Fee / booking",
|
||||||
"support": "Support"
|
"support": "Support"
|
||||||
},
|
},
|
||||||
"features": {
|
"features": {
|
||||||
@ -3421,8 +3452,10 @@
|
|||||||
},
|
},
|
||||||
"values": {
|
"values": {
|
||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited",
|
||||||
"shipmentsPerYear": "12/year",
|
"shipmentsPerYear": "5/year",
|
||||||
"shipmentsUnlimited": "Unlimited",
|
"shipmentsUnlimited": "Unlimited",
|
||||||
|
"bookingFeePerBooking": "{price} / booking",
|
||||||
|
"bookingFeeCustom": "Custom",
|
||||||
"supportNone": "None",
|
"supportNone": "None",
|
||||||
"supportEmail": "Email",
|
"supportEmail": "Email",
|
||||||
"supportDirect": "Direct",
|
"supportDirect": "Direct",
|
||||||
|
|||||||
@ -203,7 +203,8 @@
|
|||||||
"perMonth": "/mois",
|
"perMonth": "/mois",
|
||||||
"billedYearly": "Facturé {price}€/an",
|
"billedYearly": "Facturé {price}€/an",
|
||||||
"saveWithYearly": "Économisez 1 mois avec l'annuel",
|
"saveWithYearly": "Économisez 1 mois avec l'annuel",
|
||||||
"commission": "Commission {rate}",
|
"bookingFee": "Forfait {price} / booking",
|
||||||
|
"bookingFeeCustom": "Forfait booking sur mesure",
|
||||||
"noCommitment": "Sans engagement · Résiliable à tout moment",
|
"noCommitment": "Sans engagement · Résiliable à tout moment",
|
||||||
"questions": "Des questions ?",
|
"questions": "Des questions ?",
|
||||||
"contactSales": "Contactez notre équipe commerciale",
|
"contactSales": "Contactez notre équipe commerciale",
|
||||||
@ -214,7 +215,7 @@
|
|||||||
"name": "Bronze",
|
"name": "Bronze",
|
||||||
"description": "Pour découvrir la plateforme",
|
"description": "Pour découvrir la plateforme",
|
||||||
"users": "1 utilisateur",
|
"users": "1 utilisateur",
|
||||||
"shipments": "12 expéditions / an",
|
"shipments": "5 bookings / an",
|
||||||
"support": "Aucun support",
|
"support": "Aucun support",
|
||||||
"cta": "Commencer gratuitement"
|
"cta": "Commencer gratuitement"
|
||||||
},
|
},
|
||||||
@ -397,6 +398,13 @@
|
|||||||
"title": "Réservations",
|
"title": "Réservations",
|
||||||
"description": "Gérez et suivez vos envois",
|
"description": "Gérez et suivez vos envois",
|
||||||
"new": "Nouvelle Réservation",
|
"new": "Nouvelle Réservation",
|
||||||
|
"limit": {
|
||||||
|
"title": "Limite de réservations atteinte",
|
||||||
|
"message": "Votre abonnement gratuit est limité à {max} réservations par an. Passez à un abonnement supérieur pour continuer à réserver.",
|
||||||
|
"upgrade": "Améliorer mon abonnement",
|
||||||
|
"upgradeShort": "Améliorer",
|
||||||
|
"back": "Retour aux réservations"
|
||||||
|
},
|
||||||
"transferBanner": {
|
"transferBanner": {
|
||||||
"title": "Virement déclaré",
|
"title": "Virement déclaré",
|
||||||
"message": "Votre virement a été enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation."
|
"message": "Votre virement a été enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation."
|
||||||
@ -433,12 +441,16 @@
|
|||||||
},
|
},
|
||||||
"statusFilter": {
|
"statusFilter": {
|
||||||
"label": "Statut",
|
"label": "Statut",
|
||||||
"all": "Tous les statuts"
|
"all": "Tous les statuts",
|
||||||
|
"toFinalize": "À finaliser"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
|
"pendingPayment": "Paiement en attente",
|
||||||
|
"pendingBankTransfer": "Virement en attente",
|
||||||
"pending": "En attente",
|
"pending": "En attente",
|
||||||
"accepted": "Accepté",
|
"accepted": "Accepté",
|
||||||
"rejected": "Refusé"
|
"rejected": "Refusé",
|
||||||
|
"cancelled": "Annulé"
|
||||||
},
|
},
|
||||||
"loading": "Chargement des réservations...",
|
"loading": "Chargement des réservations...",
|
||||||
"search": "Rechercher",
|
"search": "Rechercher",
|
||||||
@ -446,6 +458,8 @@
|
|||||||
"palletsPackages": "Palettes/Colis",
|
"palletsPackages": "Palettes/Colis",
|
||||||
"weight": "Poids",
|
"weight": "Poids",
|
||||||
"route": "Route",
|
"route": "Route",
|
||||||
|
"cargo": "Marchandise",
|
||||||
|
"reference": "Référence",
|
||||||
"status": "Statut",
|
"status": "Statut",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"quoteNumber": "N° Devis",
|
"quoteNumber": "N° Devis",
|
||||||
@ -454,7 +468,8 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"view": "Voir",
|
"view": "Voir",
|
||||||
"pay": "Payer"
|
"pay": "Payer",
|
||||||
|
"edit": "Modifier"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Réservation",
|
"title": "Réservation",
|
||||||
@ -611,7 +626,9 @@
|
|||||||
},
|
},
|
||||||
"validUntil": "✓ Valide jusqu'au {date}",
|
"validUntil": "✓ Valide jusqu'au {date}",
|
||||||
"surcharges": "Surcharges applicables",
|
"surcharges": "Surcharges applicables",
|
||||||
"selectShort": "Sélectionner"
|
"selectShort": "Sélectionner",
|
||||||
|
"selectAndPay": "Choisir et payer",
|
||||||
|
"editMode": "Modification d'une réservation existante : choisissez une compagnie pour mettre à jour votre réservation, puis passez au paiement."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notificationsPage": {
|
"notificationsPage": {
|
||||||
@ -1050,6 +1067,10 @@
|
|||||||
"weight": "Poids",
|
"weight": "Poids",
|
||||||
"volume": "Volume",
|
"volume": "Volume",
|
||||||
"priceSection": "Prix",
|
"priceSection": "Prix",
|
||||||
|
"freight": "Fret",
|
||||||
|
"fob": "Frais FOB",
|
||||||
|
"bookingFee": "Forfait Xpeditis",
|
||||||
|
"transportNote": "Fret et FOB sont réglés au transporteur ; le forfait est encaissé par Xpeditis.",
|
||||||
"datesSection": "Dates",
|
"datesSection": "Dates",
|
||||||
"createdAt": "Créée le",
|
"createdAt": "Créée le",
|
||||||
"updatedAt": "Mise à jour",
|
"updatedAt": "Mise à jour",
|
||||||
@ -1519,7 +1540,8 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"replace": "Remplacer"
|
"replace": "Remplacer",
|
||||||
|
"delete": "Supprimer"
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"fileName": "Nom du fichier",
|
"fileName": "Nom du fichier",
|
||||||
@ -1561,6 +1583,15 @@
|
|||||||
"errorMessage": "Erreur lors du remplacement du document",
|
"errorMessage": "Erreur lors du remplacement du document",
|
||||||
"noFileError": "Veuillez sélectionner un fichier de remplacement"
|
"noFileError": "Veuillez sélectionner un fichier de remplacement"
|
||||||
},
|
},
|
||||||
|
"deleteDocument": {
|
||||||
|
"title": "Supprimer le document",
|
||||||
|
"message": "Êtes-vous sûr de vouloir supprimer « {fileName} » ? Cette action est irréversible.",
|
||||||
|
"confirm": "Supprimer",
|
||||||
|
"deleting": "Suppression en cours...",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"successMessage": "Document supprimé avec succès!",
|
||||||
|
"errorMessage": "Erreur lors de la suppression du document"
|
||||||
|
},
|
||||||
"downloadError": "Erreur lors du téléchargement du document"
|
"downloadError": "Erreur lors du téléchargement du document"
|
||||||
},
|
},
|
||||||
"wikiPages": {
|
"wikiPages": {
|
||||||
@ -3411,7 +3442,7 @@
|
|||||||
"stats": {
|
"stats": {
|
||||||
"users": "Utilisateurs",
|
"users": "Utilisateurs",
|
||||||
"shipments": "Expéditions",
|
"shipments": "Expéditions",
|
||||||
"commission": "Commission",
|
"bookingFee": "Forfait / booking",
|
||||||
"support": "Support"
|
"support": "Support"
|
||||||
},
|
},
|
||||||
"features": {
|
"features": {
|
||||||
@ -3427,8 +3458,10 @@
|
|||||||
},
|
},
|
||||||
"values": {
|
"values": {
|
||||||
"unlimited": "Illimité",
|
"unlimited": "Illimité",
|
||||||
"shipmentsPerYear": "12/an",
|
"shipmentsPerYear": "5/an",
|
||||||
"shipmentsUnlimited": "Illimitées",
|
"shipmentsUnlimited": "Illimitées",
|
||||||
|
"bookingFeePerBooking": "{price} / booking",
|
||||||
|
"bookingFeeCustom": "Sur mesure",
|
||||||
"supportNone": "Aucun",
|
"supportNone": "Aucun",
|
||||||
"supportEmail": "Email",
|
"supportEmail": "Email",
|
||||||
"supportDirect": "Direct",
|
"supportDirect": "Direct",
|
||||||
|
|||||||
40
apps/frontend/src/hooks/useReservationQuota.ts
Normal file
40
apps/frontend/src/hooks/useReservationQuota.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getReservationQuota } from '@/lib/api/bookings';
|
||||||
|
import { useAuth } from '@/lib/context/auth-context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reservation (paid shipment) quota for the current plan.
|
||||||
|
*
|
||||||
|
* The free (Bronze) plan is limited to a number of PAID reservations per year
|
||||||
|
* (`maxShipmentsPerYear`, e.g. 5); higher plans are unlimited. The value comes
|
||||||
|
* from the backend, which uses the organization's real plan (not the ADMIN
|
||||||
|
* PLATINIUM override) and counts only paid shipments — so the UI can prompt an
|
||||||
|
* upgrade once the limit is reached instead of failing at the end of the flow.
|
||||||
|
*/
|
||||||
|
export function useReservationQuota() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['reservation-quota'],
|
||||||
|
queryFn: getReservationQuota,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const max = data?.max ?? -1;
|
||||||
|
const used = data?.used ?? 0;
|
||||||
|
const unlimited = data?.unlimited ?? true;
|
||||||
|
const limitReached = data?.limitReached ?? false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading: isLoading,
|
||||||
|
max,
|
||||||
|
used,
|
||||||
|
remaining: unlimited ? Infinity : Math.max(0, max - used),
|
||||||
|
unlimited,
|
||||||
|
limitReached,
|
||||||
|
blocked: limitReached,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -197,12 +197,22 @@ export async function getOrganizationDocuments(organizationId: string): Promise<
|
|||||||
|
|
||||||
// ==================== BLOG ====================
|
// ==================== BLOG ====================
|
||||||
|
|
||||||
|
export interface BlogFaqItem {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BlogPostSeoFields {
|
export interface BlogPostSeoFields {
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
primaryKeyword?: string;
|
primaryKeyword?: string;
|
||||||
secondaryKeywords?: string[];
|
secondaryKeywords?: string[];
|
||||||
scheduledAt?: string;
|
scheduledAt?: string;
|
||||||
|
// GEO (Generative Engine Optimisation)
|
||||||
|
aiSummary?: string | null;
|
||||||
|
faq?: BlogFaqItem[];
|
||||||
|
keyTakeaways?: string[];
|
||||||
|
aiEntities?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBlogPostRequest extends BlogPostSeoFields {
|
export interface CreateBlogPostRequest extends BlogPostSeoFields {
|
||||||
@ -210,7 +220,7 @@ export interface CreateBlogPostRequest extends BlogPostSeoFields {
|
|||||||
slug: string;
|
slug: string;
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
content: string;
|
content: string;
|
||||||
coverImageUrl?: string;
|
coverImageUrl?: string | null;
|
||||||
category: BlogPostCategory;
|
category: BlogPostCategory;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
authorName: string;
|
authorName: string;
|
||||||
@ -221,7 +231,7 @@ export interface UpdateBlogPostRequest extends BlogPostSeoFields {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
coverImageUrl?: string;
|
coverImageUrl?: string | null;
|
||||||
category?: BlogPostCategory;
|
category?: BlogPostCategory;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
authorName?: string;
|
authorName?: string;
|
||||||
@ -233,11 +243,13 @@ export async function getAllBlogPosts(params?: {
|
|||||||
status?: BlogPostStatus;
|
status?: BlogPostStatus;
|
||||||
category?: BlogPostCategory;
|
category?: BlogPostCategory;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
trashed?: boolean;
|
||||||
}): Promise<{ posts: BlogPost[]; total: number }> {
|
}): Promise<{ posts: BlogPost[]; total: number }> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.status) query.set('status', params.status);
|
if (params?.status) query.set('status', params.status);
|
||||||
if (params?.category) query.set('category', params.category);
|
if (params?.category) query.set('category', params.category);
|
||||||
if (params?.search) query.set('search', params.search);
|
if (params?.search) query.set('search', params.search);
|
||||||
|
if (params?.trashed) query.set('trashed', 'true');
|
||||||
const qs = query.toString();
|
const qs = query.toString();
|
||||||
return get(`/api/v1/admin/blog${qs ? `?${qs}` : ''}`);
|
return get(`/api/v1/admin/blog${qs ? `?${qs}` : ''}`);
|
||||||
}
|
}
|
||||||
@ -250,6 +262,22 @@ export async function updateBlogPost(id: string, data: UpdateBlogPostRequest): P
|
|||||||
return patch<BlogPost>(`/api/v1/admin/blog/${id}`, data);
|
return patch<BlogPost>(`/api/v1/admin/blog/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Move a post to the trash (soft delete). */
|
||||||
export async function deleteBlogPost(id: string): Promise<void> {
|
export async function deleteBlogPost(id: string): Promise<void> {
|
||||||
return del<void>(`/api/v1/admin/blog/${id}`);
|
return del<void>(`/api/v1/admin/blog/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Restore a post from the trash. */
|
||||||
|
export async function restoreBlogPost(id: string): Promise<BlogPost> {
|
||||||
|
return post<BlogPost>(`/api/v1/admin/blog/${id}/restore`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permanently delete a post from the trash. */
|
||||||
|
export async function permanentlyDeleteBlogPost(id: string): Promise<void> {
|
||||||
|
return del<void>(`/api/v1/admin/blog/${id}/permanent`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Duplicate a post as a new draft. */
|
||||||
|
export async function duplicateBlogPost(id: string): Promise<BlogPost> {
|
||||||
|
return post<BlogPost>(`/api/v1/admin/blog/${id}/duplicate`, {});
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,11 @@ import { get } from './client';
|
|||||||
export type BlogPostStatus = 'draft' | 'scheduled' | 'published' | 'archived';
|
export type BlogPostStatus = 'draft' | 'scheduled' | 'published' | 'archived';
|
||||||
export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news';
|
export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news';
|
||||||
|
|
||||||
|
export interface BlogFaqItem {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BlogPost {
|
export interface BlogPost {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -20,6 +25,11 @@ export interface BlogPost {
|
|||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
primaryKeyword?: string;
|
primaryKeyword?: string;
|
||||||
secondaryKeywords: string[];
|
secondaryKeywords: string[];
|
||||||
|
// GEO (Generative Engine Optimisation)
|
||||||
|
aiSummary?: string | null;
|
||||||
|
faq?: BlogFaqItem[];
|
||||||
|
keyTakeaways?: string[];
|
||||||
|
aiEntities?: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,6 +66,7 @@ export interface CsvBookingResponse {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
commissionRate?: number;
|
commissionRate?: number;
|
||||||
commissionAmountEur?: number;
|
commissionAmountEur?: number;
|
||||||
|
options?: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommissionPaymentResponse {
|
export interface CommissionPaymentResponse {
|
||||||
@ -255,6 +256,21 @@ export async function getCsvBookingStats(): Promise<CsvBookingStatsResponse> {
|
|||||||
return get<CsvBookingStatsResponse>('/api/v1/csv-bookings/stats');
|
return get<CsvBookingStatsResponse>('/api/v1/csv-bookings/stats');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reservation (paid shipment) quota for the current organization.
|
||||||
|
* GET /api/v1/csv-bookings/reservation-quota
|
||||||
|
*/
|
||||||
|
export interface ReservationQuotaResponse {
|
||||||
|
max: number;
|
||||||
|
used: number;
|
||||||
|
unlimited: boolean;
|
||||||
|
limitReached: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReservationQuota(): Promise<ReservationQuotaResponse> {
|
||||||
|
return get<ReservationQuotaResponse>('/api/v1/csv-bookings/reservation-quota');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel a pending CSV booking
|
* Cancel a pending CSV booking
|
||||||
* PATCH /api/v1/csv-bookings/:id/cancel
|
* PATCH /api/v1/csv-bookings/:id/cancel
|
||||||
@ -263,6 +279,64 @@ export async function cancelCsvBooking(id: string): Promise<SuccessResponse> {
|
|||||||
return patch<SuccessResponse>(`/api/v1/csv-bookings/${id}/cancel`, {});
|
return patch<SuccessResponse>(`/api/v1/csv-bookings/${id}/cancel`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update cargo details of a booking awaiting payment
|
||||||
|
* PATCH /api/v1/csv-bookings/:id/details
|
||||||
|
*/
|
||||||
|
export interface UpdateCsvBookingDetailsRequest {
|
||||||
|
volumeCBM?: number;
|
||||||
|
weightKG?: number;
|
||||||
|
palletCount?: number;
|
||||||
|
notes?: string;
|
||||||
|
// Recomputed pricing (optional; sent when cargo details change)
|
||||||
|
priceUSD?: number;
|
||||||
|
priceEUR?: number;
|
||||||
|
primaryCurrency?: string;
|
||||||
|
freightTotal?: number;
|
||||||
|
freightCurrency?: string;
|
||||||
|
fobTotal?: number;
|
||||||
|
fobCurrency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCsvBookingDetails(
|
||||||
|
id: string,
|
||||||
|
data: UpdateCsvBookingDetailsRequest
|
||||||
|
): Promise<CsvBookingResponse> {
|
||||||
|
return patch<CsvBookingResponse>(`/api/v1/csv-bookings/${id}/details`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-apply a full rate selection to a booking awaiting payment
|
||||||
|
* PATCH /api/v1/csv-bookings/:id/rate
|
||||||
|
*/
|
||||||
|
export interface UpdateCsvBookingRateRequest {
|
||||||
|
carrierName: string;
|
||||||
|
carrierEmail: string;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
containerType: string;
|
||||||
|
transitDays: number;
|
||||||
|
volumeCBM: number;
|
||||||
|
weightKG: number;
|
||||||
|
palletCount: number;
|
||||||
|
priceUSD: number;
|
||||||
|
priceEUR: number;
|
||||||
|
primaryCurrency: string;
|
||||||
|
freightTotal?: number;
|
||||||
|
freightCurrency?: string;
|
||||||
|
fobTotal?: number;
|
||||||
|
fobCurrency?: string;
|
||||||
|
notes?: string;
|
||||||
|
options?: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCsvBookingRate(
|
||||||
|
id: string,
|
||||||
|
data: UpdateCsvBookingRateRequest
|
||||||
|
): Promise<CsvBookingResponse> {
|
||||||
|
return patch<CsvBookingResponse>(`/api/v1/csv-bookings/${id}/rate`, data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept a CSV booking (public endpoint, no auth required)
|
* Accept a CSV booking (public endpoint, no auth required)
|
||||||
* POST /api/v1/csv-bookings/:token/accept
|
* POST /api/v1/csv-bookings/:token/accept
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export interface PlanDetails {
|
|||||||
monthlyPriceEur: number;
|
monthlyPriceEur: number;
|
||||||
yearlyPriceEur: number;
|
yearlyPriceEur: number;
|
||||||
maxShipmentsPerYear: number;
|
maxShipmentsPerYear: number;
|
||||||
commissionRatePercent: number;
|
bookingFeeEur: number;
|
||||||
supportLevel: 'none' | 'email' | 'direct' | 'dedicated_kam';
|
supportLevel: 'none' | 'email' | 'direct' | 'dedicated_kam';
|
||||||
statusBadge: 'none' | 'silver' | 'gold' | 'platinium';
|
statusBadge: 'none' | 'silver' | 'gold' | 'platinium';
|
||||||
planFeatures: PlanFeature[];
|
planFeatures: PlanFeature[];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user