This commit is contained in:
David 2026-05-12 21:01:52 +02:00
parent f5eaa4e083
commit 3d65693395
168 changed files with 13167 additions and 5483 deletions

View File

@ -0,0 +1,840 @@
{
"name": "frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"@tiptap/extension-color": "^3.23.1",
"@tiptap/extension-highlight": "^3.23.1",
"@tiptap/extension-image": "^3.23.1",
"@tiptap/extension-link": "^3.23.1",
"@tiptap/extension-placeholder": "^3.23.1",
"@tiptap/extension-text-align": "^3.23.1",
"@tiptap/extension-text-style": "^3.23.1",
"@tiptap/extension-underline": "^3.23.1",
"@tiptap/react": "^3.23.1",
"@tiptap/starter-kit": "^3.23.1"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT",
"optional": true
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tiptap/core": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.1.tgz",
"integrity": "sha512-8YvSGiJTeU5wPuGiYIIYgyiyaaT1CAx+kJL0bju0w871OvbJJj0T/ywhcmxGXW6pOal2T8X2xt9ZqE+vib0VJw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.23.1.tgz",
"integrity": "sha512-FdVZLZOkL06j3WLXOC2UeX7++Cj3qI2vfohruMJiz4vk1Q5UUH7G4+AykFzjzBJHrdEpkiRUkRpU1KZIWdbluw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.23.1.tgz",
"integrity": "sha512-EAYdNzyOjlQh2VBY1EhdxtiTjVMaOAD6P0ezms60dKRjd4oj/8grfXfUqwgo4NVdFb11Ks85vXoHuXJSylfR4A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.23.1.tgz",
"integrity": "sha512-1advMCpPkHD/3ucZhYmNau8B4tF0L6iRAFhUOglp5bBZDuq13+rYujh3cm4vFmjH9KqThzpcUDn+ZU2c+mTMyw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.23.1.tgz",
"integrity": "sha512-owWnBBI4t+jqVDY0naDjhsAmrNGldh4czouef2K+mEf032B7uGsDVCwKp1qaX1JZesyYDfvXOaIwT22hNID2mw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.1"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.23.1.tgz",
"integrity": "sha512-nGuhb4YghgTfkejwWHrD9GSpwcC5kkVmm2sN/UY4yceDw+PkyysYKJWZehRLTOC8GNgSAhq/EeQeq14Xwk6dyg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.23.1.tgz",
"integrity": "sha512-BdJGqM57CsKgYrQUZz78vIG8Yn7EpsE2pA7iKn5tYoSXpYtt0IaU4qB1heH7lwWD/vVCAm0YQVD7/0F+0++yhA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/extension-color": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.23.1.tgz",
"integrity": "sha512-OYk/fT3h8Bz4B6GUVTQDvKGpPnpI5d6QHkuqjVhdFsgH3oo58PdLE1TdIGgeavuYPLaFxgBtEXmm3oTY9jPWxw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-text-style": "3.23.1"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.23.1.tgz",
"integrity": "sha512-NA5Rx59HRwG6Hb6LwLpC5lE7z6vCj6f90S7RNNsnE+CyiXNR/OhY2BcjuxiGnascHvsnsAbvxGU3ymKMDgvDVg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.23.1.tgz",
"integrity": "sha512-WRN7e/h9m3uI5j9/+L6jcPhHbTL6aKxfFfQWZHNf5M8TqSL1P+/2h034td0XMj3n48i4fWyzjVUV9+sz6t2fDw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.1"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.23.1.tgz",
"integrity": "sha512-XrYHpLn1DpLFSGTko9F9xgbNamL6fGpWkK4wqgwPVbg/SJwQCDO/9p5D3DtJTwD+xgw4sQ9as4O6rt6jx8JT+Q==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.23.1.tgz",
"integrity": "sha512-E4hB0xquUpEXy7kboLBazrFyRCsN0j0fsTFR8udgQf5xetAVPhOexSTKuzOcU/n0kxsKJin7laYYEag/Fd2KNw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.1"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.23.1.tgz",
"integrity": "sha512-XYkCKC5RVqMmmBk+nd22/6IDDx1OC54sdStH5VEHtfOrarriO0JztK8Mr0TijPPk9N4rKXsmndYZM2xyWZZytQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.23.1.tgz",
"integrity": "sha512-1z9yCSp8fevgX3r/4kWXO3of0WFCQWfYjWfHANvoJ4JQTYBkARjXlj1tbk5rrAJBFDDfKRkUpZOurXKgGo+h+g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-highlight": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.23.1.tgz",
"integrity": "sha512-Bf5u5KBb6SLveKzPgUEbHoU5uWTUcEvSSZr/mpQZpvCpE6MlXZbvengmFj1OkKnrNZWg0Um2p9e6zKnZHH07sA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.23.1.tgz",
"integrity": "sha512-30XUHXdEZxcz1FCWjz9HW2EEq06NQcAye6rXGnvHo6Y60iJ6MRsrX5byvceFNF9DTVtOIcUFBQ/psIiRcoi0KA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/extension-image": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.23.1.tgz",
"integrity": "sha512-rAyfh8HS0PfXS8PKl1VQUiDFzXtF5SlrILpOPmz+4Oc4pmI+/vN+ain4z8k6HRxWM03YVpvLvyeQ0OFwi/fq3A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.1.tgz",
"integrity": "sha512-lZB9YCjoVNDoPMguya66nBvaS/2YpGN5iAcjAGx/JQkCAZeOAtl9+ALMzbWPKH6tQP6m98YtkY1T7RXr++T0bA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.23.1.tgz",
"integrity": "sha512-uOeyLqYQI0WG62agpFG24kVHSn3Z48gD8Y0uLLJbtzh/nDFC3d9So2sQGWlSVyMzsgkJ4k/9jNnxxsVO8qgJOg==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.1.tgz",
"integrity": "sha512-v1AeXPpagslgRZdOp7WdjCoO4TjjNP8RM2R6Gqx0/inGaNXnM8zCMshOxZlAb03Ad7kq/4RGJmkpM/Jjsi6dEQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.23.1.tgz",
"integrity": "sha512-Fk/884un5OSLCFxe2TbOmfp3sLMB5b76CnMjaSrvgfiaZnsV2WlJZGPXxCAPbxNIATTykNlSBsVuMBO7we64Vg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.1"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.23.1.tgz",
"integrity": "sha512-sHbE5sxiJzhgGn94GUAzD4qKM9SyImBrOlAGS/EIe+pausjqQE7xi+YW0gRo2jG+gXhSYl4/oAGXQXzmSInSUQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.1"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.23.1.tgz",
"integrity": "sha512-3GG7YFhVJWw/HWmRxvMMUC296x7TPBQRLsH4ryEC1SMAmVJnbTIvetyvIcLqLEXGW7Rj41S7SO8qjOXVceSOTA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.1"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.23.1.tgz",
"integrity": "sha512-GC7b6yAjASl1q9sNkPmukZmVYMfxx03EEhpMMrLYJY9GBz82Ald927yYQsOqf2aKA/Rjo/aZMYCGtjXkGk6aBA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.23.1.tgz",
"integrity": "sha512-eKmQhDt+51GIPxcFXoT8wmS+ejt6evIiHtXBgsLaABG6wg9GHnpGEndPcXsDGVR1s5ZLawVB52PFCX5GqKoWlQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.1"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.23.1.tgz",
"integrity": "sha512-+R5LG0ZW9SDZc4weA79uq6uUduVsCEph9tRcoQCRA82IVIiPYSTxTLew9odalmk/Mc7vdZvOK5jjtO5jUVw/rg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.23.1.tgz",
"integrity": "sha512-k1Ki9bBV6mLz1mFP+Laqh1YHJ2MY0P8XzaMqpkgMndEBIJQ3XcpWQc5bfAlRnYcOI9ZXDbAgQ8CwgArxHmQWCQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-text-align": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.23.1.tgz",
"integrity": "sha512-ap4ZN31v57mVX2P+0OoW5iO+ehsUNe0C5MgF/Ta2F/HRmTCc1M1mFqYUCk8zJYX1TFRV18vqK2j6STRBk0R8ng==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.23.1.tgz",
"integrity": "sha512-q3GQQo+lBhrtNkqdbhYWnv/byG/RYAxVnNhYPQMubRzavGdXBU8NhpJ/47YYjPimG1sahzcs2aqy7amVd8ri/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.23.1.tgz",
"integrity": "sha512-+PvHyVozHyxJ9oWCIQx5JHBZ7LAa/sFJUOFaKyfmel4gL9AbP52MmvrciXARlZHd1WCULJtdbLan0+x5/D/9hQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.1.tgz",
"integrity": "sha512-7UIn+idaVTVhdlP0KmgzBh8Csmwck357Dq4te5DuAxhSkN1gsXHlq39mpx907UYKJdSOgd+GMFeyOziPwSmbOQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1"
}
},
"node_modules/@tiptap/pm": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.1.tgz",
"integrity": "sha512-8G+TkNsUHHAAJYREpA6fw+Dw/m2Y3Go4/QMQM8RYepid+wTeE1wSv7sBA/CBrphhYmJSWeTyCPtgQIxnTJXMCA==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.23.1.tgz",
"integrity": "sha512-43zUwKOcsxRIcgiDbcEUagojhPIez2OIryaNG/uiDcRzkrUteiTu2wSJndkQqwouwh3wJEm+KOw8xybNYvU+qA==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.23.1",
"@tiptap/extension-floating-menu": "^3.23.1"
},
"peerDependencies": {
"@tiptap/core": "3.23.1",
"@tiptap/pm": "3.23.1",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.23.1.tgz",
"integrity": "sha512-CURePHQagBaZIDJrHH3of4Nmi0VYGpZ6yBlkdFxFHBxY9aeG2/h5kn+oHo8GbzkSFsRV+9olzRgDTOULVgs8pQ==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.23.1",
"@tiptap/extension-blockquote": "^3.23.1",
"@tiptap/extension-bold": "^3.23.1",
"@tiptap/extension-bullet-list": "^3.23.1",
"@tiptap/extension-code": "^3.23.1",
"@tiptap/extension-code-block": "^3.23.1",
"@tiptap/extension-document": "^3.23.1",
"@tiptap/extension-dropcursor": "^3.23.1",
"@tiptap/extension-gapcursor": "^3.23.1",
"@tiptap/extension-hard-break": "^3.23.1",
"@tiptap/extension-heading": "^3.23.1",
"@tiptap/extension-horizontal-rule": "^3.23.1",
"@tiptap/extension-italic": "^3.23.1",
"@tiptap/extension-link": "^3.23.1",
"@tiptap/extension-list": "^3.23.1",
"@tiptap/extension-list-item": "^3.23.1",
"@tiptap/extension-list-keymap": "^3.23.1",
"@tiptap/extension-ordered-list": "^3.23.1",
"@tiptap/extension-paragraph": "^3.23.1",
"@tiptap/extension-strike": "^3.23.1",
"@tiptap/extension-text": "^3.23.1",
"@tiptap/extension-underline": "^3.23.1",
"@tiptap/extensions": "^3.23.1",
"@tiptap/pm": "^3.23.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
},
"engines": {
"node": ">=4"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT",
"peer": true
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.6"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT",
"peer": true
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
"license": "MIT",
"peer": true
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
}
}
}

View File

@ -0,0 +1,15 @@
{
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"@tiptap/extension-color": "^3.23.1",
"@tiptap/extension-highlight": "^3.23.1",
"@tiptap/extension-image": "^3.23.1",
"@tiptap/extension-link": "^3.23.1",
"@tiptap/extension-placeholder": "^3.23.1",
"@tiptap/extension-text-align": "^3.23.1",
"@tiptap/extension-text-style": "^3.23.1",
"@tiptap/extension-underline": "^3.23.1",
"@tiptap/react": "^3.23.1",
"@tiptap/starter-kit": "^3.23.1"
}
}

View File

@ -1,114 +1,113 @@
/** /**
* Script pour créer un booking de test avec statut PENDING * Script pour créer un booking de test avec statut PENDING
* Usage: node create-test-booking.js * Usage: node create-test-booking.js
*/ */
const { Client } = require('pg'); const { Client } = require('pg');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
async function createTestBooking() { async function createTestBooking() {
const client = new Client({ const client = new Client({
host: process.env.DATABASE_HOST || 'localhost', host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT || '5432'), port: parseInt(process.env.DATABASE_PORT || '5432'),
database: process.env.DATABASE_NAME || 'xpeditis_dev', database: process.env.DATABASE_NAME || 'xpeditis_dev',
user: process.env.DATABASE_USER || 'xpeditis', user: process.env.DATABASE_USER || 'xpeditis',
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
}); });
try { try {
await client.connect(); await client.connect();
console.log('✅ Connecté à la base de données'); console.log('✅ Connecté à la base de données');
const bookingId = uuidv4(); const bookingId = uuidv4();
const confirmationToken = uuidv4(); const confirmationToken = uuidv4();
const userId = '8cf7d5b3-d94f-44aa-bb5a-080002919dd1'; // User demo@xpeditis.com const userId = '8cf7d5b3-d94f-44aa-bb5a-080002919dd1'; // User demo@xpeditis.com
const organizationId = '199fafa9-d26f-4cf9-9206-73432baa8f63'; const organizationId = '199fafa9-d26f-4cf9-9206-73432baa8f63';
// Create dummy documents in JSONB format // Create dummy documents in JSONB format
const dummyDocuments = JSON.stringify([ const dummyDocuments = JSON.stringify([
{ {
id: uuidv4(), id: uuidv4(),
type: 'BILL_OF_LADING', type: 'BILL_OF_LADING',
fileName: 'bill-of-lading.pdf', fileName: 'bill-of-lading.pdf',
filePath: 'https://dummy-storage.com/documents/bill-of-lading.pdf', filePath: 'https://dummy-storage.com/documents/bill-of-lading.pdf',
mimeType: 'application/pdf', mimeType: 'application/pdf',
size: 102400, // 100KB size: 102400, // 100KB
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
}, },
{ {
id: uuidv4(), id: uuidv4(),
type: 'PACKING_LIST', type: 'PACKING_LIST',
fileName: 'packing-list.pdf', fileName: 'packing-list.pdf',
filePath: 'https://dummy-storage.com/documents/packing-list.pdf', filePath: 'https://dummy-storage.com/documents/packing-list.pdf',
mimeType: 'application/pdf', mimeType: 'application/pdf',
size: 51200, // 50KB size: 51200, // 50KB
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
}, },
{ {
id: uuidv4(), id: uuidv4(),
type: 'COMMERCIAL_INVOICE', type: 'COMMERCIAL_INVOICE',
fileName: 'commercial-invoice.pdf', fileName: 'commercial-invoice.pdf',
filePath: 'https://dummy-storage.com/documents/commercial-invoice.pdf', filePath: 'https://dummy-storage.com/documents/commercial-invoice.pdf',
mimeType: 'application/pdf', mimeType: 'application/pdf',
size: 76800, // 75KB size: 76800, // 75KB
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
}, },
]); ]);
const query = ` const query = `
INSERT INTO csv_bookings ( INSERT INTO csv_bookings (
id, user_id, organization_id, carrier_name, carrier_email, id, user_id, organization_id, carrier_name, carrier_email,
origin, destination, volume_cbm, weight_kg, pallet_count, origin, destination, volume_cbm, weight_kg, pallet_count,
price_usd, price_eur, primary_currency, transit_days, container_type, price_usd, price_eur, primary_currency, transit_days, container_type,
status, confirmation_token, requested_at, notes, documents status, confirmation_token, requested_at, notes, documents
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19 $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19
) RETURNING id, confirmation_token; ) RETURNING id, confirmation_token;
`; `;
const values = [ const values = [
bookingId, bookingId,
userId, userId,
organizationId, organizationId,
'Test Carrier', 'Test Carrier',
'test@carrier.com', 'test@carrier.com',
'NLRTM', // Rotterdam 'NLRTM', // Rotterdam
'USNYC', // New York 'USNYC', // New York
25.5, // volume_cbm 25.5, // volume_cbm
3500, // weight_kg 3500, // weight_kg
10, // pallet_count 10, // pallet_count
1850.50, // price_usd 1850.5, // price_usd
1665.45, // price_eur 1665.45, // price_eur
'USD', // primary_currency 'USD', // primary_currency
28, // transit_days 28, // transit_days
'LCL', // container_type 'LCL', // container_type
'PENDING', // status - IMPORTANT! 'PENDING', // status - IMPORTANT!
confirmationToken, confirmationToken,
'Test booking created by script', 'Test booking created by script',
dummyDocuments, // documents JSONB dummyDocuments, // documents JSONB
]; ];
const result = await client.query(query, values); const result = await client.query(query, values);
console.log('\n🎉 Booking de test créé avec succès!'); console.log('\n🎉 Booking de test créé avec succès!');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`📦 Booking ID: ${bookingId}`); console.log(`📦 Booking ID: ${bookingId}`);
console.log(`🔑 Token: ${confirmationToken}`); console.log(`🔑 Token: ${confirmationToken}`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log('🔗 URLs de test:'); console.log('🔗 URLs de test:');
console.log(` Accept: http://localhost:3000/carrier/accept/${confirmationToken}`); console.log(` Accept: http://localhost:3000/carrier/accept/${confirmationToken}`);
console.log(` Reject: http://localhost:3000/carrier/reject/${confirmationToken}`); console.log(` Reject: http://localhost:3000/carrier/reject/${confirmationToken}`);
console.log('\n📧 URL API (pour curl):'); console.log('\n📧 URL API (pour curl):');
console.log(` curl http://localhost:4000/api/v1/csv-bookings/accept/${confirmationToken}`); console.log(` curl http://localhost:4000/api/v1/csv-bookings/accept/${confirmationToken}`);
console.log('\n✅ Ce booking est en statut PENDING et peut être accepté/refusé.\n'); console.log('\n✅ Ce booking est en statut PENDING et peut être accepté/refusé.\n');
} catch (error) {
} catch (error) { console.error('❌ Erreur:', error.message);
console.error('❌ Erreur:', error.message); console.error(error);
console.error(error); } finally {
} finally { await client.end();
await client.end(); }
} }
}
createTestBooking();
createTestBooking();

View File

@ -1,321 +1,324 @@
/** /**
* Script de debug pour tester le flux complet d'envoi d'email * Script de debug pour tester le flux complet d'envoi d'email
* *
* Ce script teste: * Ce script teste:
* 1. Connexion SMTP * 1. Connexion SMTP
* 2. Envoi d'un email simple * 2. Envoi d'un email simple
* 3. Envoi avec le template complet * 3. Envoi avec le template complet
*/ */
require('dotenv').config(); require('dotenv').config();
const nodemailer = require('nodemailer'); const nodemailer = require('nodemailer');
console.log('\n🔍 DEBUG - Flux d\'envoi d\'email transporteur\n'); console.log("\n🔍 DEBUG - Flux d'envoi d'email transporteur\n");
console.log('='.repeat(60)); console.log('='.repeat(60));
// 1. Afficher la configuration // 1. Afficher la configuration
console.log('\n📋 CONFIGURATION ACTUELLE:'); console.log('\n📋 CONFIGURATION ACTUELLE:');
console.log('----------------------------'); console.log('----------------------------');
console.log('SMTP_HOST:', process.env.SMTP_HOST); console.log('SMTP_HOST:', process.env.SMTP_HOST);
console.log('SMTP_PORT:', process.env.SMTP_PORT); console.log('SMTP_PORT:', process.env.SMTP_PORT);
console.log('SMTP_SECURE:', process.env.SMTP_SECURE); console.log('SMTP_SECURE:', process.env.SMTP_SECURE);
console.log('SMTP_USER:', process.env.SMTP_USER); console.log('SMTP_USER:', process.env.SMTP_USER);
console.log('SMTP_PASS:', process.env.SMTP_PASS ? '***' + process.env.SMTP_PASS.slice(-4) : 'NON DÉFINI'); console.log(
console.log('SMTP_FROM:', process.env.SMTP_FROM); 'SMTP_PASS:',
console.log('APP_URL:', process.env.APP_URL); process.env.SMTP_PASS ? '***' + process.env.SMTP_PASS.slice(-4) : 'NON DÉFINI'
);
// 2. Vérifier les variables requises console.log('SMTP_FROM:', process.env.SMTP_FROM);
console.log('\n✅ VÉRIFICATION DES VARIABLES:'); console.log('APP_URL:', process.env.APP_URL);
console.log('--------------------------------');
const requiredVars = ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS']; // 2. Vérifier les variables requises
const missing = requiredVars.filter(v => !process.env[v]); console.log('\n✅ VÉRIFICATION DES VARIABLES:');
if (missing.length > 0) { console.log('--------------------------------');
console.error('❌ Variables manquantes:', missing.join(', ')); const requiredVars = ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS'];
process.exit(1); const missing = requiredVars.filter(v => !process.env[v]);
} else { if (missing.length > 0) {
console.log('✅ Toutes les variables requises sont présentes'); console.error('❌ Variables manquantes:', missing.join(', '));
} process.exit(1);
} else {
// 3. Créer le transporter avec la même configuration que le backend console.log('✅ Toutes les variables requises sont présentes');
console.log('\n🔧 CRÉATION DU TRANSPORTER:'); }
console.log('----------------------------');
// 3. Créer le transporter avec la même configuration que le backend
const host = process.env.SMTP_HOST; console.log('\n🔧 CRÉATION DU TRANSPORTER:');
const port = parseInt(process.env.SMTP_PORT); console.log('----------------------------');
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS; const host = process.env.SMTP_HOST;
const secure = process.env.SMTP_SECURE === 'true'; const port = parseInt(process.env.SMTP_PORT);
const user = process.env.SMTP_USER;
// Même logique que dans email.adapter.ts const pass = process.env.SMTP_PASS;
const useDirectIP = host.includes('mailtrap.io'); const secure = process.env.SMTP_SECURE === 'true';
const actualHost = useDirectIP ? '3.209.246.195' : host;
const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; // Même logique que dans email.adapter.ts
const useDirectIP = host.includes('mailtrap.io');
console.log('Configuration détectée:'); const actualHost = useDirectIP ? '3.209.246.195' : host;
console.log(' Host original:', host); const serverName = useDirectIP ? 'smtp.mailtrap.io' : host;
console.log(' Utilise IP directe:', useDirectIP);
console.log(' Host réel:', actualHost); console.log('Configuration détectée:');
console.log(' Server name (TLS):', serverName); console.log(' Host original:', host);
console.log(' Port:', port); console.log(' Utilise IP directe:', useDirectIP);
console.log(' Secure:', secure); console.log(' Host réel:', actualHost);
console.log(' Server name (TLS):', serverName);
const transporter = nodemailer.createTransport({ console.log(' Port:', port);
host: actualHost, console.log(' Secure:', secure);
port,
secure, const transporter = nodemailer.createTransport({
auth: { host: actualHost,
user, port,
pass, secure,
}, auth: {
tls: { user,
rejectUnauthorized: false, pass,
servername: serverName, },
}, tls: {
connectionTimeout: 10000, rejectUnauthorized: false,
greetingTimeout: 10000, servername: serverName,
socketTimeout: 30000, },
dnsTimeout: 10000, connectionTimeout: 10000,
}); greetingTimeout: 10000,
socketTimeout: 30000,
// 4. Tester la connexion dnsTimeout: 10000,
console.log('\n🔌 TEST DE CONNEXION SMTP:'); });
console.log('---------------------------');
// 4. Tester la connexion
async function testConnection() { console.log('\n🔌 TEST DE CONNEXION SMTP:');
try { console.log('---------------------------');
console.log('Vérification de la connexion...');
await transporter.verify(); async function testConnection() {
console.log('✅ Connexion SMTP réussie!'); try {
return true; console.log('Vérification de la connexion...');
} catch (error) { await transporter.verify();
console.error('❌ Échec de la connexion SMTP:'); console.log('✅ Connexion SMTP réussie!');
console.error(' Message:', error.message); return true;
console.error(' Code:', error.code); } catch (error) {
console.error(' Command:', error.command); console.error('❌ Échec de la connexion SMTP:');
if (error.stack) { console.error(' Message:', error.message);
console.error(' Stack:', error.stack.substring(0, 200) + '...'); console.error(' Code:', error.code);
} console.error(' Command:', error.command);
return false; if (error.stack) {
} console.error(' Stack:', error.stack.substring(0, 200) + '...');
} }
return false;
// 5. Envoyer un email de test simple }
async function sendSimpleEmail() { }
console.log('\n📧 TEST 1: Email simple');
console.log('------------------------'); // 5. Envoyer un email de test simple
async function sendSimpleEmail() {
try { console.log('\n📧 TEST 1: Email simple');
const info = await transporter.sendMail({ console.log('------------------------');
from: process.env.SMTP_FROM || 'noreply@xpeditis.com',
to: 'test@example.com', try {
subject: 'Test Simple - ' + new Date().toISOString(), const info = await transporter.sendMail({
text: 'Ceci est un test simple', from: process.env.SMTP_FROM || 'noreply@xpeditis.com',
html: '<h1>Test Simple</h1><p>Ceci est un test simple</p>', to: 'test@example.com',
}); subject: 'Test Simple - ' + new Date().toISOString(),
text: 'Ceci est un test simple',
console.log('✅ Email simple envoyé avec succès!'); html: '<h1>Test Simple</h1><p>Ceci est un test simple</p>',
console.log(' Message ID:', info.messageId); });
console.log(' Response:', info.response);
console.log(' Accepted:', info.accepted); console.log('✅ Email simple envoyé avec succès!');
console.log(' Rejected:', info.rejected); console.log(' Message ID:', info.messageId);
return true; console.log(' Response:', info.response);
} catch (error) { console.log(' Accepted:', info.accepted);
console.error('❌ Échec d\'envoi email simple:'); console.log(' Rejected:', info.rejected);
console.error(' Message:', error.message); return true;
console.error(' Code:', error.code); } catch (error) {
return false; console.error("❌ Échec d'envoi email simple:");
} console.error(' Message:', error.message);
} console.error(' Code:', error.code);
return false;
// 6. Envoyer un email avec le template transporteur complet }
async function sendCarrierEmail() { }
console.log('\n📧 TEST 2: Email transporteur avec template');
console.log('--------------------------------------------'); // 6. Envoyer un email avec le template transporteur complet
async function sendCarrierEmail() {
const bookingData = { console.log('\n📧 TEST 2: Email transporteur avec template');
bookingId: 'TEST-' + Date.now(), console.log('--------------------------------------------');
origin: 'FRPAR',
destination: 'USNYC', const bookingData = {
volumeCBM: 15.5, bookingId: 'TEST-' + Date.now(),
weightKG: 1200, origin: 'FRPAR',
palletCount: 6, destination: 'USNYC',
priceUSD: 2500, volumeCBM: 15.5,
priceEUR: 2250, weightKG: 1200,
primaryCurrency: 'USD', palletCount: 6,
transitDays: 18, priceUSD: 2500,
containerType: '40FT', priceEUR: 2250,
documents: [ primaryCurrency: 'USD',
{ type: 'Bill of Lading', fileName: 'bol-test.pdf' }, transitDays: 18,
{ type: 'Packing List', fileName: 'packing-test.pdf' }, containerType: '40FT',
{ type: 'Commercial Invoice', fileName: 'invoice-test.pdf' }, documents: [
], { type: 'Bill of Lading', fileName: 'bol-test.pdf' },
}; { type: 'Packing List', fileName: 'packing-test.pdf' },
{ type: 'Commercial Invoice', fileName: 'invoice-test.pdf' },
const baseUrl = process.env.APP_URL || 'http://localhost:3000'; ],
const acceptUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/accept`; };
const rejectUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/reject`;
const baseUrl = process.env.APP_URL || 'http://localhost:3000';
// Template HTML (version simplifiée pour le test) const acceptUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/accept`;
const htmlTemplate = ` const rejectUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/reject`;
<!DOCTYPE html>
<html lang="fr"> // Template HTML (version simplifiée pour le test)
<head> const htmlTemplate = `
<meta charset="UTF-8"> <!DOCTYPE html>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <html lang="fr">
<title>Nouvelle demande de réservation</title> <head>
</head> <meta charset="UTF-8">
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f6f8;"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<div style="max-width: 600px; margin: 20px auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);"> <title>Nouvelle demande de réservation</title>
<div style="background: linear-gradient(135deg, #045a8d, #00bcd4); color: #ffffff; padding: 30px 20px; text-align: center;"> </head>
<h1 style="margin: 0; font-size: 28px;">🚢 Nouvelle demande de réservation</h1> <body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f6f8;">
<p style="margin: 5px 0 0; font-size: 14px;">Xpeditis</p> <div style="max-width: 600px; margin: 20px auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);">
</div> <div style="background: linear-gradient(135deg, #045a8d, #00bcd4); color: #ffffff; padding: 30px 20px; text-align: center;">
<div style="padding: 30px 20px;"> <h1 style="margin: 0; font-size: 28px;">🚢 Nouvelle demande de réservation</h1>
<p style="font-size: 16px;">Bonjour,</p> <p style="margin: 5px 0 0; font-size: 14px;">Xpeditis</p>
<p>Vous avez reçu une nouvelle demande de réservation via Xpeditis.</p> </div>
<div style="padding: 30px 20px;">
<h2 style="color: #045a8d; border-bottom: 2px solid #00bcd4; padding-bottom: 8px;">📋 Détails du transport</h2> <p style="font-size: 16px;">Bonjour,</p>
<table style="width: 100%; border-collapse: collapse;"> <p>Vous avez reçu une nouvelle demande de réservation via Xpeditis.</p>
<tr style="border-bottom: 1px solid #e0e0e0;">
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Route</td> <h2 style="color: #045a8d; border-bottom: 2px solid #00bcd4; padding-bottom: 8px;">📋 Détails du transport</h2>
<td style="padding: 12px;">${bookingData.origin} ${bookingData.destination}</td> <table style="width: 100%; border-collapse: collapse;">
</tr> <tr style="border-bottom: 1px solid #e0e0e0;">
<tr style="border-bottom: 1px solid #e0e0e0;"> <td style="padding: 12px; font-weight: bold; color: #045a8d;">Route</td>
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Volume</td> <td style="padding: 12px;">${bookingData.origin} ${bookingData.destination}</td>
<td style="padding: 12px;">${bookingData.volumeCBM} CBM</td> </tr>
</tr> <tr style="border-bottom: 1px solid #e0e0e0;">
<tr style="border-bottom: 1px solid #e0e0e0;"> <td style="padding: 12px; font-weight: bold; color: #045a8d;">Volume</td>
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Poids</td> <td style="padding: 12px;">${bookingData.volumeCBM} CBM</td>
<td style="padding: 12px;">${bookingData.weightKG} kg</td> </tr>
</tr> <tr style="border-bottom: 1px solid #e0e0e0;">
<tr style="border-bottom: 1px solid #e0e0e0;"> <td style="padding: 12px; font-weight: bold; color: #045a8d;">Poids</td>
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Prix</td> <td style="padding: 12px;">${bookingData.weightKG} kg</td>
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #00aa00;"> </tr>
${bookingData.priceUSD} USD <tr style="border-bottom: 1px solid #e0e0e0;">
</td> <td style="padding: 12px; font-weight: bold; color: #045a8d;">Prix</td>
</tr> <td style="padding: 12px; font-size: 24px; font-weight: bold; color: #00aa00;">
</table> ${bookingData.priceUSD} USD
</td>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 6px; margin: 20px 0;"> </tr>
<h3 style="margin-top: 0; color: #045a8d;">📄 Documents fournis</h3> </table>
<ul style="list-style: none; padding: 0; margin: 10px 0 0;">
${bookingData.documents.map(doc => `<li style="padding: 8px 0;">📄 <strong>${doc.type}:</strong> ${doc.fileName}</li>`).join('')} <div style="background-color: #f9f9f9; padding: 20px; border-radius: 6px; margin: 20px 0;">
</ul> <h3 style="margin-top: 0; color: #045a8d;">📄 Documents fournis</h3>
</div> <ul style="list-style: none; padding: 0; margin: 10px 0 0;">
${bookingData.documents.map(doc => `<li style="padding: 8px 0;">📄 <strong>${doc.type}:</strong> ${doc.fileName}</li>`).join('')}
<div style="text-align: center; margin: 30px 0;"> </ul>
<p style="font-weight: bold; font-size: 16px;">Veuillez confirmer votre décision :</p> </div>
<div style="margin: 15px 0;">
<a href="${acceptUrl}" style="display: inline-block; padding: 15px 30px; background-color: #00aa00; color: #ffffff; text-decoration: none; border-radius: 6px; margin: 0 5px; min-width: 200px;"> Accepter la demande</a> <div style="text-align: center; margin: 30px 0;">
<a href="${rejectUrl}" style="display: inline-block; padding: 15px 30px; background-color: #cc0000; color: #ffffff; text-decoration: none; border-radius: 6px; margin: 0 5px; min-width: 200px;"> Refuser la demande</a> <p style="font-weight: bold; font-size: 16px;">Veuillez confirmer votre décision :</p>
</div> <div style="margin: 15px 0;">
</div> <a href="${acceptUrl}" style="display: inline-block; padding: 15px 30px; background-color: #00aa00; color: #ffffff; text-decoration: none; border-radius: 6px; margin: 0 5px; min-width: 200px;"> Accepter la demande</a>
<a href="${rejectUrl}" style="display: inline-block; padding: 15px 30px; background-color: #cc0000; color: #ffffff; text-decoration: none; border-radius: 6px; margin: 0 5px; min-width: 200px;"> Refuser la demande</a>
<div style="background-color: #fff8e1; border-left: 4px solid #f57c00; padding: 15px; margin: 20px 0; border-radius: 4px;"> </div>
<p style="margin: 0; font-size: 14px; color: #666;"> </div>
<strong style="color: #f57c00;"> Important</strong><br>
Cette demande expire automatiquement dans <strong>7 jours</strong> si aucune action n'est prise. <div style="background-color: #fff8e1; border-left: 4px solid #f57c00; padding: 15px; margin: 20px 0; border-radius: 4px;">
</p> <p style="margin: 0; font-size: 14px; color: #666;">
</div> <strong style="color: #f57c00;"> Important</strong><br>
</div> Cette demande expire automatiquement dans <strong>7 jours</strong> si aucune action n'est prise.
<div style="background-color: #f4f6f8; padding: 20px; text-align: center; font-size: 12px; color: #666;"> </p>
<p style="margin: 5px 0; font-weight: bold; color: #045a8d;">Référence de réservation : ${bookingData.bookingId}</p> </div>
<p style="margin: 5px 0;">© 2025 Xpeditis. Tous droits réservés.</p> </div>
<p style="margin: 5px 0;">Cet email a été envoyé automatiquement. Merci de ne pas y répondre directement.</p> <div style="background-color: #f4f6f8; padding: 20px; text-align: center; font-size: 12px; color: #666;">
</div> <p style="margin: 5px 0; font-weight: bold; color: #045a8d;">Référence de réservation : ${bookingData.bookingId}</p>
</div> <p style="margin: 5px 0;">© 2025 Xpeditis. Tous droits réservés.</p>
</body> <p style="margin: 5px 0;">Cet email a été envoyé automatiquement. Merci de ne pas y répondre directement.</p>
</html> </div>
`; </div>
</body>
try { </html>
console.log('Données du booking:'); `;
console.log(' Booking ID:', bookingData.bookingId);
console.log(' Route:', bookingData.origin, '→', bookingData.destination); try {
console.log(' Prix:', bookingData.priceUSD, 'USD'); console.log('Données du booking:');
console.log(' Accept URL:', acceptUrl); console.log(' Booking ID:', bookingData.bookingId);
console.log(' Reject URL:', rejectUrl); console.log(' Route:', bookingData.origin, '→', bookingData.destination);
console.log('\nEnvoi en cours...'); console.log(' Prix:', bookingData.priceUSD, 'USD');
console.log(' Accept URL:', acceptUrl);
const info = await transporter.sendMail({ console.log(' Reject URL:', rejectUrl);
from: process.env.SMTP_FROM || 'noreply@xpeditis.com', console.log('\nEnvoi en cours...');
to: 'carrier@test.com',
subject: `Nouvelle demande de réservation - ${bookingData.origin}${bookingData.destination}`, const info = await transporter.sendMail({
html: htmlTemplate, from: process.env.SMTP_FROM || 'noreply@xpeditis.com',
}); to: 'carrier@test.com',
subject: `Nouvelle demande de réservation - ${bookingData.origin}${bookingData.destination}`,
console.log('\n✅ Email transporteur envoyé avec succès!'); html: htmlTemplate,
console.log(' Message ID:', info.messageId); });
console.log(' Response:', info.response);
console.log(' Accepted:', info.accepted); console.log('\n✅ Email transporteur envoyé avec succès!');
console.log(' Rejected:', info.rejected); console.log(' Message ID:', info.messageId);
console.log('\n📬 Vérifiez votre inbox Mailtrap:'); console.log(' Response:', info.response);
console.log(' URL: https://mailtrap.io/inboxes'); console.log(' Accepted:', info.accepted);
console.log(' Sujet: Nouvelle demande de réservation - FRPAR → USNYC'); console.log(' Rejected:', info.rejected);
return true; console.log('\n📬 Vérifiez votre inbox Mailtrap:');
} catch (error) { console.log(' URL: https://mailtrap.io/inboxes');
console.error('\n❌ Échec d\'envoi email transporteur:'); console.log(' Sujet: Nouvelle demande de réservation - FRPAR → USNYC');
console.error(' Message:', error.message); return true;
console.error(' Code:', error.code); } catch (error) {
console.error(' ResponseCode:', error.responseCode); console.error("\n❌ Échec d'envoi email transporteur:");
console.error(' Response:', error.response); console.error(' Message:', error.message);
if (error.stack) { console.error(' Code:', error.code);
console.error(' Stack:', error.stack.substring(0, 300)); console.error(' ResponseCode:', error.responseCode);
} console.error(' Response:', error.response);
return false; if (error.stack) {
} console.error(' Stack:', error.stack.substring(0, 300));
} }
return false;
// Exécuter tous les tests }
async function runAllTests() { }
console.log('\n🚀 DÉMARRAGE DES TESTS');
console.log('='.repeat(60)); // Exécuter tous les tests
async function runAllTests() {
// Test 1: Connexion console.log('\n🚀 DÉMARRAGE DES TESTS');
const connectionOk = await testConnection(); console.log('='.repeat(60));
if (!connectionOk) {
console.log('\n❌ ARRÊT: La connexion SMTP a échoué'); // Test 1: Connexion
console.log(' Vérifiez vos credentials SMTP dans .env'); const connectionOk = await testConnection();
process.exit(1); if (!connectionOk) {
} console.log('\n❌ ARRÊT: La connexion SMTP a échoué');
console.log(' Vérifiez vos credentials SMTP dans .env');
// Test 2: Email simple process.exit(1);
const simpleEmailOk = await sendSimpleEmail(); }
if (!simpleEmailOk) {
console.log('\n⚠ L\'email simple a échoué, mais on continue...'); // Test 2: Email simple
} const simpleEmailOk = await sendSimpleEmail();
if (!simpleEmailOk) {
// Test 3: Email transporteur console.log("\n⚠ L'email simple a échoué, mais on continue...");
const carrierEmailOk = await sendCarrierEmail(); }
// Résumé // Test 3: Email transporteur
console.log('\n' + '='.repeat(60)); const carrierEmailOk = await sendCarrierEmail();
console.log('📊 RÉSUMÉ DES TESTS:');
console.log('='.repeat(60)); // Résumé
console.log('Connexion SMTP:', connectionOk ? '✅ OK' : '❌ ÉCHEC'); console.log('\n' + '='.repeat(60));
console.log('Email simple:', simpleEmailOk ? '✅ OK' : '❌ ÉCHEC'); console.log('📊 RÉSUMÉ DES TESTS:');
console.log('Email transporteur:', carrierEmailOk ? '✅ OK' : '❌ ÉCHEC'); console.log('='.repeat(60));
console.log('Connexion SMTP:', connectionOk ? '✅ OK' : '❌ ÉCHEC');
if (connectionOk && simpleEmailOk && carrierEmailOk) { console.log('Email simple:', simpleEmailOk ? '✅ OK' : '❌ ÉCHEC');
console.log('\n✅ TOUS LES TESTS ONT RÉUSSI!'); console.log('Email transporteur:', carrierEmailOk ? '✅ OK' : '❌ ÉCHEC');
console.log(' Le système d\'envoi d\'email fonctionne correctement.');
console.log(' Si vous ne recevez pas les emails dans le backend,'); if (connectionOk && simpleEmailOk && carrierEmailOk) {
console.log(' le problème vient de l\'intégration NestJS.'); console.log('\n✅ TOUS LES TESTS ONT RÉUSSI!');
} else { console.log(" Le système d'envoi d'email fonctionne correctement.");
console.log('\n❌ CERTAINS TESTS ONT ÉCHOUÉ'); console.log(' Si vous ne recevez pas les emails dans le backend,');
console.log(' Vérifiez les erreurs ci-dessus pour comprendre le problème.'); console.log(" le problème vient de l'intégration NestJS.");
} } else {
console.log('\n❌ CERTAINS TESTS ONT ÉCHOUÉ');
console.log('\n' + '='.repeat(60)); console.log(' Vérifiez les erreurs ci-dessus pour comprendre le problème.');
} }
// Lancer les tests console.log('\n' + '='.repeat(60));
runAllTests() }
.then(() => {
console.log('\n✅ Tests terminés\n'); // Lancer les tests
process.exit(0); runAllTests()
}) .then(() => {
.catch(error => { console.log('\n✅ Tests terminés\n');
console.error('\n❌ Erreur fatale:', error); process.exit(0);
process.exit(1); })
}); .catch(error => {
console.error('\n❌ Erreur fatale:', error);
process.exit(1);
});

View File

@ -1,106 +1,106 @@
/** /**
* Script to delete test documents from MinIO * Script to delete test documents from MinIO
* *
* Deletes only small test files (< 1000 bytes) created by upload-test-documents.js * Deletes only small test files (< 1000 bytes) created by upload-test-documents.js
* Preserves real uploaded documents (larger files) * Preserves real uploaded documents (larger files)
*/ */
const { S3Client, ListObjectsV2Command, DeleteObjectCommand } = require('@aws-sdk/client-s3'); const { S3Client, ListObjectsV2Command, DeleteObjectCommand } = require('@aws-sdk/client-s3');
require('dotenv').config(); require('dotenv').config();
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
const BUCKET_NAME = 'xpeditis-documents'; const BUCKET_NAME = 'xpeditis-documents';
const TEST_FILE_SIZE_THRESHOLD = 1000; // Files smaller than 1KB are likely test files const TEST_FILE_SIZE_THRESHOLD = 1000; // Files smaller than 1KB are likely test files
// Initialize MinIO client // Initialize MinIO client
const s3Client = new S3Client({ const s3Client = new S3Client({
region: 'us-east-1', region: 'us-east-1',
endpoint: MINIO_ENDPOINT, endpoint: MINIO_ENDPOINT,
credentials: { credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
}, },
forcePathStyle: true, forcePathStyle: true,
}); });
async function deleteTestDocuments() { async function deleteTestDocuments() {
try { try {
console.log('📋 Listing all files in bucket:', BUCKET_NAME); console.log('📋 Listing all files in bucket:', BUCKET_NAME);
// List all files // List all files
let allFiles = []; let allFiles = [];
let continuationToken = null; let continuationToken = null;
do { do {
const command = new ListObjectsV2Command({ const command = new ListObjectsV2Command({
Bucket: BUCKET_NAME, Bucket: BUCKET_NAME,
ContinuationToken: continuationToken, ContinuationToken: continuationToken,
}); });
const response = await s3Client.send(command); const response = await s3Client.send(command);
if (response.Contents) { if (response.Contents) {
allFiles = allFiles.concat(response.Contents); allFiles = allFiles.concat(response.Contents);
} }
continuationToken = response.NextContinuationToken; continuationToken = response.NextContinuationToken;
} while (continuationToken); } while (continuationToken);
console.log(`\n📊 Found ${allFiles.length} total files\n`); console.log(`\n📊 Found ${allFiles.length} total files\n`);
// Filter test files (small files < 1000 bytes) // Filter test files (small files < 1000 bytes)
const testFiles = allFiles.filter(file => file.Size < TEST_FILE_SIZE_THRESHOLD); const testFiles = allFiles.filter(file => file.Size < TEST_FILE_SIZE_THRESHOLD);
const realFiles = allFiles.filter(file => file.Size >= TEST_FILE_SIZE_THRESHOLD); const realFiles = allFiles.filter(file => file.Size >= TEST_FILE_SIZE_THRESHOLD);
console.log(`🔍 Analysis:`); console.log(`🔍 Analysis:`);
console.log(` Test files (< ${TEST_FILE_SIZE_THRESHOLD} bytes): ${testFiles.length}`); console.log(` Test files (< ${TEST_FILE_SIZE_THRESHOLD} bytes): ${testFiles.length}`);
console.log(` Real files (>= ${TEST_FILE_SIZE_THRESHOLD} bytes): ${realFiles.length}\n`); console.log(` Real files (>= ${TEST_FILE_SIZE_THRESHOLD} bytes): ${realFiles.length}\n`);
if (testFiles.length === 0) { if (testFiles.length === 0) {
console.log('✅ No test files to delete'); console.log('✅ No test files to delete');
return; return;
} }
console.log(`🗑️ Deleting ${testFiles.length} test files:\n`); console.log(`🗑️ Deleting ${testFiles.length} test files:\n`);
let deletedCount = 0; let deletedCount = 0;
for (const file of testFiles) { for (const file of testFiles) {
console.log(` Deleting: ${file.Key} (${file.Size} bytes)`); console.log(` Deleting: ${file.Key} (${file.Size} bytes)`);
try { try {
await s3Client.send( await s3Client.send(
new DeleteObjectCommand({ new DeleteObjectCommand({
Bucket: BUCKET_NAME, Bucket: BUCKET_NAME,
Key: file.Key, Key: file.Key,
}) })
); );
deletedCount++; deletedCount++;
} catch (error) { } catch (error) {
console.error(` ❌ Failed to delete ${file.Key}:`, error.message); console.error(` ❌ Failed to delete ${file.Key}:`, error.message);
} }
} }
console.log(`\n✅ Deleted ${deletedCount} test files`); console.log(`\n✅ Deleted ${deletedCount} test files`);
console.log(`✅ Preserved ${realFiles.length} real documents\n`); console.log(`✅ Preserved ${realFiles.length} real documents\n`);
console.log('📂 Remaining real documents:'); console.log('📂 Remaining real documents:');
realFiles.forEach(file => { realFiles.forEach(file => {
const filename = file.Key.split('/').pop(); const filename = file.Key.split('/').pop();
const sizeMB = (file.Size / 1024 / 1024).toFixed(2); const sizeMB = (file.Size / 1024 / 1024).toFixed(2);
console.log(` - ${filename} (${sizeMB} MB)`); console.log(` - ${filename} (${sizeMB} MB)`);
}); });
} catch (error) { } catch (error) {
console.error('❌ Error:', error); console.error('❌ Error:', error);
throw error; throw error;
} }
} }
deleteTestDocuments() deleteTestDocuments()
.then(() => { .then(() => {
console.log('\n✅ Script completed successfully'); console.log('\n✅ Script completed successfully');
process.exit(0); process.exit(0);
}) })
.catch((error) => { .catch(error => {
console.error('\n❌ Script failed:', error); console.error('\n❌ Script failed:', error);
process.exit(1); process.exit(1);
}); });

View File

@ -1,42 +1,42 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Script to fix TypeScript imports in domain/services * Script to fix TypeScript imports in domain/services
* Replace relative paths with path aliases * Replace relative paths with path aliases
*/ */
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
function fixImportsInFile(filePath) { function fixImportsInFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8'); const content = fs.readFileSync(filePath, 'utf8');
let modified = content; let modified = content;
// Replace relative imports to ../ports/ with @domain/ports/ // Replace relative imports to ../ports/ with @domain/ports/
modified = modified.replace(/from ['"]\.\.\/ports\//g, "from '@domain/ports/"); modified = modified.replace(/from ['"]\.\.\/ports\//g, "from '@domain/ports/");
modified = modified.replace(/import\s+(['"])\.\.\/ports\//g, "import $1@domain/ports/"); modified = modified.replace(/import\s+(['"])\.\.\/ports\//g, 'import $1@domain/ports/');
if (modified !== content) { if (modified !== content) {
fs.writeFileSync(filePath, modified, 'utf8'); fs.writeFileSync(filePath, modified, 'utf8');
return true; return true;
} }
return false; return false;
} }
const servicesDir = path.join(__dirname, 'src/domain/services'); const servicesDir = path.join(__dirname, 'src/domain/services');
console.log('🔧 Fixing domain/services imports...\n'); console.log('🔧 Fixing domain/services imports...\n');
const files = fs.readdirSync(servicesDir); const files = fs.readdirSync(servicesDir);
let count = 0; let count = 0;
for (const file of files) { for (const file of files) {
if (file.endsWith('.ts')) { if (file.endsWith('.ts')) {
const filePath = path.join(servicesDir, file); const filePath = path.join(servicesDir, file);
if (fixImportsInFile(filePath)) { if (fixImportsInFile(filePath)) {
console.log(`✅ Fixed: ${filePath}`); console.log(`✅ Fixed: ${filePath}`);
count++; count++;
} }
} }
} }
console.log(`\n✅ Fixed ${count} files in domain/services`); console.log(`\n✅ Fixed ${count} files in domain/services`);

View File

@ -1,90 +1,90 @@
/** /**
* Script to fix dummy storage URLs in the database * Script to fix dummy storage URLs in the database
* *
* This script updates all document URLs from "dummy-storage.com" to proper MinIO URLs * This script updates all document URLs from "dummy-storage.com" to proper MinIO URLs
*/ */
const { Client } = require('pg'); const { Client } = require('pg');
require('dotenv').config(); require('dotenv').config();
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
const BUCKET_NAME = 'xpeditis-documents'; const BUCKET_NAME = 'xpeditis-documents';
async function fixDummyUrls() { async function fixDummyUrls() {
const client = new Client({ const client = new Client({
host: process.env.DATABASE_HOST || 'localhost', host: process.env.DATABASE_HOST || 'localhost',
port: process.env.DATABASE_PORT || 5432, port: process.env.DATABASE_PORT || 5432,
user: process.env.DATABASE_USER || 'xpeditis', user: process.env.DATABASE_USER || 'xpeditis',
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
database: process.env.DATABASE_NAME || 'xpeditis_dev', database: process.env.DATABASE_NAME || 'xpeditis_dev',
}); });
try { try {
await client.connect(); await client.connect();
console.log('✅ Connected to database'); console.log('✅ Connected to database');
// Get all CSV bookings with documents // Get all CSV bookings with documents
const result = await client.query( const result = await client.query(
`SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND documents::text LIKE '%dummy-storage%'` `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND documents::text LIKE '%dummy-storage%'`
); );
console.log(`\n📄 Found ${result.rows.length} bookings with dummy URLs\n`); console.log(`\n📄 Found ${result.rows.length} bookings with dummy URLs\n`);
let updatedCount = 0; let updatedCount = 0;
for (const row of result.rows) { for (const row of result.rows) {
const bookingId = row.id; const bookingId = row.id;
const documents = row.documents; const documents = row.documents;
// Update each document URL // Update each document URL
const updatedDocuments = documents.map((doc) => { const updatedDocuments = documents.map(doc => {
if (doc.filePath && doc.filePath.includes('dummy-storage')) { if (doc.filePath && doc.filePath.includes('dummy-storage')) {
// Extract filename from dummy URL // Extract filename from dummy URL
const fileName = doc.fileName || doc.filePath.split('/').pop(); const fileName = doc.fileName || doc.filePath.split('/').pop();
const documentId = doc.id; const documentId = doc.id;
// Build proper MinIO URL // Build proper MinIO URL
const newUrl = `${MINIO_ENDPOINT}/${BUCKET_NAME}/csv-bookings/${bookingId}/${documentId}-${fileName}`; const newUrl = `${MINIO_ENDPOINT}/${BUCKET_NAME}/csv-bookings/${bookingId}/${documentId}-${fileName}`;
console.log(` Old: ${doc.filePath}`); console.log(` Old: ${doc.filePath}`);
console.log(` New: ${newUrl}`); console.log(` New: ${newUrl}`);
return { return {
...doc, ...doc,
filePath: newUrl, filePath: newUrl,
}; };
} }
return doc; return doc;
}); });
// Update the database // Update the database
await client.query( await client.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, JSON.stringify(updatedDocuments),
[JSON.stringify(updatedDocuments), bookingId] bookingId,
); ]);
updatedCount++; updatedCount++;
console.log(`✅ Updated booking ${bookingId}\n`); console.log(`✅ Updated booking ${bookingId}\n`);
} }
console.log(`\n🎉 Successfully updated ${updatedCount} bookings`); console.log(`\n🎉 Successfully updated ${updatedCount} bookings`);
console.log(`\n⚠️ Note: The actual files need to be uploaded to MinIO at the correct paths.`); console.log(`\n⚠️ Note: The actual files need to be uploaded to MinIO at the correct paths.`);
console.log(` You can upload test files or re-create the bookings with real file uploads.`); console.log(` You can upload test files or re-create the bookings with real file uploads.`);
} catch (error) { } catch (error) {
console.error('❌ Error:', error); console.error('❌ Error:', error);
throw error; throw error;
} finally { } finally {
await client.end(); await client.end();
console.log('\n👋 Disconnected from database'); console.log('\n👋 Disconnected from database');
} }
} }
fixDummyUrls() fixDummyUrls()
.then(() => { .then(() => {
console.log('\n✅ Script completed successfully'); console.log('\n✅ Script completed successfully');
process.exit(0); process.exit(0);
}) })
.catch((error) => { .catch(error => {
console.error('\n❌ Script failed:', error); console.error('\n❌ Script failed:', error);
process.exit(1); process.exit(1);
}); });

View File

@ -1,65 +1,68 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Script to fix TypeScript imports from relative paths to path aliases * Script to fix TypeScript imports from relative paths to path aliases
* *
* Replaces: * Replaces:
* - from '../../domain/...' from '@domain/...' * - from '../../domain/...' from '@domain/...'
* - from '../../../domain/...' from '@domain/...' * - from '../../../domain/...' from '@domain/...'
* - from '../domain/...' from '@domain/...' * - from '../domain/...' from '@domain/...'
* - from '../../../../domain/...' from '@domain/...' * - from '../../../../domain/...' from '@domain/...'
*/ */
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
function fixImportsInFile(filePath) { function fixImportsInFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8'); const content = fs.readFileSync(filePath, 'utf8');
let modified = content; let modified = content;
// Replace all variations of relative domain imports with @domain alias // Replace all variations of relative domain imports with @domain alias
modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/"); modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/");
modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/"); modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/");
modified = modified.replace(/from ['"]\.\.\/\.\.\/domain\//g, "from '@domain/"); modified = modified.replace(/from ['"]\.\.\/\.\.\/domain\//g, "from '@domain/");
modified = modified.replace(/from ['"]\.\.\/domain\//g, "from '@domain/"); modified = modified.replace(/from ['"]\.\.\/domain\//g, "from '@domain/");
// Also fix import statements (not just from) // Also fix import statements (not just from)
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, "import $1@domain/"); modified = modified.replace(
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/domain\//g, "import $1@domain/"); /import\s+(['"])\.\.\/\.\.\/\.\.\/\.\.\/domain\//g,
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/domain\//g, "import $1@domain/"); 'import $1@domain/'
modified = modified.replace(/import\s+(['"])\.\.\/domain\//g, "import $1@domain/"); );
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/domain\//g, 'import $1@domain/');
if (modified !== content) { modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/domain\//g, 'import $1@domain/');
fs.writeFileSync(filePath, modified, 'utf8'); modified = modified.replace(/import\s+(['"])\.\.\/domain\//g, 'import $1@domain/');
return true;
} if (modified !== content) {
return false; fs.writeFileSync(filePath, modified, 'utf8');
} return true;
}
function walkDir(dir) { return false;
const files = fs.readdirSync(dir); }
let count = 0;
function walkDir(dir) {
for (const file of files) { const files = fs.readdirSync(dir);
const filePath = path.join(dir, file); let count = 0;
const stat = fs.statSync(filePath);
for (const file of files) {
if (stat.isDirectory()) { const filePath = path.join(dir, file);
count += walkDir(filePath); const stat = fs.statSync(filePath);
} else if (file.endsWith('.ts')) {
if (fixImportsInFile(filePath)) { if (stat.isDirectory()) {
console.log(`✅ Fixed: ${filePath}`); count += walkDir(filePath);
count++; } else if (file.endsWith('.ts')) {
} if (fixImportsInFile(filePath)) {
} console.log(`✅ Fixed: ${filePath}`);
} count++;
}
return count; }
} }
const srcDir = path.join(__dirname, 'src'); return count;
console.log('🔧 Fixing TypeScript imports...\n'); }
const count = walkDir(srcDir); const srcDir = path.join(__dirname, 'src');
console.log('🔧 Fixing TypeScript imports...\n');
console.log(`\n✅ Fixed ${count} files`);
const count = walkDir(srcDir);
console.log(`\n✅ Fixed ${count} files`);

View File

@ -1,81 +1,81 @@
/** /**
* Script to fix minio hostname in document URLs * Script to fix minio hostname in document URLs
* *
* Changes http://minio:9000 to http://localhost:9000 * Changes http://minio:9000 to http://localhost:9000
*/ */
const { Client } = require('pg'); const { Client } = require('pg');
require('dotenv').config(); require('dotenv').config();
async function fixMinioHostname() { async function fixMinioHostname() {
const client = new Client({ const client = new Client({
host: process.env.DATABASE_HOST || 'localhost', host: process.env.DATABASE_HOST || 'localhost',
port: process.env.DATABASE_PORT || 5432, port: process.env.DATABASE_PORT || 5432,
user: process.env.DATABASE_USER || 'xpeditis', user: process.env.DATABASE_USER || 'xpeditis',
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
database: process.env.DATABASE_NAME || 'xpeditis_dev', database: process.env.DATABASE_NAME || 'xpeditis_dev',
}); });
try { try {
await client.connect(); await client.connect();
console.log('✅ Connected to database'); console.log('✅ Connected to database');
// Find bookings with minio:9000 in URLs // Find bookings with minio:9000 in URLs
const result = await client.query( const result = await client.query(
`SELECT id, documents FROM csv_bookings WHERE documents::text LIKE '%http://minio:9000%'` `SELECT id, documents FROM csv_bookings WHERE documents::text LIKE '%http://minio:9000%'`
); );
console.log(`\n📄 Found ${result.rows.length} bookings with minio hostname\n`); console.log(`\n📄 Found ${result.rows.length} bookings with minio hostname\n`);
let updatedCount = 0; let updatedCount = 0;
for (const row of result.rows) { for (const row of result.rows) {
const bookingId = row.id; const bookingId = row.id;
const documents = row.documents; const documents = row.documents;
// Update each document URL // Update each document URL
const updatedDocuments = documents.map((doc) => { const updatedDocuments = documents.map(doc => {
if (doc.filePath && doc.filePath.includes('http://minio:9000')) { if (doc.filePath && doc.filePath.includes('http://minio:9000')) {
const newUrl = doc.filePath.replace('http://minio:9000', 'http://localhost:9000'); const newUrl = doc.filePath.replace('http://minio:9000', 'http://localhost:9000');
console.log(` Booking: ${bookingId}`); console.log(` Booking: ${bookingId}`);
console.log(` Old: ${doc.filePath}`); console.log(` Old: ${doc.filePath}`);
console.log(` New: ${newUrl}\n`); console.log(` New: ${newUrl}\n`);
return { return {
...doc, ...doc,
filePath: newUrl, filePath: newUrl,
}; };
} }
return doc; return doc;
}); });
// Update the database // Update the database
await client.query( await client.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, JSON.stringify(updatedDocuments),
[JSON.stringify(updatedDocuments), bookingId] bookingId,
); ]);
updatedCount++; updatedCount++;
console.log(`✅ Updated booking ${bookingId}\n`); console.log(`✅ Updated booking ${bookingId}\n`);
} }
console.log(`\n🎉 Successfully updated ${updatedCount} bookings`); console.log(`\n🎉 Successfully updated ${updatedCount} bookings`);
} catch (error) { } catch (error) {
console.error('❌ Error:', error); console.error('❌ Error:', error);
throw error; throw error;
} finally { } finally {
await client.end(); await client.end();
console.log('\n👋 Disconnected from database'); console.log('\n👋 Disconnected from database');
} }
} }
fixMinioHostname() fixMinioHostname()
.then(() => { .then(() => {
console.log('\n✅ Script completed successfully'); console.log('\n✅ Script completed successfully');
process.exit(0); process.exit(0);
}) })
.catch((error) => { .catch(error => {
console.error('\n❌ Script failed:', error); console.error('\n❌ Script failed:', error);
process.exit(1); process.exit(1);
}); });

View File

@ -1,14 +1,14 @@
const argon2 = require('argon2'); const argon2 = require('argon2');
async function generateHash() { async function generateHash() {
const hash = await argon2.hash('Password123!', { const hash = await argon2.hash('Password123!', {
type: argon2.argon2id, type: argon2.argon2id,
memoryCost: 65536, // 64 MB memoryCost: 65536, // 64 MB
timeCost: 3, timeCost: 3,
parallelism: 4, parallelism: 4,
}); });
console.log('Argon2id hash for "Password123!":'); console.log('Argon2id hash for "Password123!":');
console.log(hash); console.log(hash);
} }
generateHash().catch(console.error); generateHash().catch(console.error);

View File

@ -1,92 +1,92 @@
/** /**
* Script to list all files in MinIO xpeditis-documents bucket * Script to list all files in MinIO xpeditis-documents bucket
*/ */
const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3'); const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3');
require('dotenv').config(); require('dotenv').config();
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
const BUCKET_NAME = 'xpeditis-documents'; const BUCKET_NAME = 'xpeditis-documents';
// Initialize MinIO client // Initialize MinIO client
const s3Client = new S3Client({ const s3Client = new S3Client({
region: 'us-east-1', region: 'us-east-1',
endpoint: MINIO_ENDPOINT, endpoint: MINIO_ENDPOINT,
credentials: { credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
}, },
forcePathStyle: true, forcePathStyle: true,
}); });
async function listFiles() { async function listFiles() {
try { try {
console.log(`📋 Listing all files in bucket: ${BUCKET_NAME}\n`); console.log(`📋 Listing all files in bucket: ${BUCKET_NAME}\n`);
let allFiles = []; let allFiles = [];
let continuationToken = null; let continuationToken = null;
do { do {
const command = new ListObjectsV2Command({ const command = new ListObjectsV2Command({
Bucket: BUCKET_NAME, Bucket: BUCKET_NAME,
ContinuationToken: continuationToken, ContinuationToken: continuationToken,
}); });
const response = await s3Client.send(command); const response = await s3Client.send(command);
if (response.Contents) { if (response.Contents) {
allFiles = allFiles.concat(response.Contents); allFiles = allFiles.concat(response.Contents);
} }
continuationToken = response.NextContinuationToken; continuationToken = response.NextContinuationToken;
} while (continuationToken); } while (continuationToken);
console.log(`Found ${allFiles.length} files total:\n`); console.log(`Found ${allFiles.length} files total:\n`);
// Group by booking ID // Group by booking ID
const byBooking = {}; const byBooking = {};
allFiles.forEach(file => { allFiles.forEach(file => {
const parts = file.Key.split('/'); const parts = file.Key.split('/');
if (parts.length >= 3 && parts[0] === 'csv-bookings') { if (parts.length >= 3 && parts[0] === 'csv-bookings') {
const bookingId = parts[1]; const bookingId = parts[1];
if (!byBooking[bookingId]) { if (!byBooking[bookingId]) {
byBooking[bookingId] = []; byBooking[bookingId] = [];
} }
byBooking[bookingId].push({ byBooking[bookingId].push({
key: file.Key, key: file.Key,
size: file.Size, size: file.Size,
lastModified: file.LastModified, lastModified: file.LastModified,
}); });
} else { } else {
console.log(` Other: ${file.Key} (${file.Size} bytes)`); console.log(` Other: ${file.Key} (${file.Size} bytes)`);
} }
}); });
console.log(`\nFiles grouped by booking:\n`); console.log(`\nFiles grouped by booking:\n`);
Object.entries(byBooking).forEach(([bookingId, files]) => { Object.entries(byBooking).forEach(([bookingId, files]) => {
console.log(`📦 Booking: ${bookingId.substring(0, 8)}...`); console.log(`📦 Booking: ${bookingId.substring(0, 8)}...`);
files.forEach(file => { files.forEach(file => {
const filename = file.key.split('/').pop(); const filename = file.key.split('/').pop();
console.log(` - ${filename} (${file.size} bytes) - ${file.lastModified}`); console.log(` - ${filename} (${file.size} bytes) - ${file.lastModified}`);
}); });
console.log(''); console.log('');
}); });
console.log(`\n📊 Summary:`); console.log(`\n📊 Summary:`);
console.log(` Total files: ${allFiles.length}`); console.log(` Total files: ${allFiles.length}`);
console.log(` Bookings with files: ${Object.keys(byBooking).length}`); console.log(` Bookings with files: ${Object.keys(byBooking).length}`);
} catch (error) { } catch (error) {
console.error('❌ Error:', error); console.error('❌ Error:', error);
throw error; throw error;
} }
} }
listFiles() listFiles()
.then(() => { .then(() => {
console.log('\n✅ Script completed successfully'); console.log('\n✅ Script completed successfully');
process.exit(0); process.exit(0);
}) })
.catch((error) => { .catch(error => {
console.error('\n❌ Script failed:', error); console.error('\n❌ Script failed:', error);
process.exit(1); process.exit(1);
}); });

View File

@ -9,18 +9,21 @@ async function loginAndTestEmail() {
console.log('🔐 Connexion...'); console.log('🔐 Connexion...');
const loginResponse = await axios.post(`${API_URL}/auth/login`, { const loginResponse = await axios.post(`${API_URL}/auth/login`, {
email: 'admin@xpeditis.com', email: 'admin@xpeditis.com',
password: 'Admin123!@#' password: 'Admin123!@#',
}); });
const token = loginResponse.data.accessToken; const token = loginResponse.data.accessToken;
console.log('✅ Connecté avec succès\n'); console.log('✅ Connecté avec succès\n');
// 2. Créer un CSV booking pour tester l'envoi d'email // 2. Créer un CSV booking pour tester l'envoi d'email
console.log('📧 Création d\'une CSV booking pour tester l\'envoi d\'email...'); console.log("📧 Création d'une CSV booking pour tester l'envoi d'email...");
const form = new FormData(); const form = new FormData();
const testFile = Buffer.from('Test document PDF content'); const testFile = Buffer.from('Test document PDF content');
form.append('documents', testFile, { filename: 'test-doc.pdf', contentType: 'application/pdf' }); form.append('documents', testFile, {
filename: 'test-doc.pdf',
contentType: 'application/pdf',
});
form.append('carrierName', 'Test Carrier'); form.append('carrierName', 'Test Carrier');
form.append('carrierEmail', 'testcarrier@example.com'); form.append('carrierEmail', 'testcarrier@example.com');
@ -39,8 +42,8 @@ async function loginAndTestEmail() {
const bookingResponse = await axios.post(`${API_URL}/csv-bookings`, form, { const bookingResponse = await axios.post(`${API_URL}/csv-bookings`, form, {
headers: { headers: {
...form.getHeaders(), ...form.getHeaders(),
'Authorization': `Bearer ${token}` Authorization: `Bearer ${token}`,
} },
}); });
console.log('✅ CSV Booking créé:', bookingResponse.data.id); console.log('✅ CSV Booking créé:', bookingResponse.data.id);
@ -50,7 +53,6 @@ async function loginAndTestEmail() {
console.log('2. Vérifier Mailtrap inbox: https://mailtrap.io/inboxes'); console.log('2. Vérifier Mailtrap inbox: https://mailtrap.io/inboxes');
console.log('3. Email devrait être envoyé à: testcarrier@example.com'); console.log('3. Email devrait être envoyé à: testcarrier@example.com');
console.log('\n⏳ Attendez quelques secondes puis vérifiez les logs du backend...'); console.log('\n⏳ Attendez quelques secondes puis vérifiez les logs du backend...');
} catch (error) { } catch (error) {
console.error('❌ ERREUR:'); console.error('❌ ERREUR:');
if (error.response) { if (error.response) {

View File

@ -1,176 +1,182 @@
/** /**
* Script to restore document references in database from MinIO files * Script to restore document references in database from MinIO files
* *
* Scans MinIO for existing files and creates/updates database references * Scans MinIO for existing files and creates/updates database references
*/ */
const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3'); const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { Client } = require('pg'); const { Client } = require('pg');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
require('dotenv').config(); require('dotenv').config();
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
const BUCKET_NAME = 'xpeditis-documents'; const BUCKET_NAME = 'xpeditis-documents';
// Initialize MinIO client // Initialize MinIO client
const s3Client = new S3Client({ const s3Client = new S3Client({
region: 'us-east-1', region: 'us-east-1',
endpoint: MINIO_ENDPOINT, endpoint: MINIO_ENDPOINT,
credentials: { credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
}, },
forcePathStyle: true, forcePathStyle: true,
}); });
async function restoreDocumentReferences() { async function restoreDocumentReferences() {
const pgClient = new Client({ const pgClient = new Client({
host: process.env.DATABASE_HOST || 'localhost', host: process.env.DATABASE_HOST || 'localhost',
port: process.env.DATABASE_PORT || 5432, port: process.env.DATABASE_PORT || 5432,
user: process.env.DATABASE_USER || 'xpeditis', user: process.env.DATABASE_USER || 'xpeditis',
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
database: process.env.DATABASE_NAME || 'xpeditis_dev', database: process.env.DATABASE_NAME || 'xpeditis_dev',
}); });
try { try {
await pgClient.connect(); await pgClient.connect();
console.log('✅ Connected to database\n'); console.log('✅ Connected to database\n');
// Get all MinIO files // Get all MinIO files
console.log('📋 Listing files in MinIO...'); console.log('📋 Listing files in MinIO...');
let allFiles = []; let allFiles = [];
let continuationToken = null; let continuationToken = null;
do { do {
const command = new ListObjectsV2Command({ const command = new ListObjectsV2Command({
Bucket: BUCKET_NAME, Bucket: BUCKET_NAME,
ContinuationToken: continuationToken, ContinuationToken: continuationToken,
}); });
const response = await s3Client.send(command); const response = await s3Client.send(command);
if (response.Contents) { if (response.Contents) {
allFiles = allFiles.concat(response.Contents); allFiles = allFiles.concat(response.Contents);
} }
continuationToken = response.NextContinuationToken; continuationToken = response.NextContinuationToken;
} while (continuationToken); } while (continuationToken);
console.log(` Found ${allFiles.length} files in MinIO\n`); console.log(` Found ${allFiles.length} files in MinIO\n`);
// Group files by booking ID // Group files by booking ID
const filesByBooking = {}; const filesByBooking = {};
allFiles.forEach(file => { allFiles.forEach(file => {
const parts = file.Key.split('/'); const parts = file.Key.split('/');
if (parts.length >= 3 && parts[0] === 'csv-bookings') { if (parts.length >= 3 && parts[0] === 'csv-bookings') {
const bookingId = parts[1]; const bookingId = parts[1];
const documentId = parts[2].split('-')[0]; // Extract UUID from filename const documentId = parts[2].split('-')[0]; // Extract UUID from filename
const fileName = parts[2].substring(37); // Remove UUID prefix (36 chars + dash) const fileName = parts[2].substring(37); // Remove UUID prefix (36 chars + dash)
if (!filesByBooking[bookingId]) { if (!filesByBooking[bookingId]) {
filesByBooking[bookingId] = []; filesByBooking[bookingId] = [];
} }
filesByBooking[bookingId].push({ filesByBooking[bookingId].push({
key: file.Key, key: file.Key,
documentId: documentId, documentId: documentId,
fileName: fileName, fileName: fileName,
size: file.Size, size: file.Size,
lastModified: file.LastModified, lastModified: file.LastModified,
}); });
} }
}); });
console.log(`📦 Found files for ${Object.keys(filesByBooking).length} bookings\n`); console.log(`📦 Found files for ${Object.keys(filesByBooking).length} bookings\n`);
let updatedCount = 0; let updatedCount = 0;
let createdDocsCount = 0; let createdDocsCount = 0;
for (const [bookingId, files] of Object.entries(filesByBooking)) { for (const [bookingId, files] of Object.entries(filesByBooking)) {
// Check if booking exists // Check if booking exists
const bookingResult = await pgClient.query( const bookingResult = await pgClient.query(
'SELECT id, documents FROM csv_bookings WHERE id = $1', 'SELECT id, documents FROM csv_bookings WHERE id = $1',
[bookingId] [bookingId]
); );
if (bookingResult.rows.length === 0) { if (bookingResult.rows.length === 0) {
console.log(`⚠️ Booking not found: ${bookingId.substring(0, 8)}... (skipping)`); console.log(`⚠️ Booking not found: ${bookingId.substring(0, 8)}... (skipping)`);
continue; continue;
} }
const booking = bookingResult.rows[0]; const booking = bookingResult.rows[0];
const existingDocs = booking.documents || []; const existingDocs = booking.documents || [];
console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`); console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`);
console.log(` Existing documents in DB: ${existingDocs.length}`); console.log(` Existing documents in DB: ${existingDocs.length}`);
console.log(` Files in MinIO: ${files.length}`); console.log(` Files in MinIO: ${files.length}`);
// Create document references for files // Create document references for files
const newDocuments = files.map(file => { const newDocuments = files.map(file => {
// Determine MIME type from file extension // Determine MIME type from file extension
const ext = file.fileName.split('.').pop().toLowerCase(); const ext = file.fileName.split('.').pop().toLowerCase();
const mimeTypeMap = { const mimeTypeMap = {
pdf: 'application/pdf', pdf: 'application/pdf',
png: 'image/png', png: 'image/png',
jpg: 'image/jpeg', jpg: 'image/jpeg',
jpeg: 'image/jpeg', jpeg: 'image/jpeg',
txt: 'text/plain', txt: 'text/plain',
}; };
const mimeType = mimeTypeMap[ext] || 'application/octet-stream'; const mimeType = mimeTypeMap[ext] || 'application/octet-stream';
// Determine document type // Determine document type
let docType = 'OTHER'; let docType = 'OTHER';
if (file.fileName.toLowerCase().includes('bill-of-lading') || file.fileName.toLowerCase().includes('bol')) { if (
docType = 'BILL_OF_LADING'; file.fileName.toLowerCase().includes('bill-of-lading') ||
} else if (file.fileName.toLowerCase().includes('packing-list')) { file.fileName.toLowerCase().includes('bol')
docType = 'PACKING_LIST'; ) {
} else if (file.fileName.toLowerCase().includes('commercial-invoice') || file.fileName.toLowerCase().includes('invoice')) { docType = 'BILL_OF_LADING';
docType = 'COMMERCIAL_INVOICE'; } else if (file.fileName.toLowerCase().includes('packing-list')) {
} docType = 'PACKING_LIST';
} else if (
const doc = { file.fileName.toLowerCase().includes('commercial-invoice') ||
id: file.documentId, file.fileName.toLowerCase().includes('invoice')
type: docType, ) {
fileName: file.fileName, docType = 'COMMERCIAL_INVOICE';
filePath: `${MINIO_ENDPOINT}/${BUCKET_NAME}/${file.key}`, }
mimeType: mimeType,
size: file.size, const doc = {
uploadedAt: file.lastModified.toISOString(), id: file.documentId,
}; type: docType,
fileName: file.fileName,
console.log(`${file.fileName} (${(file.size / 1024).toFixed(2)} KB)`); filePath: `${MINIO_ENDPOINT}/${BUCKET_NAME}/${file.key}`,
return doc; mimeType: mimeType,
}); size: file.size,
uploadedAt: file.lastModified.toISOString(),
// Update the booking with new document references };
await pgClient.query(
'UPDATE csv_bookings SET documents = $1 WHERE id = $2', console.log(`${file.fileName} (${(file.size / 1024).toFixed(2)} KB)`);
[JSON.stringify(newDocuments), bookingId] return doc;
); });
updatedCount++; // Update the booking with new document references
createdDocsCount += newDocuments.length; await pgClient.query('UPDATE csv_bookings SET documents = $1 WHERE id = $2', [
} JSON.stringify(newDocuments),
bookingId,
console.log(`\n📊 Summary:`); ]);
console.log(` Bookings updated: ${updatedCount}`);
console.log(` Document references created: ${createdDocsCount}`); updatedCount++;
console.log(`\n✅ Document references restored`); createdDocsCount += newDocuments.length;
} catch (error) { }
console.error('❌ Error:', error);
throw error; console.log(`\n📊 Summary:`);
} finally { console.log(` Bookings updated: ${updatedCount}`);
await pgClient.end(); console.log(` Document references created: ${createdDocsCount}`);
console.log('\n👋 Disconnected from database'); console.log(`\n✅ Document references restored`);
} } catch (error) {
} console.error('❌ Error:', error);
throw error;
restoreDocumentReferences() } finally {
.then(() => { await pgClient.end();
console.log('\n✅ Script completed successfully'); console.log('\n👋 Disconnected from database');
process.exit(0); }
}) }
.catch((error) => {
console.error('\n❌ Script failed:', error); restoreDocumentReferences()
process.exit(1); .then(() => {
}); console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch(error => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -1,44 +1,44 @@
const { DataSource } = require('typeorm'); const { DataSource } = require('typeorm');
const path = require('path'); const path = require('path');
const AppDataSource = new DataSource({ const AppDataSource = new DataSource({
type: 'postgres', type: 'postgres',
host: process.env.DATABASE_HOST, host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT, 10), port: parseInt(process.env.DATABASE_PORT, 10),
username: process.env.DATABASE_USER, username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD, password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME, database: process.env.DATABASE_NAME,
entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')], entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')],
migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')], migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')],
synchronize: false, synchronize: false,
logging: true, logging: true,
}); });
console.log('🚀 Starting Xpeditis Backend Migration Script...'); console.log('🚀 Starting Xpeditis Backend Migration Script...');
console.log('📦 Initializing DataSource...'); console.log('📦 Initializing DataSource...');
AppDataSource.initialize() AppDataSource.initialize()
.then(async () => { .then(async () => {
console.log('✅ DataSource initialized successfully'); console.log('✅ DataSource initialized successfully');
console.log('🔄 Running pending migrations...'); console.log('🔄 Running pending migrations...');
const migrations = await AppDataSource.runMigrations(); const migrations = await AppDataSource.runMigrations();
if (migrations.length === 0) { if (migrations.length === 0) {
console.log('✅ No pending migrations'); console.log('✅ No pending migrations');
} else { } else {
console.log(`✅ Successfully ran ${migrations.length} migration(s):`); console.log(`✅ Successfully ran ${migrations.length} migration(s):`);
migrations.forEach((migration) => { migrations.forEach(migration => {
console.log(` - ${migration.name}`); console.log(` - ${migration.name}`);
}); });
} }
await AppDataSource.destroy(); await AppDataSource.destroy();
console.log('✅ Database migrations completed successfully'); console.log('✅ Database migrations completed successfully');
process.exit(0); process.exit(0);
}) })
.catch((error) => { .catch(error => {
console.error('❌ Error during migration:'); console.error('❌ Error during migration:');
console.error(error); console.error(error);
process.exit(1); process.exit(1);
}); });

View File

@ -210,10 +210,7 @@ function parseSeaPorts(filePath: string): ParsedPort[] {
// Validate coordinates // Validate coordinates
const [longitude, latitude] = port.coordinates; const [longitude, latitude] = port.coordinates;
if ( if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
latitude < -90 || latitude > 90 ||
longitude < -180 || longitude > 180
) {
skipped++; skipped++;
continue; continue;
} }
@ -244,13 +241,14 @@ function generateSQLInserts(ports: ParsedPort[]): string {
for (let i = 0; i < ports.length; i += batchSize) { for (let i = 0; i < ports.length; i += batchSize) {
const batch = ports.slice(i, i + batchSize); const batch = ports.slice(i, i + batchSize);
const values = batch.map(port => { const values = batch
const name = port.name.replace(/'/g, "''"); .map(port => {
const city = port.city.replace(/'/g, "''"); const name = port.name.replace(/'/g, "''");
const countryName = port.countryName.replace(/'/g, "''"); const city = port.city.replace(/'/g, "''");
const timezone = port.timezone ? `'${port.timezone}'` : 'NULL'; const countryName = port.countryName.replace(/'/g, "''");
const timezone = port.timezone ? `'${port.timezone}'` : 'NULL';
return `( return `(
'${port.code}', '${port.code}',
'${name}', '${name}',
'${city}', '${city}',
@ -261,7 +259,8 @@ function generateSQLInserts(ports: ParsedPort[]): string {
${timezone}, ${timezone},
${port.isActive} ${port.isActive}
)`; )`;
}).join(',\n '); })
.join(',\n ');
batches.push(` batches.push(`
// Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(ports.length / batchSize)} (${batch.length} ports) // Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(ports.length / batchSize)} (${batch.length} ports)
@ -321,7 +320,9 @@ async function main() {
if (!fs.existsSync(seaPortsPath)) { if (!fs.existsSync(seaPortsPath)) {
console.error('❌ Error: /tmp/sea-ports.json not found!'); console.error('❌ Error: /tmp/sea-ports.json not found!');
console.log('Please download it first:'); console.log('Please download it first:');
console.log('curl -o /tmp/sea-ports.json https://raw.githubusercontent.com/marchah/sea-ports/master/lib/ports.json'); console.log(
'curl -o /tmp/sea-ports.json https://raw.githubusercontent.com/marchah/sea-ports/master/lib/ports.json'
);
process.exit(1); process.exit(1);
} }
@ -342,7 +343,10 @@ async function main() {
const migrationContent = generateMigration(ports); const migrationContent = generateMigration(ports);
// Write migration file // Write migration file
const migrationsDir = path.join(__dirname, '../src/infrastructure/persistence/typeorm/migrations'); const migrationsDir = path.join(
__dirname,
'../src/infrastructure/persistence/typeorm/migrations'
);
const timestamp = Date.now(); const timestamp = Date.now();
const fileName = `${timestamp}-SeedPorts.ts`; const fileName = `${timestamp}-SeedPorts.ts`;
const filePath = path.join(migrationsDir, fileName); const filePath = path.join(migrationsDir, fileName);

View File

@ -5,7 +5,10 @@
const Stripe = require('stripe'); const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr'); const stripe = new Stripe(
process.env.STRIPE_SECRET_KEY ||
'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr'
);
async function listPrices() { async function listPrices() {
console.log('Fetching Stripe prices...\n'); console.log('Fetching Stripe prices...\n');
@ -46,7 +49,6 @@ async function listPrices() {
console.log('STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx'); console.log('STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx');
console.log('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx'); console.log('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx');
console.log('STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx'); console.log('STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx');
} catch (error) { } catch (error) {
console.error('Error fetching prices:', error.message); console.error('Error fetching prices:', error.message);
} }

View File

@ -1,79 +1,79 @@
/** /**
* Script to set MinIO bucket policy for public read access * Script to set MinIO bucket policy for public read access
* *
* This allows documents to be downloaded directly via URL without authentication * This allows documents to be downloaded directly via URL without authentication
*/ */
const { S3Client, PutBucketPolicyCommand, GetBucketPolicyCommand } = require('@aws-sdk/client-s3'); const { S3Client, PutBucketPolicyCommand, GetBucketPolicyCommand } = require('@aws-sdk/client-s3');
require('dotenv').config(); require('dotenv').config();
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
const BUCKET_NAME = 'xpeditis-documents'; const BUCKET_NAME = 'xpeditis-documents';
// Initialize MinIO client // Initialize MinIO client
const s3Client = new S3Client({ const s3Client = new S3Client({
region: 'us-east-1', region: 'us-east-1',
endpoint: MINIO_ENDPOINT, endpoint: MINIO_ENDPOINT,
credentials: { credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
}, },
forcePathStyle: true, forcePathStyle: true,
}); });
async function setBucketPolicy() { async function setBucketPolicy() {
try { try {
// Policy to allow public read access to all objects in the bucket // Policy to allow public read access to all objects in the bucket
const policy = { const policy = {
Version: '2012-10-17', Version: '2012-10-17',
Statement: [ Statement: [
{ {
Effect: 'Allow', Effect: 'Allow',
Principal: '*', Principal: '*',
Action: ['s3:GetObject'], Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`], Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
}, },
], ],
}; };
console.log('📋 Setting bucket policy for:', BUCKET_NAME); console.log('📋 Setting bucket policy for:', BUCKET_NAME);
console.log('Policy:', JSON.stringify(policy, null, 2)); console.log('Policy:', JSON.stringify(policy, null, 2));
// Set the bucket policy // Set the bucket policy
await s3Client.send( await s3Client.send(
new PutBucketPolicyCommand({ new PutBucketPolicyCommand({
Bucket: BUCKET_NAME, Bucket: BUCKET_NAME,
Policy: JSON.stringify(policy), Policy: JSON.stringify(policy),
}) })
); );
console.log('\n✅ Bucket policy set successfully!'); console.log('\n✅ Bucket policy set successfully!');
console.log(` All objects in ${BUCKET_NAME} are now publicly readable`); console.log(` All objects in ${BUCKET_NAME} are now publicly readable`);
// Verify the policy was set // Verify the policy was set
console.log('\n🔍 Verifying bucket policy...'); console.log('\n🔍 Verifying bucket policy...');
const getPolicy = await s3Client.send( const getPolicy = await s3Client.send(
new GetBucketPolicyCommand({ new GetBucketPolicyCommand({
Bucket: BUCKET_NAME, Bucket: BUCKET_NAME,
}) })
); );
console.log('✅ Current policy:', getPolicy.Policy); console.log('✅ Current policy:', getPolicy.Policy);
console.log('\n📝 Note: This allows public read access to all documents.'); console.log('\n📝 Note: This allows public read access to all documents.');
console.log(' For production, consider using signed URLs instead.'); console.log(' For production, consider using signed URLs instead.');
} catch (error) { } catch (error) {
console.error('❌ Error:', error); console.error('❌ Error:', error);
throw error; throw error;
} }
} }
setBucketPolicy() setBucketPolicy()
.then(() => { .then(() => {
console.log('\n✅ Script completed successfully'); console.log('\n✅ Script completed successfully');
process.exit(0); process.exit(0);
}) })
.catch((error) => { .catch(error => {
console.error('\n❌ Script failed:', error); console.error('\n❌ Script failed:', error);
process.exit(1); process.exit(1);
}); });

View File

@ -1,91 +1,91 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Setup MinIO Bucket * Setup MinIO Bucket
* *
* Creates the required bucket for document storage if it doesn't exist * Creates the required bucket for document storage if it doesn't exist
*/ */
const { S3Client, CreateBucketCommand, HeadBucketCommand } = require('@aws-sdk/client-s3'); const { S3Client, CreateBucketCommand, HeadBucketCommand } = require('@aws-sdk/client-s3');
require('dotenv').config(); require('dotenv').config();
const BUCKET_NAME = 'xpeditis-documents'; const BUCKET_NAME = 'xpeditis-documents';
// Configure S3 client for MinIO // Configure S3 client for MinIO
const s3Client = new S3Client({ const s3Client = new S3Client({
region: process.env.AWS_REGION || 'us-east-1', region: process.env.AWS_REGION || 'us-east-1',
endpoint: process.env.AWS_S3_ENDPOINT || 'http://localhost:9000', endpoint: process.env.AWS_S3_ENDPOINT || 'http://localhost:9000',
credentials: { credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
}, },
forcePathStyle: true, // Required for MinIO forcePathStyle: true, // Required for MinIO
}); });
async function setupBucket() { async function setupBucket() {
console.log('\n🪣 MinIO Bucket Setup'); console.log('\n🪣 MinIO Bucket Setup');
console.log('=========================================='); console.log('==========================================');
console.log(`Bucket name: ${BUCKET_NAME}`); console.log(`Bucket name: ${BUCKET_NAME}`);
console.log(`Endpoint: ${process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'}`); console.log(`Endpoint: ${process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'}`);
console.log(''); console.log('');
try { try {
// Check if bucket exists // Check if bucket exists
console.log('📋 Step 1: Checking if bucket exists...'); console.log('📋 Step 1: Checking if bucket exists...');
try { try {
await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME })); await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }));
console.log(`✅ Bucket '${BUCKET_NAME}' already exists`); console.log(`✅ Bucket '${BUCKET_NAME}' already exists`);
console.log(''); console.log('');
console.log('✅ Setup complete! The bucket is ready to use.'); console.log('✅ Setup complete! The bucket is ready to use.');
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
console.log(` Bucket '${BUCKET_NAME}' does not exist`); console.log(` Bucket '${BUCKET_NAME}' does not exist`);
} else { } else {
throw error; throw error;
} }
} }
// Create bucket // Create bucket
console.log(''); console.log('');
console.log('📋 Step 2: Creating bucket...'); console.log('📋 Step 2: Creating bucket...');
await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME })); await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME }));
console.log(`✅ Bucket '${BUCKET_NAME}' created successfully!`); console.log(`✅ Bucket '${BUCKET_NAME}' created successfully!`);
// Verify creation // Verify creation
console.log(''); console.log('');
console.log('📋 Step 3: Verifying bucket...'); console.log('📋 Step 3: Verifying bucket...');
await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME })); await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }));
console.log(`✅ Bucket '${BUCKET_NAME}' verified!`); console.log(`✅ Bucket '${BUCKET_NAME}' verified!`);
console.log(''); console.log('');
console.log('=========================================='); console.log('==========================================');
console.log('✅ Setup complete! The bucket is ready to use.'); console.log('✅ Setup complete! The bucket is ready to use.');
console.log(''); console.log('');
console.log('You can now:'); console.log('You can now:');
console.log(' 1. Create CSV bookings via the frontend'); console.log(' 1. Create CSV bookings via the frontend');
console.log(' 2. Upload documents to this bucket'); console.log(' 2. Upload documents to this bucket');
console.log(' 3. View files at: http://localhost:9001 (MinIO Console)'); console.log(' 3. View files at: http://localhost:9001 (MinIO Console)');
console.log(''); console.log('');
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
console.error(''); console.error('');
console.error('❌ ERROR: Failed to setup bucket'); console.error('❌ ERROR: Failed to setup bucket');
console.error(''); console.error('');
console.error('Error details:'); console.error('Error details:');
console.error(` Name: ${error.name}`); console.error(` Name: ${error.name}`);
console.error(` Message: ${error.message}`); console.error(` Message: ${error.message}`);
if (error.$metadata) { if (error.$metadata) {
console.error(` HTTP Status: ${error.$metadata.httpStatusCode}`); console.error(` HTTP Status: ${error.$metadata.httpStatusCode}`);
} }
console.error(''); console.error('');
console.error('Common solutions:'); console.error('Common solutions:');
console.error(' 1. Check if MinIO is running: docker ps | grep minio'); console.error(' 1. Check if MinIO is running: docker ps | grep minio');
console.error(' 2. Verify credentials in .env file'); console.error(' 2. Verify credentials in .env file');
console.error(' 3. Ensure AWS_S3_ENDPOINT is set correctly'); console.error(' 3. Ensure AWS_S3_ENDPOINT is set correctly');
console.error(''); console.error('');
process.exit(1); process.exit(1);
} }
} }
setupBucket(); setupBucket();

View File

@ -28,6 +28,7 @@ import { WebhooksModule } from './application/webhooks/webhooks.module';
import { GDPRModule } from './application/gdpr/gdpr.module'; import { GDPRModule } from './application/gdpr/gdpr.module';
import { CsvBookingsModule } from './application/csv-bookings.module'; import { CsvBookingsModule } from './application/csv-bookings.module';
import { AdminModule } from './application/admin/admin.module'; import { AdminModule } from './application/admin/admin.module';
import { BlogModule } from './application/blog/blog.module';
import { LogsModule } from './application/logs/logs.module'; import { LogsModule } from './application/logs/logs.module';
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module'; import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
import { ApiKeysModule } from './application/api-keys/api-keys.module'; import { ApiKeysModule } from './application/api-keys/api-keys.module';
@ -179,6 +180,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
WebhooksModule, WebhooksModule,
GDPRModule, GDPRModule,
AdminModule, AdminModule,
BlogModule,
SubscriptionsModule, SubscriptionsModule,
ApiKeysModule, ApiKeysModule,
LogsModule, LogsModule,

View File

@ -29,18 +29,20 @@ import { CsvBookingsModule } from '../csv-bookings.module';
// Email // Email
import { EmailModule } from '@infrastructure/email/email.module'; import { EmailModule } from '@infrastructure/email/email.module';
/** // Blog
* Admin Module import { BlogModule } from '../blog/blog.module';
*
* Provides admin-only endpoints for managing all data in the system. // Storage
* All endpoints require ADMIN role. import { StorageModule } from '@infrastructure/storage/storage.module';
*/
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]), TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]),
ConfigModule, ConfigModule,
CsvBookingsModule, CsvBookingsModule,
EmailModule, EmailModule,
BlogModule,
StorageModule,
], ],
controllers: [AdminController], controllers: [AdminController],
providers: [ providers: [

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BlogController } from '../controllers/blog.controller';
import { BlogService } from '../services/blog.service';
import { BlogPostOrmEntity } from '../../infrastructure/persistence/typeorm/entities/blog-post.orm-entity';
import { TypeOrmBlogPostRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-blog-post.repository';
import { BLOG_POST_REPOSITORY } from '@domain/ports/out/blog-post.repository';
import { StorageModule } from '../../infrastructure/storage/storage.module';
@Module({
imports: [TypeOrmModule.forFeature([BlogPostOrmEntity]), StorageModule],
controllers: [BlogController],
providers: [
BlogService,
{
provide: BLOG_POST_REPOSITORY,
useClass: TypeOrmBlogPostRepository,
},
],
exports: [BlogService],
})
export class BlogModule {}

View File

@ -6,6 +6,7 @@ import {
Delete, Delete,
Param, Param,
Body, Body,
Query,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Logger, Logger,
@ -15,14 +16,22 @@ import {
BadRequestException, BadRequestException,
ParseUUIDPipe, ParseUUIDPipe,
UseGuards, UseGuards,
UseInterceptors,
UploadedFile,
Inject, Inject,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { v4 as uuidv4 } from 'uuid';
import * as path from 'path';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiNotFoundResponse, ApiNotFoundResponse,
ApiParam, ApiParam,
ApiQuery,
ApiConsumes,
ApiBearerAuth, ApiBearerAuth,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
@ -56,6 +65,25 @@ import {
// Email imports // Email imports
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
// Blog imports
import { BlogService } from '../services/blog.service';
import { CreateBlogPostDto, UpdateBlogPostDto } from '../dto/blog-post.dto';
import { BlogPost } from '@domain/entities/blog-post.entity';
import type { BlogPostCategory } from '@domain/entities/blog-post.entity';
// Storage imports
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
const BLOG_IMAGES_BUCKET = 'xpeditis-blog';
const ALLOWED_IMAGE_MIMETYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'image/svg+xml',
];
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
/** /**
* Admin Controller * Admin Controller
* *
@ -80,7 +108,9 @@ export class AdminController {
private readonly csvBookingService: CsvBookingService, private readonly csvBookingService: CsvBookingService,
@Inject(SIRET_VERIFICATION_PORT) @Inject(SIRET_VERIFICATION_PORT)
private readonly siretVerificationPort: SiretVerificationPort, private readonly siretVerificationPort: SiretVerificationPort,
@Inject(EMAIL_PORT) private readonly emailPort: EmailPort @Inject(EMAIL_PORT) private readonly emailPort: EmailPort,
private readonly blogService: BlogService,
@Inject(STORAGE_PORT) private readonly storage: StoragePort
) {} ) {}
// ==================== USERS ENDPOINTS ==================== // ==================== USERS ENDPOINTS ====================
@ -912,4 +942,134 @@ export class AdminController {
this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`); this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`);
return { success: true, message: 'Document deleted successfully' }; return { success: true, message: 'Document deleted successfully' };
} }
// ==================== BLOG ENDPOINTS ====================
@Post('blog/images')
@UseInterceptors(
FileInterceptor('image', {
storage: memoryStorage(),
limits: { fileSize: MAX_IMAGE_SIZE },
fileFilter: (_req, file, cb) => {
if (ALLOWED_IMAGE_MIMETYPES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(
new BadRequestException('Only image files are allowed (jpg, png, webp, gif, svg)'),
false
);
}
},
})
)
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload a blog image to storage (Admin only)' })
@ApiResponse({
status: 201,
schema: { properties: { url: { type: 'string' }, filename: { type: 'string' } } },
})
async uploadBlogImage(
@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 image: ${file.originalname}`);
const ext = path.extname(file.originalname).toLowerCase();
const sanitizedName = path
.basename(file.originalname, ext)
.replace(/[^a-z0-9]/gi, '-')
.toLowerCase();
const filename = `${uuidv4()}-${sanitizedName}${ext}`;
const key = `blog-images/${filename}`;
await this.storage.upload({
bucket: BLOG_IMAGES_BUCKET,
key,
body: file.buffer,
contentType: file.mimetype,
});
this.logger.log(`[ADMIN] Blog image uploaded: ${key}`);
return { url: `/api/v1/blog/images/${filename}`, filename };
}
@Get('blog')
@ApiOperation({ summary: 'List all blog posts (Admin only)' })
@ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'category', required: false })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'offset', required: false, type: Number })
async listBlogPosts(
@Query('status') status?: any,
@Query('category') category?: BlogPostCategory,
@Query('search') search?: string,
@Query('limit') limit = 50,
@Query('offset') offset = 0,
@CurrentUser() user?: UserPayload
) {
this.logger.log(`[ADMIN: ${user?.email}] Listing blog posts`);
const { posts, total } = await this.blogService.listAllPosts({
status,
category,
search,
limit: Number(limit),
offset: Number(offset),
});
return { posts: posts.map(this.mapBlogPostToDto), total };
}
@Post('blog')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({ summary: 'Create a blog post (Admin only)' })
async createBlogPost(@Body() dto: CreateBlogPostDto, @CurrentUser() user: UserPayload) {
this.logger.log(`[ADMIN: ${user.email}] Creating blog post: ${dto.slug}`);
const post = await this.blogService.createPost(dto);
return this.mapBlogPostToDto(post);
}
@Patch('blog/:id')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({ summary: 'Update a blog post (Admin only)' })
async updateBlogPost(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateBlogPostDto,
@CurrentUser() user: UserPayload
) {
this.logger.log(`[ADMIN: ${user.email}] Updating blog post: ${id}`);
const post = await this.blogService.updatePost(id, dto);
return this.mapBlogPostToDto(post);
}
@Delete('blog/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a blog post (Admin only)' })
async deleteBlogPost(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload
): Promise<void> {
this.logger.log(`[ADMIN: ${user.email}] Deleting blog post: ${id}`);
await this.blogService.deletePost(id);
}
private mapBlogPostToDto(post: BlogPost) {
return {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
content: post.content,
coverImageUrl: post.coverImageUrl,
category: post.category,
tags: post.tags,
authorName: post.authorName,
status: post.status,
isFeatured: post.isFeatured,
publishedAt: post.publishedAt,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
};
}
} }

View File

@ -0,0 +1,131 @@
import {
Controller,
Get,
Param,
Query,
HttpCode,
HttpStatus,
Res,
NotFoundException,
Inject,
Logger,
StreamableFile,
} from '@nestjs/common';
import { Response } from 'express';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { Public } from '../decorators/public.decorator';
import { BlogService } from '../services/blog.service';
import { BlogPost } from '@domain/entities/blog-post.entity';
import { BlogPostResponseDto, BlogPostListResponseDto } from '../dto/blog-post.dto';
import type { BlogPostCategory } from '@domain/entities/blog-post.entity';
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
const BLOG_IMAGES_BUCKET = 'xpeditis-blog';
@ApiTags('Blog')
@Controller('blog')
@Public()
export class BlogController {
private readonly logger = new Logger(BlogController.name);
constructor(
private readonly blogService: BlogService,
@Inject(STORAGE_PORT) private readonly storage: StoragePort
) {}
@Get()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'List published blog posts' })
@ApiQuery({
name: 'category',
required: false,
enum: ['industry', 'technology', 'guides', 'news'],
})
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'offset', required: false, type: Number })
@ApiResponse({ status: 200, type: BlogPostListResponseDto })
async listPosts(
@Query('category') category?: BlogPostCategory,
@Query('search') search?: string,
@Query('limit') limit = 20,
@Query('offset') offset = 0
): Promise<BlogPostListResponseDto> {
const { posts, total } = await this.blogService.listPublishedPosts({
category,
search,
limit: Number(limit),
offset: Number(offset),
});
return {
posts: posts.map(this.mapToDto),
total,
limit: Number(limit),
offset: Number(offset),
};
}
@Get('images/:filename')
@ApiOperation({ summary: 'Serve a blog image from storage' })
@ApiParam({ name: 'filename' })
async serveImage(
@Param('filename') filename: string,
@Res({ passthrough: true }) res: Response
): Promise<StreamableFile> {
const key = `blog-images/${filename}`;
let buffer: Buffer;
try {
buffer = await this.storage.download({ bucket: BLOG_IMAGES_BUCKET, key });
} catch (err: any) {
this.logger.error(`Failed to serve blog image "${key}": ${err?.message}`);
throw new NotFoundException(`Image not found: ${filename}`);
}
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
const contentTypeMap: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
svg: 'image/svg+xml',
};
const contentType = contentTypeMap[ext] ?? 'application/octet-stream';
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
return new StreamableFile(buffer);
}
@Get(':slug')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Get a published blog post by slug' })
@ApiParam({ name: 'slug' })
@ApiResponse({ status: 200, type: BlogPostResponseDto })
async getPost(@Param('slug') slug: string): Promise<BlogPostResponseDto> {
const post = await this.blogService.getPublishedPostBySlug(slug);
return this.mapToDto(post);
}
private mapToDto(post: BlogPost): BlogPostResponseDto {
return {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
content: post.content,
coverImageUrl: post.coverImageUrl,
category: post.category,
tags: post.tags,
authorName: post.authorName,
status: post.status,
isFeatured: post.isFeatured,
publishedAt: post.publishedAt,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
};
}
}

View File

@ -0,0 +1,150 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsNotEmpty,
IsOptional,
IsArray,
IsBoolean,
IsEnum,
MaxLength,
MinLength,
Matches,
} from 'class-validator';
import { BlogPostStatus, type BlogPostCategory } from '@domain/entities/blog-post.entity';
const CATEGORIES: BlogPostCategory[] = ['industry', 'technology', 'guides', 'news'];
export class CreateBlogPostDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(255)
title: string;
@ApiProperty({ description: 'URL-friendly slug, e.g. "my-article"' })
@IsString()
@IsNotEmpty()
@MaxLength(255)
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: 'Slug must be lowercase alphanumeric with hyphens',
})
slug: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
@MinLength(10)
excerpt: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
content: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
coverImageUrl?: string;
@ApiProperty({ enum: CATEGORIES })
@IsEnum(CATEGORIES)
category: BlogPostCategory;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(255)
authorName: string;
}
export class UpdateBlogPostDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(255)
title?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(255)
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: 'Slug must be lowercase alphanumeric with hyphens',
})
slug?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
excerpt?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
content?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
coverImageUrl?: string;
@ApiPropertyOptional({ enum: CATEGORIES })
@IsOptional()
@IsEnum(CATEGORIES)
category?: BlogPostCategory;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(255)
authorName?: string;
@ApiPropertyOptional({ enum: BlogPostStatus })
@IsOptional()
@IsEnum(BlogPostStatus)
status?: BlogPostStatus;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isFeatured?: boolean;
}
export class BlogPostResponseDto {
@ApiProperty() id: string;
@ApiProperty() title: string;
@ApiProperty() slug: string;
@ApiProperty() excerpt: string;
@ApiProperty() content: string;
@ApiPropertyOptional() coverImageUrl?: string;
@ApiProperty() category: string;
@ApiProperty({ type: [String] }) tags: string[];
@ApiProperty() authorName: string;
@ApiProperty({ enum: BlogPostStatus }) status: BlogPostStatus;
@ApiProperty() isFeatured: boolean;
@ApiPropertyOptional() publishedAt?: Date;
@ApiProperty() createdAt: Date;
@ApiProperty() updatedAt: Date;
}
export class BlogPostListResponseDto {
@ApiProperty({ type: [BlogPostResponseDto] }) posts: BlogPostResponseDto[];
@ApiProperty() total: number;
@ApiProperty() limit: number;
@ApiProperty() offset: number;
}

View File

@ -0,0 +1,139 @@
import {
Injectable,
Inject,
NotFoundException,
ConflictException,
Logger,
OnApplicationBootstrap,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { BlogPost, BlogPostStatus } from '@domain/entities/blog-post.entity';
import {
BlogPostRepository,
BlogPostFilters,
BLOG_POST_REPOSITORY,
} from '@domain/ports/out/blog-post.repository';
import { CreateBlogPostDto, UpdateBlogPostDto } from '../dto/blog-post.dto';
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
const BLOG_IMAGES_BUCKET = 'xpeditis-blog';
@Injectable()
export class BlogService implements OnApplicationBootstrap {
private readonly logger = new Logger(BlogService.name);
constructor(
@Inject(BLOG_POST_REPOSITORY)
private readonly blogPostRepository: BlogPostRepository,
@Inject(STORAGE_PORT)
private readonly storage: StoragePort
) {}
async onApplicationBootstrap(): Promise<void> {
try {
await this.storage.ensureBucket(BLOG_IMAGES_BUCKET);
this.logger.log(`Blog images bucket "${BLOG_IMAGES_BUCKET}" is ready`);
} catch (err: any) {
this.logger.warn(`Could not ensure blog images bucket: ${err?.message}`);
}
}
async createPost(dto: CreateBlogPostDto): Promise<BlogPost> {
const slugTaken = await this.blogPostRepository.slugExists(dto.slug);
if (slugTaken) {
throw new ConflictException(`Slug "${dto.slug}" is already in use`);
}
const post = BlogPost.create({
id: uuidv4(),
title: dto.title,
slug: dto.slug,
excerpt: dto.excerpt,
content: dto.content,
coverImageUrl: dto.coverImageUrl,
category: dto.category,
tags: dto.tags ?? [],
authorName: dto.authorName,
});
return this.blogPostRepository.save(post);
}
async updatePost(id: string, dto: UpdateBlogPostDto): Promise<BlogPost> {
const post = await this.findOrFail(id);
if (dto.slug && dto.slug !== post.slug) {
const slugTaken = await this.blogPostRepository.slugExists(dto.slug, id);
if (slugTaken) {
throw new ConflictException(`Slug "${dto.slug}" is already in use`);
}
}
let updated = post.update({
title: dto.title,
slug: dto.slug,
excerpt: dto.excerpt,
content: dto.content,
coverImageUrl: dto.coverImageUrl,
category: dto.category,
tags: dto.tags,
authorName: dto.authorName,
isFeatured: dto.isFeatured,
});
if (dto.status !== undefined && dto.status !== post.status) {
if (dto.status === BlogPostStatus.PUBLISHED) updated = updated.publish();
else if (dto.status === BlogPostStatus.ARCHIVED) updated = updated.archive();
else if (dto.status === BlogPostStatus.DRAFT) updated = updated.unpublish();
}
return this.blogPostRepository.save(updated);
}
async deletePost(id: string): Promise<void> {
await this.findOrFail(id);
await this.blogPostRepository.delete(id);
}
async getPostById(id: string): Promise<BlogPost> {
return this.findOrFail(id);
}
async getPublishedPostBySlug(slug: string): Promise<BlogPost> {
const post = await this.blogPostRepository.findBySlug(slug);
if (!post || !post.isPublished()) {
throw new NotFoundException('Article not found');
}
return post;
}
async listPublishedPosts(
filters: BlogPostFilters
): Promise<{ posts: BlogPost[]; total: number }> {
const publishedFilters: BlogPostFilters = {
...filters,
status: BlogPostStatus.PUBLISHED,
};
const [posts, total] = await Promise.all([
this.blogPostRepository.findByFilters(publishedFilters),
this.blogPostRepository.count(publishedFilters),
]);
return { posts, total };
}
async listAllPosts(filters: BlogPostFilters): Promise<{ posts: BlogPost[]; total: number }> {
const [posts, total] = await Promise.all([
this.blogPostRepository.findByFilters(filters),
this.blogPostRepository.count(filters),
]);
return { posts, total };
}
private async findOrFail(id: string): Promise<BlogPost> {
const post = await this.blogPostRepository.findById(id);
if (!post) {
throw new NotFoundException(`Blog post with id "${id}" not found`);
}
return post;
}
}

View File

@ -0,0 +1,132 @@
export enum BlogPostStatus {
DRAFT = 'draft',
PUBLISHED = 'published',
ARCHIVED = 'archived',
}
export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news';
interface BlogPostProps {
id: string;
title: string;
slug: string;
excerpt: string;
content: string;
coverImageUrl?: string;
category: BlogPostCategory;
tags: string[];
authorName: string;
status: BlogPostStatus;
isFeatured: boolean;
publishedAt?: Date;
createdAt: Date;
updatedAt: Date;
}
export class BlogPost {
private constructor(private readonly props: BlogPostProps) {}
static create(
props: Omit<BlogPostProps, 'status' | 'isFeatured' | 'publishedAt' | 'createdAt' | 'updatedAt'>
): BlogPost {
const now = new Date();
return new BlogPost({
...props,
status: BlogPostStatus.DRAFT,
isFeatured: false,
createdAt: now,
updatedAt: now,
});
}
static fromPersistence(props: BlogPostProps): BlogPost {
return new BlogPost(props);
}
get id(): string {
return this.props.id;
}
get title(): string {
return this.props.title;
}
get slug(): string {
return this.props.slug;
}
get excerpt(): string {
return this.props.excerpt;
}
get content(): string {
return this.props.content;
}
get coverImageUrl(): string | undefined {
return this.props.coverImageUrl;
}
get category(): BlogPostCategory {
return this.props.category;
}
get tags(): string[] {
return this.props.tags;
}
get authorName(): string {
return this.props.authorName;
}
get status(): BlogPostStatus {
return this.props.status;
}
get isFeatured(): boolean {
return this.props.isFeatured;
}
get publishedAt(): Date | undefined {
return this.props.publishedAt;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
update(
data: Partial<
Pick<
BlogPostProps,
| 'title'
| 'slug'
| 'excerpt'
| 'content'
| 'coverImageUrl'
| 'category'
| 'tags'
| 'authorName'
| 'isFeatured'
>
>
): BlogPost {
return new BlogPost({ ...this.props, ...data, updatedAt: new Date() });
}
publish(): BlogPost {
return new BlogPost({
...this.props,
status: BlogPostStatus.PUBLISHED,
publishedAt: this.props.publishedAt ?? new Date(),
updatedAt: new Date(),
});
}
archive(): BlogPost {
return new BlogPost({ ...this.props, status: BlogPostStatus.ARCHIVED, updatedAt: new Date() });
}
unpublish(): BlogPost {
return new BlogPost({ ...this.props, status: BlogPostStatus.DRAFT, updatedAt: new Date() });
}
isPublished(): boolean {
return this.props.status === BlogPostStatus.PUBLISHED;
}
toObject(): BlogPostProps {
return { ...this.props };
}
}

View File

@ -0,0 +1,22 @@
import { BlogPost, BlogPostCategory, BlogPostStatus } from '@domain/entities/blog-post.entity';
export const BLOG_POST_REPOSITORY = 'BlogPostRepository';
export interface BlogPostFilters {
status?: BlogPostStatus;
category?: BlogPostCategory;
search?: string;
isFeatured?: boolean;
limit?: number;
offset?: number;
}
export interface BlogPostRepository {
save(post: BlogPost): Promise<BlogPost>;
findById(id: string): Promise<BlogPost | null>;
findBySlug(slug: string): Promise<BlogPost | null>;
findByFilters(filters: BlogPostFilters): Promise<BlogPost[]>;
count(filters: BlogPostFilters): Promise<number>;
delete(id: string): Promise<void>;
slugExists(slug: string, excludeId?: string): Promise<boolean>;
}

View File

@ -66,4 +66,9 @@ export interface StoragePort {
* List objects in a bucket * List objects in a bucket
*/ */
list(bucket: string, prefix?: string): Promise<StorageObject[]>; list(bucket: string, prefix?: string): Promise<StorageObject[]>;
/**
* Ensure a bucket exists, creating it if it does not
*/
ensureBucket(bucket: string): Promise<void>;
} }

View File

@ -0,0 +1,48 @@
import { Entity, PrimaryColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
@Entity('blog_posts')
@Index(['status', 'published_at'])
@Index(['slug'], { unique: true })
export class BlogPostOrmEntity {
@PrimaryColumn('uuid')
id: string;
@Column('varchar', { length: 255 })
title: string;
@Column('varchar', { length: 255, unique: true })
slug: string;
@Column('text')
excerpt: string;
@Column('text')
content: string;
@Column('varchar', { length: 500, nullable: true })
cover_image_url?: string;
@Column('varchar', { length: 50 })
category: string;
@Column('jsonb', { default: [] })
tags: string[];
@Column('varchar', { length: 255 })
author_name: string;
@Column('varchar', { length: 20, default: 'draft' })
status: string;
@Column('boolean', { default: false })
is_featured: boolean;
@Column('timestamp', { nullable: true })
published_at?: Date;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@ -0,0 +1,116 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreateBlogPostsTable1746000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'blog_posts',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
},
{
name: 'title',
type: 'varchar',
length: '255',
isNullable: false,
},
{
name: 'slug',
type: 'varchar',
length: '255',
isNullable: false,
isUnique: true,
},
{
name: 'excerpt',
type: 'text',
isNullable: false,
},
{
name: 'content',
type: 'text',
isNullable: false,
},
{
name: 'cover_image_url',
type: 'varchar',
length: '500',
isNullable: true,
},
{
name: 'category',
type: 'varchar',
length: '50',
isNullable: false,
},
{
name: 'tags',
type: 'jsonb',
isNullable: false,
default: "'[]'",
},
{
name: 'author_name',
type: 'varchar',
length: '255',
isNullable: false,
},
{
name: 'status',
type: 'varchar',
length: '20',
isNullable: false,
default: "'draft'",
},
{
name: 'is_featured',
type: 'boolean',
isNullable: false,
default: false,
},
{
name: 'published_at',
type: 'timestamp',
isNullable: true,
},
{
name: 'created_at',
type: 'timestamp',
default: 'CURRENT_TIMESTAMP',
isNullable: false,
},
{
name: 'updated_at',
type: 'timestamp',
default: 'CURRENT_TIMESTAMP',
isNullable: false,
},
],
}),
true
);
await queryRunner.createIndex(
'blog_posts',
new TableIndex({
name: 'idx_blog_posts_status_published_at',
columnNames: ['status', 'published_at'],
})
);
await queryRunner.createIndex(
'blog_posts',
new TableIndex({
name: 'idx_blog_posts_category_status',
columnNames: ['category', 'status'],
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('blog_posts');
}
}

View File

@ -0,0 +1,131 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BlogPost, BlogPostCategory, BlogPostStatus } from '@domain/entities/blog-post.entity';
import { BlogPostFilters, BlogPostRepository } from '@domain/ports/out/blog-post.repository';
import { BlogPostOrmEntity } from '../entities/blog-post.orm-entity';
@Injectable()
export class TypeOrmBlogPostRepository implements BlogPostRepository {
constructor(
@InjectRepository(BlogPostOrmEntity)
private readonly ormRepository: Repository<BlogPostOrmEntity>
) {}
async save(post: BlogPost): Promise<BlogPost> {
const orm = this.toOrm(post);
const saved = await this.ormRepository.save(orm);
return this.toDomain(saved);
}
async findById(id: string): Promise<BlogPost | null> {
const orm = await this.ormRepository.findOne({ where: { id } });
return orm ? this.toDomain(orm) : null;
}
async findBySlug(slug: string): Promise<BlogPost | null> {
const orm = await this.ormRepository.findOne({ where: { slug } });
return orm ? this.toDomain(orm) : null;
}
async findByFilters(filters: BlogPostFilters): Promise<BlogPost[]> {
const query = this.ormRepository.createQueryBuilder('post');
if (filters.status) {
query.andWhere('post.status = :status', { status: filters.status });
}
if (filters.category) {
query.andWhere('post.category = :category', { category: filters.category });
}
if (filters.isFeatured !== undefined) {
query.andWhere('post.is_featured = :isFeatured', { isFeatured: filters.isFeatured });
}
if (filters.search) {
query.andWhere('(post.title ILIKE :search OR post.excerpt ILIKE :search)', {
search: `%${filters.search}%`,
});
}
query.orderBy('post.published_at', 'DESC').addOrderBy('post.created_at', 'DESC');
if (filters.offset) query.skip(filters.offset);
if (filters.limit) query.take(filters.limit);
const results = await query.getMany();
return results.map(e => this.toDomain(e));
}
async count(filters: BlogPostFilters): Promise<number> {
const query = this.ormRepository.createQueryBuilder('post');
if (filters.status) {
query.andWhere('post.status = :status', { status: filters.status });
}
if (filters.category) {
query.andWhere('post.category = :category', { category: filters.category });
}
if (filters.search) {
query.andWhere('(post.title ILIKE :search OR post.excerpt ILIKE :search)', {
search: `%${filters.search}%`,
});
}
return query.getCount();
}
async delete(id: string): Promise<void> {
await this.ormRepository.delete(id);
}
async slugExists(slug: string, excludeId?: string): Promise<boolean> {
const query = this.ormRepository
.createQueryBuilder('post')
.where('post.slug = :slug', { slug });
if (excludeId) {
query.andWhere('post.id != :excludeId', { excludeId });
}
const count = await query.getCount();
return count > 0;
}
private toDomain(orm: BlogPostOrmEntity): BlogPost {
return BlogPost.fromPersistence({
id: orm.id,
title: orm.title,
slug: orm.slug,
excerpt: orm.excerpt,
content: orm.content,
coverImageUrl: orm.cover_image_url,
category: orm.category as BlogPostCategory,
tags: orm.tags ?? [],
authorName: orm.author_name,
status: orm.status as BlogPostStatus,
isFeatured: orm.is_featured,
publishedAt: orm.published_at,
createdAt: orm.created_at,
updatedAt: orm.updated_at,
});
}
private toOrm(post: BlogPost): BlogPostOrmEntity {
const orm = new BlogPostOrmEntity();
orm.id = post.id;
orm.title = post.title;
orm.slug = post.slug;
orm.excerpt = post.excerpt;
orm.content = post.content;
orm.cover_image_url = post.coverImageUrl;
orm.category = post.category;
orm.tags = post.tags;
orm.author_name = post.authorName;
orm.status = post.status;
orm.is_featured = post.isFeatured;
orm.published_at = post.publishedAt;
orm.created_at = post.createdAt;
orm.updated_at = post.updatedAt;
return orm;
}
}

View File

@ -12,6 +12,8 @@ import {
GetObjectCommand, GetObjectCommand,
DeleteObjectCommand, DeleteObjectCommand,
HeadObjectCommand, HeadObjectCommand,
HeadBucketCommand,
CreateBucketCommand,
ListObjectsV2Command, ListObjectsV2Command,
} from '@aws-sdk/client-s3'; } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
@ -70,6 +72,23 @@ export class S3StorageAdapter implements StoragePort {
); );
} }
async ensureBucket(bucket: string): Promise<void> {
if (!this.s3Client) return;
try {
await this.s3Client.send(new HeadBucketCommand({ Bucket: bucket }));
} catch (err: any) {
const status = err.$metadata?.httpStatusCode;
if (status === 404 || err.name === 'NoSuchBucket' || err.name === 'NotFound') {
this.logger.log(`Bucket "${bucket}" not found — creating it automatically`);
await this.s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
this.logger.log(`Bucket "${bucket}" created`);
} else {
throw err;
}
}
}
async upload(options: UploadOptions): Promise<StorageObject> { async upload(options: UploadOptions): Promise<StorageObject> {
if (!this.s3Client) { if (!this.s3Client) {
throw new Error( throw new Error(
@ -77,6 +96,8 @@ export class S3StorageAdapter implements StoragePort {
); );
} }
await this.ensureBucket(options.bucket);
try { try {
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: options.bucket, Bucket: options.bucket,
@ -108,6 +129,12 @@ export class S3StorageAdapter implements StoragePort {
} }
async download(options: DownloadOptions): Promise<Buffer> { async download(options: DownloadOptions): Promise<Buffer> {
if (!this.s3Client) {
throw new Error(
'S3 Storage is not configured. Set AWS_S3_ENDPOINT or AWS credentials in .env'
);
}
try { try {
const command = new GetObjectCommand({ const command = new GetObjectCommand({
Bucket: options.bucket, Bucket: options.bucket,

View File

@ -1,102 +1,102 @@
#!/usr/bin/env node #!/usr/bin/env node
const { Client } = require('pg'); const { Client } = require('pg');
const { DataSource } = require('typeorm'); const { DataSource } = require('typeorm');
const path = require('path'); const path = require('path');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
async function waitForPostgres(maxAttempts = 30) { async function waitForPostgres(maxAttempts = 30) {
console.log('⏳ Waiting for PostgreSQL to be ready...'); console.log('⏳ Waiting for PostgreSQL to be ready...');
for (let attempt = 1; attempt <= maxAttempts; attempt++) { for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try { try {
const client = new Client({ const client = new Client({
host: process.env.DATABASE_HOST, host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT, 10), port: parseInt(process.env.DATABASE_PORT, 10),
user: process.env.DATABASE_USER, user: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD, password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME, database: process.env.DATABASE_NAME,
}); });
await client.connect(); await client.connect();
await client.end(); await client.end();
console.log('✅ PostgreSQL is ready'); console.log('✅ PostgreSQL is ready');
return true; return true;
} catch (error) { } catch (error) {
console.log(`⏳ Attempt ${attempt}/${maxAttempts} - PostgreSQL not ready, retrying...`); console.log(`⏳ Attempt ${attempt}/${maxAttempts} - PostgreSQL not ready, retrying...`);
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 2000));
} }
} }
console.error('❌ Failed to connect to PostgreSQL after', maxAttempts, 'attempts'); console.error('❌ Failed to connect to PostgreSQL after', maxAttempts, 'attempts');
process.exit(1); process.exit(1);
} }
async function runMigrations() { async function runMigrations() {
console.log('🔄 Running database migrations...'); console.log('🔄 Running database migrations...');
const AppDataSource = new DataSource({ const AppDataSource = new DataSource({
type: 'postgres', type: 'postgres',
host: process.env.DATABASE_HOST, host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT, 10), port: parseInt(process.env.DATABASE_PORT, 10),
username: process.env.DATABASE_USER, username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD, password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME, database: process.env.DATABASE_NAME,
entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')], entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')],
migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')], migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')],
synchronize: false, synchronize: false,
logging: true, logging: true,
}); });
try { try {
await AppDataSource.initialize(); await AppDataSource.initialize();
console.log('✅ DataSource initialized'); console.log('✅ DataSource initialized');
const migrations = await AppDataSource.runMigrations(); const migrations = await AppDataSource.runMigrations();
if (migrations.length === 0) { if (migrations.length === 0) {
console.log('✅ No pending migrations'); console.log('✅ No pending migrations');
} else { } else {
console.log(`✅ Successfully ran ${migrations.length} migration(s):`); console.log(`✅ Successfully ran ${migrations.length} migration(s):`);
migrations.forEach((migration) => { migrations.forEach(migration => {
console.log(` - ${migration.name}`); console.log(` - ${migration.name}`);
}); });
} }
await AppDataSource.destroy(); await AppDataSource.destroy();
console.log('✅ Database migrations completed'); console.log('✅ Database migrations completed');
return true; return true;
} catch (error) { } catch (error) {
console.error('❌ Error during migration:', error); console.error('❌ Error during migration:', error);
process.exit(1); process.exit(1);
} }
} }
function startApplication() { function startApplication() {
console.log('🚀 Starting NestJS application...'); console.log('🚀 Starting NestJS application...');
const app = spawn('node', ['dist/main'], { const app = spawn('node', ['dist/main'], {
stdio: 'inherit', stdio: 'inherit',
env: process.env env: process.env,
}); });
app.on('exit', (code) => { app.on('exit', code => {
process.exit(code); process.exit(code);
}); });
process.on('SIGTERM', () => app.kill('SIGTERM')); process.on('SIGTERM', () => app.kill('SIGTERM'));
process.on('SIGINT', () => app.kill('SIGINT')); process.on('SIGINT', () => app.kill('SIGINT'));
} }
async function main() { async function main() {
console.log('🚀 Starting Xpeditis Backend...'); console.log('🚀 Starting Xpeditis Backend...');
await waitForPostgres(); await waitForPostgres();
await runMigrations(); await runMigrations();
startApplication(); startApplication();
} }
main().catch((error) => { main().catch(error => {
console.error('❌ Startup failed:', error); console.error('❌ Startup failed:', error);
process.exit(1); process.exit(1);
}); });

View File

@ -1,154 +1,154 @@
/** /**
* Script to sync database with MinIO * Script to sync database with MinIO
* *
* Removes document references from database for files that no longer exist in MinIO * Removes document references from database for files that no longer exist in MinIO
*/ */
const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3'); const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { Client } = require('pg'); const { Client } = require('pg');
require('dotenv').config(); require('dotenv').config();
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
const BUCKET_NAME = 'xpeditis-documents'; const BUCKET_NAME = 'xpeditis-documents';
// Initialize MinIO client // Initialize MinIO client
const s3Client = new S3Client({ const s3Client = new S3Client({
region: 'us-east-1', region: 'us-east-1',
endpoint: MINIO_ENDPOINT, endpoint: MINIO_ENDPOINT,
credentials: { credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
}, },
forcePathStyle: true, forcePathStyle: true,
}); });
async function syncDatabase() { async function syncDatabase() {
const pgClient = new Client({ const pgClient = new Client({
host: process.env.DATABASE_HOST || 'localhost', host: process.env.DATABASE_HOST || 'localhost',
port: process.env.DATABASE_PORT || 5432, port: process.env.DATABASE_PORT || 5432,
user: process.env.DATABASE_USER || 'xpeditis', user: process.env.DATABASE_USER || 'xpeditis',
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
database: process.env.DATABASE_NAME || 'xpeditis_dev', database: process.env.DATABASE_NAME || 'xpeditis_dev',
}); });
try { try {
await pgClient.connect(); await pgClient.connect();
console.log('✅ Connected to database\n'); console.log('✅ Connected to database\n');
// Get all MinIO files // Get all MinIO files
console.log('📋 Listing files in MinIO...'); console.log('📋 Listing files in MinIO...');
let allMinioFiles = []; let allMinioFiles = [];
let continuationToken = null; let continuationToken = null;
do { do {
const command = new ListObjectsV2Command({ const command = new ListObjectsV2Command({
Bucket: BUCKET_NAME, Bucket: BUCKET_NAME,
ContinuationToken: continuationToken, ContinuationToken: continuationToken,
}); });
const response = await s3Client.send(command); const response = await s3Client.send(command);
if (response.Contents) { if (response.Contents) {
allMinioFiles = allMinioFiles.concat(response.Contents.map(f => f.Key)); allMinioFiles = allMinioFiles.concat(response.Contents.map(f => f.Key));
} }
continuationToken = response.NextContinuationToken; continuationToken = response.NextContinuationToken;
} while (continuationToken); } while (continuationToken);
console.log(` Found ${allMinioFiles.length} files in MinIO\n`); console.log(` Found ${allMinioFiles.length} files in MinIO\n`);
// Create a set for faster lookup // Create a set for faster lookup
const minioFilesSet = new Set(allMinioFiles); const minioFilesSet = new Set(allMinioFiles);
// Get all bookings with documents // Get all bookings with documents
const result = await pgClient.query( const result = await pgClient.query(
`SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND jsonb_array_length(documents::jsonb) > 0` `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND jsonb_array_length(documents::jsonb) > 0`
); );
console.log(`📄 Found ${result.rows.length} bookings with documents in database\n`); console.log(`📄 Found ${result.rows.length} bookings with documents in database\n`);
let updatedCount = 0; let updatedCount = 0;
let removedDocsCount = 0; let removedDocsCount = 0;
let emptyBookingsCount = 0; let emptyBookingsCount = 0;
for (const row of result.rows) { for (const row of result.rows) {
const bookingId = row.id; const bookingId = row.id;
const documents = row.documents; const documents = row.documents;
// Filter documents to keep only those that exist in MinIO // Filter documents to keep only those that exist in MinIO
const validDocuments = []; const validDocuments = [];
const missingDocuments = []; const missingDocuments = [];
for (const doc of documents) { for (const doc of documents) {
if (!doc.filePath) { if (!doc.filePath) {
missingDocuments.push(doc); missingDocuments.push(doc);
continue; continue;
} }
// Extract the S3 key from the URL // Extract the S3 key from the URL
try { try {
const url = new URL(doc.filePath); const url = new URL(doc.filePath);
const pathname = url.pathname; const pathname = url.pathname;
// Remove leading slash and bucket name // Remove leading slash and bucket name
const key = pathname.substring(1).replace(`${BUCKET_NAME}/`, ''); const key = pathname.substring(1).replace(`${BUCKET_NAME}/`, '');
if (minioFilesSet.has(key)) { if (minioFilesSet.has(key)) {
validDocuments.push(doc); validDocuments.push(doc);
} else { } else {
missingDocuments.push(doc); missingDocuments.push(doc);
} }
} catch (error) { } catch (error) {
console.error(` ⚠️ Invalid URL for booking ${bookingId}: ${doc.filePath}`); console.error(` ⚠️ Invalid URL for booking ${bookingId}: ${doc.filePath}`);
missingDocuments.push(doc); missingDocuments.push(doc);
} }
} }
if (missingDocuments.length > 0) { if (missingDocuments.length > 0) {
console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`); console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`);
console.log(` Total documents: ${documents.length}`); console.log(` Total documents: ${documents.length}`);
console.log(` Valid documents: ${validDocuments.length}`); console.log(` Valid documents: ${validDocuments.length}`);
console.log(` Missing documents: ${missingDocuments.length}`); console.log(` Missing documents: ${missingDocuments.length}`);
missingDocuments.forEach(doc => { missingDocuments.forEach(doc => {
console.log(`${doc.fileName || 'Unknown'}`); console.log(`${doc.fileName || 'Unknown'}`);
}); });
// Update the database // Update the database
await pgClient.query( await pgClient.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, JSON.stringify(validDocuments),
[JSON.stringify(validDocuments), bookingId] bookingId,
); ]);
updatedCount++; updatedCount++;
removedDocsCount += missingDocuments.length; removedDocsCount += missingDocuments.length;
if (validDocuments.length === 0) { if (validDocuments.length === 0) {
emptyBookingsCount++; emptyBookingsCount++;
console.log(` ⚠️ This booking now has NO documents`); console.log(` ⚠️ This booking now has NO documents`);
} }
} }
} }
console.log(`\n📊 Summary:`); console.log(`\n📊 Summary:`);
console.log(` Bookings updated: ${updatedCount}`); console.log(` Bookings updated: ${updatedCount}`);
console.log(` Documents removed from DB: ${removedDocsCount}`); console.log(` Documents removed from DB: ${removedDocsCount}`);
console.log(` Bookings with no documents: ${emptyBookingsCount}`); console.log(` Bookings with no documents: ${emptyBookingsCount}`);
console.log(`\n✅ Database synchronized with MinIO`); console.log(`\n✅ Database synchronized with MinIO`);
} catch (error) { } catch (error) {
console.error('❌ Error:', error); console.error('❌ Error:', error);
throw error; throw error;
} finally { } finally {
await pgClient.end(); await pgClient.end();
console.log('\n👋 Disconnected from database'); console.log('\n👋 Disconnected from database');
} }
} }
syncDatabase() syncDatabase()
.then(() => { .then(() => {
console.log('\n✅ Script completed successfully'); console.log('\n✅ Script completed successfully');
process.exit(0); process.exit(0);
}) })
.catch((error) => { .catch(error => {
console.error('\n❌ Script failed:', error); console.error('\n❌ Script failed:', error);
process.exit(1); process.exit(1);
}); });

View File

@ -12,7 +12,7 @@ const API_BASE = 'http://localhost:4000/api/v1';
// Test credentials - you need to use real credentials from your database // Test credentials - you need to use real credentials from your database
const TEST_USER = { const TEST_USER = {
email: 'admin@xpeditis.com', // Change this to a real user email email: 'admin@xpeditis.com', // Change this to a real user email
password: 'Admin123!', // Change this to the real password password: 'Admin123!', // Change this to the real password
}; };
async function testWorkflow() { async function testWorkflow() {
@ -56,16 +56,12 @@ async function testWorkflow() {
contentType: 'application/pdf', contentType: 'application/pdf',
}); });
const bookingResponse = await axios.post( const bookingResponse = await axios.post(`${API_BASE}/csv-bookings`, form, {
`${API_BASE}/csv-bookings`, headers: {
form, ...form.getHeaders(),
{ Authorization: `Bearer ${token}`,
headers: { },
...form.getHeaders(), });
Authorization: `Bearer ${token}`,
},
}
);
console.log('✅ Booking created successfully!'); console.log('✅ Booking created successfully!');
console.log('📦 Booking ID:', bookingResponse.data.id); console.log('📦 Booking ID:', bookingResponse.data.id);
@ -80,7 +76,9 @@ async function testWorkflow() {
console.error('❌ Error:', error.response?.data || error.message); console.error('❌ Error:', error.response?.data || error.message);
if (error.response?.status === 401) { if (error.response?.status === 401) {
console.error('\n⚠ Authentication failed. Please update TEST_USER credentials in the script.'); console.error(
'\n⚠ Authentication failed. Please update TEST_USER credentials in the script.'
);
} }
if (error.response?.status === 400) { if (error.response?.status === 400) {

View File

@ -1,228 +1,230 @@
/** /**
* Script de test pour vérifier l'envoi d'email aux transporteurs * Script de test pour vérifier l'envoi d'email aux transporteurs
* *
* Usage: node test-carrier-email-fix.js * Usage: node test-carrier-email-fix.js
*/ */
const nodemailer = require('nodemailer'); const nodemailer = require('nodemailer');
async function testEmailConfig() { async function testEmailConfig() {
console.log('🔍 Test de configuration email Mailtrap...\n'); console.log('🔍 Test de configuration email Mailtrap...\n');
const config = { const config = {
host: process.env.SMTP_HOST || 'sandbox.smtp.mailtrap.io', host: process.env.SMTP_HOST || 'sandbox.smtp.mailtrap.io',
port: parseInt(process.env.SMTP_PORT || '2525'), port: parseInt(process.env.SMTP_PORT || '2525'),
user: process.env.SMTP_USER || '2597bd31d265eb', user: process.env.SMTP_USER || '2597bd31d265eb',
pass: process.env.SMTP_PASS || 'cd126234193c89', pass: process.env.SMTP_PASS || 'cd126234193c89',
}; };
console.log('📧 Configuration SMTP:'); console.log('📧 Configuration SMTP:');
console.log(` Host: ${config.host}`); console.log(` Host: ${config.host}`);
console.log(` Port: ${config.port}`); console.log(` Port: ${config.port}`);
console.log(` User: ${config.user}`); console.log(` User: ${config.user}`);
console.log(` Pass: ${config.pass.substring(0, 4)}***\n`); console.log(` Pass: ${config.pass.substring(0, 4)}***\n`);
// Test 1: Configuration standard (peut échouer avec timeout DNS) // Test 1: Configuration standard (peut échouer avec timeout DNS)
console.log('Test 1: Configuration standard...'); console.log('Test 1: Configuration standard...');
try { try {
const transporter1 = nodemailer.createTransport({ const transporter1 = nodemailer.createTransport({
host: config.host, host: config.host,
port: config.port, port: config.port,
secure: false, secure: false,
auth: { auth: {
user: config.user, user: config.user,
pass: config.pass, pass: config.pass,
}, },
connectionTimeout: 10000, connectionTimeout: 10000,
greetingTimeout: 10000, greetingTimeout: 10000,
socketTimeout: 30000, socketTimeout: 30000,
}); });
await transporter1.sendMail({ await transporter1.sendMail({
from: 'noreply@xpeditis.com', from: 'noreply@xpeditis.com',
to: 'test@xpeditis.com', to: 'test@xpeditis.com',
subject: 'Test Email - Configuration Standard', subject: 'Test Email - Configuration Standard',
html: '<h1>Test réussi!</h1><p>Configuration standard fonctionne.</p>', html: '<h1>Test réussi!</h1><p>Configuration standard fonctionne.</p>',
}); });
console.log('✅ Test 1 RÉUSSI - Configuration standard OK\n'); console.log('✅ Test 1 RÉUSSI - Configuration standard OK\n');
} catch (error) { } catch (error) {
console.error('❌ Test 1 ÉCHOUÉ:', error.message); console.error('❌ Test 1 ÉCHOUÉ:', error.message);
console.error(' Code:', error.code); console.error(' Code:', error.code);
console.error(' Timeout?', error.message.includes('ETIMEOUT')); console.error(' Timeout?', error.message.includes('ETIMEOUT'));
console.log(''); console.log('');
} }
// Test 2: Configuration avec IP directe (devrait toujours fonctionner) // Test 2: Configuration avec IP directe (devrait toujours fonctionner)
console.log('Test 2: Configuration avec IP directe...'); console.log('Test 2: Configuration avec IP directe...');
try { try {
const useDirectIP = config.host.includes('mailtrap.io'); const useDirectIP = config.host.includes('mailtrap.io');
const actualHost = useDirectIP ? '3.209.246.195' : config.host; const actualHost = useDirectIP ? '3.209.246.195' : config.host;
const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host; const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host;
console.log(` Utilisation IP directe: ${useDirectIP}`); console.log(` Utilisation IP directe: ${useDirectIP}`);
console.log(` Host réel: ${actualHost}`); console.log(` Host réel: ${actualHost}`);
console.log(` Server name (TLS): ${serverName}`); console.log(` Server name (TLS): ${serverName}`);
const transporter2 = nodemailer.createTransport({ const transporter2 = nodemailer.createTransport({
host: actualHost, host: actualHost,
port: config.port, port: config.port,
secure: false, secure: false,
auth: { auth: {
user: config.user, user: config.user,
pass: config.pass, pass: config.pass,
}, },
tls: { tls: {
rejectUnauthorized: false, rejectUnauthorized: false,
servername: serverName, servername: serverName,
}, },
connectionTimeout: 10000, connectionTimeout: 10000,
greetingTimeout: 10000, greetingTimeout: 10000,
socketTimeout: 30000, socketTimeout: 30000,
dnsTimeout: 10000, dnsTimeout: 10000,
}); });
const result = await transporter2.sendMail({ const result = await transporter2.sendMail({
from: 'noreply@xpeditis.com', from: 'noreply@xpeditis.com',
to: 'test@xpeditis.com', to: 'test@xpeditis.com',
subject: 'Test Email - Configuration IP Directe', subject: 'Test Email - Configuration IP Directe',
html: '<h1>Test réussi!</h1><p>Configuration avec IP directe fonctionne.</p>', html: '<h1>Test réussi!</h1><p>Configuration avec IP directe fonctionne.</p>',
}); });
console.log('✅ Test 2 RÉUSSI - Configuration IP directe OK'); console.log('✅ Test 2 RÉUSSI - Configuration IP directe OK');
console.log(` Message ID: ${result.messageId}`); console.log(` Message ID: ${result.messageId}`);
console.log(` Response: ${result.response}\n`); console.log(` Response: ${result.response}\n`);
} catch (error) { } catch (error) {
console.error('❌ Test 2 ÉCHOUÉ:', error.message); console.error('❌ Test 2 ÉCHOUÉ:', error.message);
console.error(' Code:', error.code); console.error(' Code:', error.code);
console.log(''); console.log('');
} }
// Test 3: Template HTML de booking transporteur // Test 3: Template HTML de booking transporteur
console.log('Test 3: Envoi avec template HTML complet...'); console.log('Test 3: Envoi avec template HTML complet...');
try { try {
const useDirectIP = config.host.includes('mailtrap.io'); const useDirectIP = config.host.includes('mailtrap.io');
const actualHost = useDirectIP ? '3.209.246.195' : config.host; const actualHost = useDirectIP ? '3.209.246.195' : config.host;
const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host; const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host;
const transporter3 = nodemailer.createTransport({ const transporter3 = nodemailer.createTransport({
host: actualHost, host: actualHost,
port: config.port, port: config.port,
secure: false, secure: false,
auth: { auth: {
user: config.user, user: config.user,
pass: config.pass, pass: config.pass,
}, },
tls: { tls: {
rejectUnauthorized: false, rejectUnauthorized: false,
servername: serverName, servername: serverName,
}, },
connectionTimeout: 10000, connectionTimeout: 10000,
greetingTimeout: 10000, greetingTimeout: 10000,
socketTimeout: 30000, socketTimeout: 30000,
dnsTimeout: 10000, dnsTimeout: 10000,
}); });
const bookingData = { const bookingData = {
bookingId: 'TEST-' + Date.now(), bookingId: 'TEST-' + Date.now(),
origin: 'FRPAR', origin: 'FRPAR',
destination: 'USNYC', destination: 'USNYC',
volumeCBM: 10.5, volumeCBM: 10.5,
weightKG: 850, weightKG: 850,
palletCount: 4, palletCount: 4,
priceUSD: 1500, priceUSD: 1500,
priceEUR: 1350, priceEUR: 1350,
primaryCurrency: 'USD', primaryCurrency: 'USD',
transitDays: 15, transitDays: 15,
containerType: '20FT', containerType: '20FT',
documents: [ documents: [
{ type: 'Bill of Lading', fileName: 'bol.pdf' }, { type: 'Bill of Lading', fileName: 'bol.pdf' },
{ type: 'Packing List', fileName: 'packing_list.pdf' }, { type: 'Packing List', fileName: 'packing_list.pdf' },
], ],
acceptUrl: 'http://localhost:3000/carrier/booking/accept', acceptUrl: 'http://localhost:3000/carrier/booking/accept',
rejectUrl: 'http://localhost:3000/carrier/booking/reject', rejectUrl: 'http://localhost:3000/carrier/booking/reject',
}; };
const htmlTemplate = ` const htmlTemplate = `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head><meta charset="UTF-8"></head> <head><meta charset="UTF-8"></head>
<body style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;"> <body style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;">
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden;"> <div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden;">
<div style="background: linear-gradient(135deg, #045a8d, #00bcd4); color: white; padding: 30px; text-align: center;"> <div style="background: linear-gradient(135deg, #045a8d, #00bcd4); color: white; padding: 30px; text-align: center;">
<h1 style="margin: 0;">🚢 Nouvelle demande de réservation</h1> <h1 style="margin: 0;">🚢 Nouvelle demande de réservation</h1>
<p style="margin: 5px 0 0;">Xpeditis</p> <p style="margin: 5px 0 0;">Xpeditis</p>
</div> </div>
<div style="padding: 30px;"> <div style="padding: 30px;">
<p style="font-size: 16px;">Bonjour,</p> <p style="font-size: 16px;">Bonjour,</p>
<p>Vous avez reçu une nouvelle demande de réservation via Xpeditis.</p> <p>Vous avez reçu une nouvelle demande de réservation via Xpeditis.</p>
<h2 style="color: #045a8d; border-bottom: 2px solid #00bcd4; padding-bottom: 8px;">📋 Détails du transport</h2> <h2 style="color: #045a8d; border-bottom: 2px solid #00bcd4; padding-bottom: 8px;">📋 Détails du transport</h2>
<table style="width: 100%; border-collapse: collapse;"> <table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #e0e0e0;"> <tr style="border-bottom: 1px solid #e0e0e0;">
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Route</td> <td style="padding: 12px; font-weight: bold; color: #045a8d;">Route</td>
<td style="padding: 12px;">${bookingData.origin} ${bookingData.destination}</td> <td style="padding: 12px;">${bookingData.origin} ${bookingData.destination}</td>
</tr> </tr>
<tr style="border-bottom: 1px solid #e0e0e0;"> <tr style="border-bottom: 1px solid #e0e0e0;">
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Volume</td> <td style="padding: 12px; font-weight: bold; color: #045a8d;">Volume</td>
<td style="padding: 12px;">${bookingData.volumeCBM} CBM</td> <td style="padding: 12px;">${bookingData.volumeCBM} CBM</td>
</tr> </tr>
<tr style="border-bottom: 1px solid #e0e0e0;"> <tr style="border-bottom: 1px solid #e0e0e0;">
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Prix</td> <td style="padding: 12px; font-weight: bold; color: #045a8d;">Prix</td>
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #00aa00;"> <td style="padding: 12px; font-size: 24px; font-weight: bold; color: #00aa00;">
${bookingData.priceUSD} USD ${bookingData.priceUSD} USD
</td> </td>
</tr> </tr>
</table> </table>
<div style="text-align: center; margin: 30px 0;"> <div style="text-align: center; margin: 30px 0;">
<p style="font-weight: bold;">Veuillez confirmer votre décision :</p> <p style="font-weight: bold;">Veuillez confirmer votre décision :</p>
<a href="${bookingData.acceptUrl}" style="display: inline-block; padding: 15px 30px; background: #00aa00; color: white; text-decoration: none; border-radius: 6px; margin: 0 5px;"> Accepter</a> <a href="${bookingData.acceptUrl}" style="display: inline-block; padding: 15px 30px; background: #00aa00; color: white; text-decoration: none; border-radius: 6px; margin: 0 5px;"> Accepter</a>
<a href="${bookingData.rejectUrl}" style="display: inline-block; padding: 15px 30px; background: #cc0000; color: white; text-decoration: none; border-radius: 6px; margin: 0 5px;"> Refuser</a> <a href="${bookingData.rejectUrl}" style="display: inline-block; padding: 15px 30px; background: #cc0000; color: white; text-decoration: none; border-radius: 6px; margin: 0 5px;"> Refuser</a>
</div> </div>
<div style="background: #fff8e1; border-left: 4px solid #f57c00; padding: 15px; margin: 20px 0;"> <div style="background: #fff8e1; border-left: 4px solid #f57c00; padding: 15px; margin: 20px 0;">
<p style="margin: 0; font-size: 14px; color: #666;"> <p style="margin: 0; font-size: 14px; color: #666;">
<strong style="color: #f57c00;"> Important</strong><br> <strong style="color: #f57c00;"> Important</strong><br>
Cette demande expire automatiquement dans 7 jours si aucune action n'est prise. Cette demande expire automatiquement dans 7 jours si aucune action n'est prise.
</p> </p>
</div> </div>
</div> </div>
<div style="background: #f4f6f8; padding: 20px; text-align: center; font-size: 12px; color: #666;"> <div style="background: #f4f6f8; padding: 20px; text-align: center; font-size: 12px; color: #666;">
<p style="margin: 5px 0; font-weight: bold; color: #045a8d;">Référence : ${bookingData.bookingId}</p> <p style="margin: 5px 0; font-weight: bold; color: #045a8d;">Référence : ${bookingData.bookingId}</p>
<p style="margin: 5px 0;">© 2025 Xpeditis. Tous droits réservés.</p> <p style="margin: 5px 0;">© 2025 Xpeditis. Tous droits réservés.</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>
`; `;
const result = await transporter3.sendMail({ const result = await transporter3.sendMail({
from: 'noreply@xpeditis.com', from: 'noreply@xpeditis.com',
to: 'carrier@test.com', to: 'carrier@test.com',
subject: `Nouvelle demande de réservation - ${bookingData.origin}${bookingData.destination}`, subject: `Nouvelle demande de réservation - ${bookingData.origin}${bookingData.destination}`,
html: htmlTemplate, html: htmlTemplate,
}); });
console.log('✅ Test 3 RÉUSSI - Email complet avec template envoyé'); console.log('✅ Test 3 RÉUSSI - Email complet avec template envoyé');
console.log(` Message ID: ${result.messageId}`); console.log(` Message ID: ${result.messageId}`);
console.log(` Response: ${result.response}\n`); console.log(` Response: ${result.response}\n`);
} catch (error) { } catch (error) {
console.error('❌ Test 3 ÉCHOUÉ:', error.message); console.error('❌ Test 3 ÉCHOUÉ:', error.message);
console.error(' Code:', error.code); console.error(' Code:', error.code);
console.log(''); console.log('');
} }
console.log('📊 Résumé des tests:'); console.log('📊 Résumé des tests:');
console.log(' ✓ Vérifiez Mailtrap inbox: https://mailtrap.io/inboxes'); console.log(' ✓ Vérifiez Mailtrap inbox: https://mailtrap.io/inboxes');
console.log(' ✓ Recherchez les emails de test ci-dessus'); console.log(' ✓ Recherchez les emails de test ci-dessus');
console.log(' ✓ Si Test 2 et 3 réussissent, le backend doit être corrigé avec la configuration IP directe\n'); console.log(
} ' ✓ Si Test 2 et 3 réussissent, le backend doit être corrigé avec la configuration IP directe\n'
);
// Run test }
testEmailConfig()
.then(() => { // Run test
console.log('✅ Tests terminés avec succès'); testEmailConfig()
process.exit(0); .then(() => {
}) console.log('✅ Tests terminés avec succès');
.catch((error) => { process.exit(0);
console.error('❌ Erreur lors des tests:', error); })
process.exit(1); .catch(error => {
}); console.error('❌ Erreur lors des tests:', error);
process.exit(1);
});

View File

@ -5,25 +5,28 @@ const transporter = nodemailer.createTransport({
port: 2525, port: 2525,
auth: { auth: {
user: '2597bd31d265eb', user: '2597bd31d265eb',
pass: 'cd126234193c89' pass: 'cd126234193c89',
} },
}); });
console.log('🔄 Tentative d\'envoi d\'email...'); console.log("🔄 Tentative d'envoi d'email...");
transporter.sendMail({ transporter
from: 'noreply@xpeditis.com', .sendMail({
to: 'test@example.com', from: 'noreply@xpeditis.com',
subject: 'Test Email depuis Portail Transporteur', to: 'test@example.com',
text: 'Email de test pour vérifier la configuration' subject: 'Test Email depuis Portail Transporteur',
}).then(info => { text: 'Email de test pour vérifier la configuration',
console.log('✅ Email envoyé:', info.messageId); })
console.log('📧 Response:', info.response); .then(info => {
process.exit(0); console.log('✅ Email envoyé:', info.messageId);
}).catch(err => { console.log('📧 Response:', info.response);
console.error('❌ Erreur:', err.message); process.exit(0);
console.error('Code:', err.code); })
console.error('Command:', err.command); .catch(err => {
console.error('Stack:', err.stack); console.error('❌ Erreur:', err.message);
process.exit(1); console.error('Code:', err.code);
}); console.error('Command:', err.command);
console.error('Stack:', err.stack);
process.exit(1);
});

View File

@ -31,7 +31,8 @@ const transporter = nodemailer.createTransport(config);
console.log('\n1⃣ Verifying SMTP connection...'); console.log('\n1⃣ Verifying SMTP connection...');
transporter.verify() transporter
.verify()
.then(() => { .then(() => {
console.log('✅ SMTP connection verified!'); console.log('✅ SMTP connection verified!');
console.log('\n2⃣ Sending test email...'); console.log('\n2⃣ Sending test email...');
@ -40,17 +41,17 @@ transporter.verify()
from: 'noreply@xpeditis.com', from: 'noreply@xpeditis.com',
to: 'test@example.com', to: 'test@example.com',
subject: 'Test Xpeditis - Envoi Direct IP', subject: 'Test Xpeditis - Envoi Direct IP',
html: '<h1>✅ Email envoyé avec succès!</h1><p>Ce test utilise l\'IP directe pour contourner le DNS.</p>', html: "<h1>✅ Email envoyé avec succès!</h1><p>Ce test utilise l'IP directe pour contourner le DNS.</p>",
}); });
}) })
.then((info) => { .then(info => {
console.log('✅ Email sent successfully!'); console.log('✅ Email sent successfully!');
console.log('📧 Message ID:', info.messageId); console.log('📧 Message ID:', info.messageId);
console.log('📬 Response:', info.response); console.log('📬 Response:', info.response);
console.log('\n🎉 SUCCESS! Email sending works with IP directly.'); console.log('\n🎉 SUCCESS! Email sending works with IP directly.');
process.exit(0); process.exit(0);
}) })
.catch((error) => { .catch(error => {
console.error('\n❌ ERROR:', error.message); console.error('\n❌ ERROR:', error.message);
console.error('Code:', error.code); console.error('Code:', error.code);
console.error('Command:', error.command); console.error('Command:', error.command);

View File

@ -6,7 +6,8 @@ const axios = require('axios');
const API_URL = 'http://localhost:4000/api/v1'; const API_URL = 'http://localhost:4000/api/v1';
// Token d'authentification (admin@xpeditis.com) // Token d'authentification (admin@xpeditis.com)
const AUTH_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MTI3Y2M0Zi04Yzg4LTRjNGUtYmU1ZC1hNmY1ZTE2MWZlNDMiLCJlbWFpbCI6ImFkbWluQHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiMWZhOWE1NjUtZjNjOC00ZTExLTliMzAtMTIwZDEwNTJjZWYwIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2NDg3NDQ2MSwiZXhwIjoxNzY0ODc1MzYxfQ.l_-97_rikGj-DP8aA14CK-Ab-0Usy722MRe1lqi0u9I'; const AUTH_TOKEN =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MTI3Y2M0Zi04Yzg4LTRjNGUtYmU1ZC1hNmY1ZTE2MWZlNDMiLCJlbWFpbCI6ImFkbWluQHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiMWZhOWE1NjUtZjNjOC00ZTExLTliMzAtMTIwZDEwNTJjZWYwIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2NDg3NDQ2MSwiZXhwIjoxNzY0ODc1MzYxfQ.l_-97_rikGj-DP8aA14CK-Ab-0Usy722MRe1lqi0u9I';
async function testCsvBookingEmail() { async function testCsvBookingEmail() {
console.log('🧪 Test envoi email via CSV booking...\n'); console.log('🧪 Test envoi email via CSV booking...\n');
@ -19,7 +20,10 @@ async function testCsvBookingEmail() {
// Créer un fichier de test temporaire // Créer un fichier de test temporaire
const testFile = Buffer.from('Test document content'); const testFile = Buffer.from('Test document content');
form.append('documents', testFile, { filename: 'test-document.pdf', contentType: 'application/pdf' }); form.append('documents', testFile, {
filename: 'test-document.pdf',
contentType: 'application/pdf',
});
// Ajouter les champs du formulaire // Ajouter les champs du formulaire
form.append('carrierName', 'Test Carrier Email'); form.append('carrierName', 'Test Carrier Email');
@ -41,8 +45,8 @@ async function testCsvBookingEmail() {
const response = await axios.post(`${API_URL}/csv-bookings`, form, { const response = await axios.post(`${API_URL}/csv-bookings`, form, {
headers: { headers: {
...form.getHeaders(), ...form.getHeaders(),
'Authorization': `Bearer ${AUTH_TOKEN}` Authorization: `Bearer ${AUTH_TOKEN}`,
} },
}); });
console.log('✅ Réponse reçue:', response.status); console.log('✅ Réponse reçue:', response.status);
@ -51,11 +55,10 @@ async function testCsvBookingEmail() {
console.log('1. Les logs du backend pour voir "Email sent to carrier:"'); console.log('1. Les logs du backend pour voir "Email sent to carrier:"');
console.log('2. Votre inbox Mailtrap: https://mailtrap.io/inboxes'); console.log('2. Votre inbox Mailtrap: https://mailtrap.io/inboxes');
console.log('3. Email destinataire: test-carrier@example.com'); console.log('3. Email destinataire: test-carrier@example.com');
} catch (error) { } catch (error) {
console.error('❌ Erreur:', error.response?.data || error.message); console.error('❌ Erreur:', error.response?.data || error.message);
if (error.response?.status === 401) { if (error.response?.status === 401) {
console.error('\n⚠ Token expiré. Connectez-vous d\'abord avec:'); console.error("\n⚠ Token expiré. Connectez-vous d'abord avec:");
console.error('POST /api/v1/auth/login'); console.error('POST /api/v1/auth/login');
console.error('{ "email": "admin@xpeditis.com", "password": "..." }'); console.error('{ "email": "admin@xpeditis.com", "password": "..." }');
} }

View File

@ -31,7 +31,8 @@ const transporter = nodemailer.createTransport(config);
console.log('\nVerifying SMTP connection...'); console.log('\nVerifying SMTP connection...');
transporter.verify() transporter
.verify()
.then(() => { .then(() => {
console.log('✅ SMTP connection verified successfully!'); console.log('✅ SMTP connection verified successfully!');
console.log('\nSending test email...'); console.log('\nSending test email...');
@ -43,13 +44,13 @@ transporter.verify()
html: '<h1>Test Email</h1><p>If you see this, email sending works!</p>', html: '<h1>Test Email</h1><p>If you see this, email sending works!</p>',
}); });
}) })
.then((info) => { .then(info => {
console.log('✅ Email sent successfully!'); console.log('✅ Email sent successfully!');
console.log('Message ID:', info.messageId); console.log('Message ID:', info.messageId);
console.log('Response:', info.response); console.log('Response:', info.response);
process.exit(0); process.exit(0);
}) })
.catch((error) => { .catch(error => {
console.error('❌ Error:', error.message); console.error('❌ Error:', error.message);
console.error('Full error:', error); console.error('Full error:', error);
process.exit(1); process.exit(1);

View File

@ -1,74 +1,74 @@
#!/usr/bin/env node #!/usr/bin/env node
// Test SMTP ultra-simple pour identifier le problème // Test SMTP ultra-simple pour identifier le problème
const nodemailer = require('nodemailer'); const nodemailer = require('nodemailer');
require('dotenv').config(); require('dotenv').config();
console.log('🔍 Test SMTP Simple\n'); console.log('🔍 Test SMTP Simple\n');
console.log('Configuration:'); console.log('Configuration:');
console.log(' SMTP_HOST:', process.env.SMTP_HOST || 'NON DÉFINI'); console.log(' SMTP_HOST:', process.env.SMTP_HOST || 'NON DÉFINI');
console.log(' SMTP_PORT:', process.env.SMTP_PORT || 'NON DÉFINI'); console.log(' SMTP_PORT:', process.env.SMTP_PORT || 'NON DÉFINI');
console.log(' SMTP_USER:', process.env.SMTP_USER || 'NON DÉFINI'); console.log(' SMTP_USER:', process.env.SMTP_USER || 'NON DÉFINI');
console.log(' SMTP_PASS:', process.env.SMTP_PASS ? '***' : 'NON DÉFINI'); console.log(' SMTP_PASS:', process.env.SMTP_PASS ? '***' : 'NON DÉFINI');
console.log(''); console.log('');
const host = process.env.SMTP_HOST; const host = process.env.SMTP_HOST;
const port = parseInt(process.env.SMTP_PORT || '2525'); const port = parseInt(process.env.SMTP_PORT || '2525');
const user = process.env.SMTP_USER; const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS; const pass = process.env.SMTP_PASS;
// Appliquer le même fix DNS que dans email.adapter.ts // Appliquer le même fix DNS que dans email.adapter.ts
const useDirectIP = host && host.includes('mailtrap.io'); const useDirectIP = host && host.includes('mailtrap.io');
const actualHost = useDirectIP ? '3.209.246.195' : host; const actualHost = useDirectIP ? '3.209.246.195' : host;
const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; const serverName = useDirectIP ? 'smtp.mailtrap.io' : host;
console.log('Fix DNS:'); console.log('Fix DNS:');
console.log(' Utilise IP directe:', useDirectIP); console.log(' Utilise IP directe:', useDirectIP);
console.log(' Host réel:', actualHost); console.log(' Host réel:', actualHost);
console.log(' Server name:', serverName); console.log(' Server name:', serverName);
console.log(''); console.log('');
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: actualHost, host: actualHost,
port, port,
secure: false, secure: false,
auth: { user, pass }, auth: { user, pass },
tls: { tls: {
rejectUnauthorized: false, rejectUnauthorized: false,
servername: serverName, servername: serverName,
}, },
connectionTimeout: 10000, connectionTimeout: 10000,
greetingTimeout: 10000, greetingTimeout: 10000,
socketTimeout: 30000, socketTimeout: 30000,
dnsTimeout: 10000, dnsTimeout: 10000,
}); });
async function test() { async function test() {
try { try {
console.log('Test 1: Vérification de la connexion...'); console.log('Test 1: Vérification de la connexion...');
await transporter.verify(); await transporter.verify();
console.log('✅ Connexion SMTP OK\n'); console.log('✅ Connexion SMTP OK\n');
console.log('Test 2: Envoi d\'un email...'); console.log("Test 2: Envoi d'un email...");
const info = await transporter.sendMail({ const info = await transporter.sendMail({
from: 'noreply@xpeditis.com', from: 'noreply@xpeditis.com',
to: 'test@example.com', to: 'test@example.com',
subject: 'Test - ' + new Date().toISOString(), subject: 'Test - ' + new Date().toISOString(),
html: '<h1>Test réussi!</h1><p>Ce message confirme que l\'envoi d\'email fonctionne.</p>', html: "<h1>Test réussi!</h1><p>Ce message confirme que l'envoi d'email fonctionne.</p>",
}); });
console.log('✅ Email envoyé avec succès!'); console.log('✅ Email envoyé avec succès!');
console.log(' Message ID:', info.messageId); console.log(' Message ID:', info.messageId);
console.log(' Response:', info.response); console.log(' Response:', info.response);
console.log(''); console.log('');
console.log('✅ TOUS LES TESTS RÉUSSIS - Le SMTP fonctionne!'); console.log('✅ TOUS LES TESTS RÉUSSIS - Le SMTP fonctionne!');
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
console.error('❌ ERREUR:', error.message); console.error('❌ ERREUR:', error.message);
console.error(' Code:', error.code); console.error(' Code:', error.code);
console.error(' Command:', error.command); console.error(' Command:', error.command);
process.exit(1); process.exit(1);
} }
} }
test(); test();

View File

@ -1,4 +1,4 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"] "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
} }

View File

@ -1,185 +1,185 @@
/** /**
* Script to upload test documents to MinIO * Script to upload test documents to MinIO
*/ */
const { S3Client, PutObjectCommand, CreateBucketCommand } = require('@aws-sdk/client-s3'); const { S3Client, PutObjectCommand, CreateBucketCommand } = require('@aws-sdk/client-s3');
const { Client: PgClient } = require('pg'); const { Client: PgClient } = require('pg');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
require('dotenv').config(); require('dotenv').config();
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
const BUCKET_NAME = 'xpeditis-documents'; const BUCKET_NAME = 'xpeditis-documents';
// Initialize MinIO client // Initialize MinIO client
const s3Client = new S3Client({ const s3Client = new S3Client({
region: 'us-east-1', region: 'us-east-1',
endpoint: MINIO_ENDPOINT, endpoint: MINIO_ENDPOINT,
credentials: { credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
}, },
forcePathStyle: true, forcePathStyle: true,
}); });
// Create a simple PDF buffer (minimal valid PDF) // Create a simple PDF buffer (minimal valid PDF)
function createTestPDF(title) { function createTestPDF(title) {
return Buffer.from( return Buffer.from(
`%PDF-1.4 `%PDF-1.4
1 0 obj 1 0 obj
<< <<
/Type /Catalog /Type /Catalog
/Pages 2 0 R /Pages 2 0 R
>> >>
endobj endobj
2 0 obj 2 0 obj
<< <<
/Type /Pages /Type /Pages
/Kids [3 0 R] /Kids [3 0 R]
/Count 1 /Count 1
>> >>
endobj endobj
3 0 obj 3 0 obj
<< <<
/Type /Page /Type /Page
/Parent 2 0 R /Parent 2 0 R
/MediaBox [0 0 612 792] /MediaBox [0 0 612 792]
/Contents 4 0 R /Contents 4 0 R
/Resources << /Resources <<
/Font << /Font <<
/F1 << /F1 <<
/Type /Font /Type /Font
/Subtype /Type1 /Subtype /Type1
/BaseFont /Helvetica /BaseFont /Helvetica
>> >>
>> >>
>> >>
>> >>
endobj endobj
4 0 obj 4 0 obj
<< <<
/Length 100 /Length 100
>> >>
stream stream
BT BT
/F1 24 Tf /F1 24 Tf
100 700 Td 100 700 Td
(${title}) Tj (${title}) Tj
ET ET
endstream endstream
endobj endobj
xref xref
0 5 0 5
0000000000 65535 f 0000000000 65535 f
0000000009 00000 n 0000000009 00000 n
0000000058 00000 n 0000000058 00000 n
0000000115 00000 n 0000000115 00000 n
0000000300 00000 n 0000000300 00000 n
trailer trailer
<< <<
/Size 5 /Size 5
/Root 1 0 R /Root 1 0 R
>> >>
startxref startxref
450 450
%%EOF`, %%EOF`,
'utf-8' 'utf-8'
); );
} }
async function uploadTestDocuments() { async function uploadTestDocuments() {
const pgClient = new PgClient({ const pgClient = new PgClient({
host: process.env.DATABASE_HOST || 'localhost', host: process.env.DATABASE_HOST || 'localhost',
port: process.env.DATABASE_PORT || 5432, port: process.env.DATABASE_PORT || 5432,
user: process.env.DATABASE_USER || 'xpeditis', user: process.env.DATABASE_USER || 'xpeditis',
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
database: process.env.DATABASE_NAME || 'xpeditis_dev', database: process.env.DATABASE_NAME || 'xpeditis_dev',
}); });
try { try {
// Connect to database // Connect to database
await pgClient.connect(); await pgClient.connect();
console.log('✅ Connected to database'); console.log('✅ Connected to database');
// Create bucket if it doesn't exist // Create bucket if it doesn't exist
try { try {
await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME })); await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME }));
console.log(`✅ Created bucket: ${BUCKET_NAME}`); console.log(`✅ Created bucket: ${BUCKET_NAME}`);
} catch (error) { } catch (error) {
if (error.name === 'BucketAlreadyOwnedByYou' || error.Code === 'BucketAlreadyOwnedByYou') { if (error.name === 'BucketAlreadyOwnedByYou' || error.Code === 'BucketAlreadyOwnedByYou') {
console.log(`✅ Bucket already exists: ${BUCKET_NAME}`); console.log(`✅ Bucket already exists: ${BUCKET_NAME}`);
} else { } else {
console.log(`⚠️ Could not create bucket (might already exist): ${error.message}`); console.log(`⚠️ Could not create bucket (might already exist): ${error.message}`);
} }
} }
// Get all CSV bookings with documents // Get all CSV bookings with documents
const result = await pgClient.query( const result = await pgClient.query(
`SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL` `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL`
); );
console.log(`\n📄 Found ${result.rows.length} bookings with documents\n`); console.log(`\n📄 Found ${result.rows.length} bookings with documents\n`);
let uploadedCount = 0; let uploadedCount = 0;
for (const row of result.rows) { for (const row of result.rows) {
const bookingId = row.id; const bookingId = row.id;
const documents = row.documents; const documents = row.documents;
console.log(`\n📦 Processing booking: ${bookingId}`); console.log(`\n📦 Processing booking: ${bookingId}`);
for (const doc of documents) { for (const doc of documents) {
if (!doc.filePath || !doc.filePath.includes(MINIO_ENDPOINT)) { if (!doc.filePath || !doc.filePath.includes(MINIO_ENDPOINT)) {
console.log(` ⏭️ Skipping document (not a MinIO URL): ${doc.fileName}`); console.log(` ⏭️ Skipping document (not a MinIO URL): ${doc.fileName}`);
continue; continue;
} }
// Extract the S3 key from the URL // Extract the S3 key from the URL
const url = new URL(doc.filePath); const url = new URL(doc.filePath);
const key = url.pathname.substring(1).replace(`${BUCKET_NAME}/`, ''); const key = url.pathname.substring(1).replace(`${BUCKET_NAME}/`, '');
// Create test PDF content // Create test PDF content
const pdfContent = createTestPDF(doc.fileName || 'Test Document'); const pdfContent = createTestPDF(doc.fileName || 'Test Document');
try { try {
// Upload to MinIO // Upload to MinIO
await s3Client.send( await s3Client.send(
new PutObjectCommand({ new PutObjectCommand({
Bucket: BUCKET_NAME, Bucket: BUCKET_NAME,
Key: key, Key: key,
Body: pdfContent, Body: pdfContent,
ContentType: doc.mimeType || 'application/pdf', ContentType: doc.mimeType || 'application/pdf',
}) })
); );
console.log(` ✅ Uploaded: ${doc.fileName}`); console.log(` ✅ Uploaded: ${doc.fileName}`);
console.log(` Path: ${key}`); console.log(` Path: ${key}`);
uploadedCount++; uploadedCount++;
} catch (error) { } catch (error) {
console.error(` ❌ Failed to upload ${doc.fileName}:`, error.message); console.error(` ❌ Failed to upload ${doc.fileName}:`, error.message);
} }
} }
} }
console.log(`\n🎉 Successfully uploaded ${uploadedCount} test documents to MinIO`); console.log(`\n🎉 Successfully uploaded ${uploadedCount} test documents to MinIO`);
console.log(`\n📍 MinIO Console: http://localhost:9001`); console.log(`\n📍 MinIO Console: http://localhost:9001`);
console.log(` Username: minioadmin`); console.log(` Username: minioadmin`);
console.log(` Password: minioadmin`); console.log(` Password: minioadmin`);
} catch (error) { } catch (error) {
console.error('❌ Error:', error); console.error('❌ Error:', error);
throw error; throw error;
} finally { } finally {
await pgClient.end(); await pgClient.end();
console.log('\n👋 Disconnected from database'); console.log('\n👋 Disconnected from database');
} }
} }
uploadTestDocuments() uploadTestDocuments()
.then(() => { .then(() => {
console.log('\n✅ Script completed successfully'); console.log('\n✅ Script completed successfully');
process.exit(0); process.exit(0);
}) })
.catch((error) => { .catch(error => {
console.error('\n❌ Script failed:', error); console.error('\n❌ Script failed:', error);
process.exit(1); process.exit(1);
}); });

View File

@ -90,7 +90,10 @@ export default function AboutPage() {
<LandingHeader activePage="about" /> <LandingHeader activePage="about" />
{/* Hero Section */} {/* Hero Section */}
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"> <section
ref={heroRef}
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
>
<div className="absolute inset-0 opacity-10"> <div className="absolute inset-0 opacity-10">
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" /> <div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" /> <div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
@ -155,9 +158,7 @@ export default function AboutPage() {
<Target className="w-8 h-8 text-white" /> <Target className="w-8 h-8 text-white" />
</div> </div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('mission.title')}</h2> <h2 className="text-3xl font-bold text-brand-navy mb-4">{t('mission.title')}</h2>
<p className="text-gray-600 text-lg leading-relaxed"> <p className="text-gray-600 text-lg leading-relaxed">{t('mission.body')}</p>
{t('mission.body')}
</p>
</motion.div> </motion.div>
<motion.div <motion.div
@ -168,9 +169,7 @@ export default function AboutPage() {
<Eye className="w-8 h-8 text-white" /> <Eye className="w-8 h-8 text-white" />
</div> </div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('vision.title')}</h2> <h2 className="text-3xl font-bold text-brand-navy mb-4">{t('vision.title')}</h2>
<p className="text-gray-600 text-lg leading-relaxed"> <p className="text-gray-600 text-lg leading-relaxed">{t('vision.body')}</p>
{t('vision.body')}
</p>
</motion.div> </motion.div>
</motion.div> </motion.div>
</div> </div>
@ -186,11 +185,7 @@ export default function AboutPage() {
> >
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{STATS.map((stat, index) => ( {STATS.map((stat, index) => (
<motion.div <motion.div key={stat.key} variants={itemVariants} className="text-center">
key={stat.key}
variants={itemVariants}
className="text-center"
>
<motion.div <motion.div
initial={{ scale: 0 }} initial={{ scale: 0 }}
animate={isStatsInView ? { scale: 1 } : {}} animate={isStatsInView ? { scale: 1 } : {}}
@ -215,10 +210,10 @@ export default function AboutPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('valuesTitle')}</h2> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> {t('valuesTitle')}
{t('valuesSubtitle')} </h2>
</p> <p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('valuesSubtitle')}</p>
</motion.div> </motion.div>
<motion.div <motion.div
@ -227,7 +222,7 @@ export default function AboutPage() {
animate={isValuesInView ? 'visible' : 'hidden'} animate={isValuesInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
> >
{VALUES.map((value) => { {VALUES.map(value => {
const IconComponent = value.icon; const IconComponent = value.icon;
return ( return (
<motion.div <motion.div
@ -241,7 +236,9 @@ export default function AboutPage() {
> >
<IconComponent className="w-7 h-7 text-white" /> <IconComponent className="w-7 h-7 text-white" />
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`values.${value.key}.title`)}</h3> <h3 className="text-xl font-bold text-brand-navy mb-3">
{t(`values.${value.key}.title`)}
</h3>
<p className="text-gray-600">{t(`values.${value.key}.description`)}</p> <p className="text-gray-600">{t(`values.${value.key}.description`)}</p>
</motion.div> </motion.div>
); );
@ -259,10 +256,10 @@ export default function AboutPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('timelineTitle')}</h2> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> {t('timelineTitle')}
{t('timelineSubtitle')} </h2>
</p> <p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('timelineSubtitle')}</p>
</motion.div> </motion.div>
<div className="relative"> <div className="relative">
@ -286,13 +283,19 @@ export default function AboutPage() {
transition={{ duration: 0.7, ease: 'easeOut' }} transition={{ duration: 0.7, ease: 'easeOut' }}
className={`flex items-center ${index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'}`} className={`flex items-center ${index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'}`}
> >
<div className={`flex-1 ${index % 2 === 0 ? 'lg:pr-12 lg:text-right' : 'lg:pl-12'}`}> <div
className={`flex-1 ${index % 2 === 0 ? 'lg:pr-12 lg:text-right' : 'lg:pl-12'}`}
>
<div className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block hover:shadow-xl transition-shadow"> <div className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block hover:shadow-xl transition-shadow">
<div className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}> <div
className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}
>
<Calendar className="w-5 h-5 text-brand-turquoise" /> <Calendar className="w-5 h-5 text-brand-turquoise" />
<span className="text-2xl font-bold text-brand-turquoise">{year}</span> <span className="text-2xl font-bold text-brand-turquoise">{year}</span>
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`timeline.${year}.title`)}</h3> <h3 className="text-xl font-bold text-brand-navy mb-2">
{t(`timeline.${year}.title`)}
</h3>
<p className="text-gray-600">{t(`timeline.${year}.description`)}</p> <p className="text-gray-600">{t(`timeline.${year}.description`)}</p>
</div> </div>
</div> </div>
@ -302,7 +305,13 @@ export default function AboutPage() {
initial={{ scale: 0 }} initial={{ scale: 0 }}
whileInView={{ scale: 1 }} whileInView={{ scale: 1 }}
viewport={{ once: true, amount: 0.6 }} viewport={{ once: true, amount: 0.6 }}
transition={{ duration: 0.4, delay: 0.15, type: 'spring', stiffness: 320, damping: 18 }} transition={{
duration: 0.4,
delay: 0.15,
type: 'spring',
stiffness: 320,
damping: 18,
}}
className="w-5 h-5 bg-brand-turquoise rounded-full border-4 border-white shadow-lg ring-2 ring-brand-turquoise/30" className="w-5 h-5 bg-brand-turquoise rounded-full border-4 border-white shadow-lg ring-2 ring-brand-turquoise/30"
/> />
</div> </div>
@ -324,10 +333,10 @@ export default function AboutPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('teamTitle')}</h2> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> {t('teamTitle')}
{t('teamSubtitle')} </h2>
</p> <p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('teamSubtitle')}</p>
</motion.div> </motion.div>
<motion.div <motion.div
@ -336,7 +345,7 @@ export default function AboutPage() {
animate={isTeamInView ? 'visible' : 'hidden'} animate={isTeamInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
> >
{TEAM.map((member) => ( {TEAM.map(member => (
<motion.div <motion.div
key={member.key} key={member.key}
variants={itemVariants} variants={itemVariants}
@ -358,7 +367,9 @@ export default function AboutPage() {
</div> </div>
<div className="p-6"> <div className="p-6">
<h3 className="text-xl font-bold text-brand-navy mb-1">{member.name}</h3> <h3 className="text-xl font-bold text-brand-navy mb-1">{member.name}</h3>
<p className="text-brand-turquoise font-medium mb-3">{t(`team.${member.key}.role`)}</p> <p className="text-brand-turquoise font-medium mb-3">
{t(`team.${member.key}.role`)}
</p>
<p className="text-gray-600 text-sm">{t(`team.${member.key}.bio`)}</p> <p className="text-gray-600 text-sm">{t(`team.${member.key}.bio`)}</p>
</div> </div>
</motion.div> </motion.div>
@ -376,12 +387,8 @@ export default function AboutPage() {
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
> >
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6"> <h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">{t('cta.title')}</h2>
{t('cta.title')} <p className="text-xl text-white/80 mb-10">{t('cta.body')}</p>
</h2>
<p className="text-xl text-white/80 mb-10">
{t('cta.body')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6"> <div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
<Link <Link
href="/register" href="/register"

View File

@ -0,0 +1,230 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { Link } from '@/i18n/navigation';
import { motion } from 'framer-motion';
import { ArrowLeft, Calendar, User, Tag, Anchor } from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout';
import { getBlogPost, type BlogPost } from '@/lib/api/blog';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
function resolveUrl(url: string | null | undefined): string | undefined {
if (!url) return undefined;
if (url.startsWith('http')) return url;
return `${API_BASE_URL}${url}`;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
const CATEGORY_LABELS: Record<string, string> = {
industry: 'Industrie',
technology: 'Technologie',
guides: 'Guides',
news: 'Actualités',
};
export default function BlogPostPage() {
const params = useParams();
const slug = params?.slug as string;
const [post, setPost] = useState<BlogPost | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
if (!slug) return;
let cancelled = false;
getBlogPost(slug)
.then(p => {
if (!cancelled) setPost(p);
})
.catch(() => {
if (!cancelled) setNotFound(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [slug]);
if (loading) {
return (
<div className="min-h-screen bg-white">
<LandingHeader />
<div className="max-w-4xl mx-auto px-6 pt-32 pb-20">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
<div className="h-64 bg-gray-200 rounded-xl mt-8" />
</div>
</div>
<LandingFooter />
</div>
);
}
if (notFound || !post) {
return (
<div className="min-h-screen bg-white">
<LandingHeader />
<div className="max-w-4xl mx-auto px-6 pt-32 pb-20 text-center">
<Anchor className="w-24 h-24 text-gray-200 mx-auto mb-6" />
<h1 className="text-3xl font-bold text-brand-navy mb-4">Article introuvable</h1>
<p className="text-gray-600 mb-8">
Cet article n&apos;existe pas ou n&apos;est pas encore publié.
</p>
<Link href="/blog">
<span className="inline-flex items-center space-x-2 px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-semibold">
<ArrowLeft className="w-4 h-4" />
<span>Retour au blog</span>
</span>
</Link>
</div>
<LandingFooter />
</div>
);
}
return (
<div className="min-h-screen bg-white">
<LandingHeader />
{/* Hero */}
<section className="relative pt-32 pb-16 bg-gradient-to-br from-brand-navy to-brand-navy/95">
<div className="absolute inset-0 opacity-10">
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
</div>
<div className="relative z-10 max-w-4xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<Link href="/blog">
<span className="inline-flex items-center space-x-2 text-white/60 hover:text-white transition-colors mb-8 text-sm">
<ArrowLeft className="w-4 h-4" />
<span>Retour au blog</span>
</span>
</Link>
<div className="flex items-center space-x-3 mb-6">
<span className="px-3 py-1 bg-brand-turquoise text-white text-sm font-medium rounded-full">
{CATEGORY_LABELS[post.category] ?? post.category}
</span>
{post.isFeatured && (
<span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full">
À la une
</span>
)}
</div>
<h1 className="text-3xl lg:text-5xl font-bold text-white mb-6 leading-tight">
{post.title}
</h1>
<p className="text-lg text-white/80 mb-8">{post.excerpt}</p>
<div className="flex flex-wrap items-center gap-6 text-white/60 text-sm">
<div className="flex items-center space-x-2">
<User className="w-4 h-4" />
<span>{post.authorName}</span>
</div>
{post.publishedAt && (
<div className="flex items-center space-x-2">
<Calendar className="w-4 h-4" />
<span>{formatDate(post.publishedAt)}</span>
</div>
)}
{post.tags.length > 0 && (
<div className="flex items-center space-x-2">
<Tag className="w-4 h-4" />
<span>{post.tags.join(', ')}</span>
</div>
)}
</div>
</motion.div>
</div>
<div className="absolute bottom-0 left-0 right-0">
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
<path
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
fill="white"
/>
</svg>
</div>
</section>
{/* Cover Image */}
{post.coverImageUrl && (
<div className="max-w-4xl mx-auto px-6 lg:px-8 -mt-8 relative z-10">
<motion.img
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
src={resolveUrl(post.coverImageUrl)}
alt={post.title}
className="w-full h-64 lg:h-96 object-cover rounded-2xl shadow-2xl"
/>
</div>
)}
{/* Content */}
<section className="py-16">
<div className="max-w-4xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
className="prose prose-lg prose-brand max-w-none"
dangerouslySetInnerHTML={{
__html: post.content.replace(
/src="(\/api\/v1\/blog\/images\/[^"]+)"/g,
`src="${API_BASE_URL}$1"`
),
}}
/>
{/* Tags */}
{post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-12 pt-8 border-t border-gray-200">
{post.tags.map(tag => (
<span
key={tag}
className="px-3 py-1 bg-gray-100 text-gray-600 text-sm rounded-full"
>
#{tag}
</span>
))}
</div>
)}
{/* Back link */}
<div className="mt-12">
<Link href="/blog">
<span className="inline-flex items-center space-x-2 px-6 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold">
<ArrowLeft className="w-4 h-4" />
<span>Retour au blog</span>
</span>
</Link>
</div>
</div>
</section>
<LandingFooter />
</div>
);
}

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
@ -19,9 +19,16 @@ import {
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout'; import { LandingHeader, LandingFooter } from '@/components/layout';
import { getBlogPosts, type BlogPost, type BlogPostCategory } from '@/lib/api/blog';
type CategoryKey = 'all' | 'industry' | 'technology' | 'guides' | 'news'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
type ArticleKey = 'incoterms' | 'costs' | 'ports' | 'funding' | 'green' | 'api' | 'documents'; function resolveUrl(url: string | null | undefined): string | undefined {
if (!url) return undefined;
if (url.startsWith('http')) return url;
return `${API_BASE_URL}${url}`;
}
type CategoryKey = 'all' | BlogPostCategory;
const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [ const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [
{ key: 'all', icon: BookOpen }, { key: 'all', icon: BookOpen },
@ -31,20 +38,36 @@ const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [
{ key: 'news', icon: Globe }, { key: 'news', icon: Globe },
]; ];
const ARTICLES: { id: number; key: ArticleKey; category: Exclude<CategoryKey, 'all'>; tags: string[] }[] = [ const containerVariants = {
{ id: 2, key: 'incoterms', category: 'guides', tags: ['Incoterms', 'Guide', 'Commerce'] }, hidden: { opacity: 0, y: 50 },
{ id: 3, key: 'costs', category: 'guides', tags: ['Optimisation', 'Costs', 'Strategy'] }, visible: {
{ id: 4, key: 'ports', category: 'industry', tags: ['Ports', 'Europe', 'Stats'] }, opacity: 1,
{ id: 5, key: 'funding', category: 'news', tags: ['Funding', 'Growth', 'Xpeditis'] }, y: 0,
{ id: 6, key: 'green', category: 'industry', tags: ['Environment', 'Decarbonization', 'Sustainability'] }, transition: { duration: 0.6, staggerChildren: 0.1 },
{ id: 7, key: 'api', category: 'technology', tags: ['API', 'Integration', 'Technical'] }, },
{ id: 8, key: 'documents', category: 'guides', tags: ['Documents', 'Export', 'Customs'] }, };
];
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.5 } },
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
export default function BlogPage() { export default function BlogPage() {
const t = useTranslations('marketing.blog'); const t = useTranslations('marketing.blog');
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all'); const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [posts, setPosts] = useState<BlogPost[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [featuredPost, setFeaturedPost] = useState<BlogPost | null>(null);
const heroRef = useRef(null); const heroRef = useRef(null);
const articlesRef = useRef(null); const articlesRef = useRef(null);
@ -54,44 +77,50 @@ export default function BlogPage() {
const isArticlesInView = useInView(articlesRef, { once: true }); const isArticlesInView = useInView(articlesRef, { once: true });
const isCategoriesInView = useInView(categoriesRef, { once: true }); const isCategoriesInView = useInView(categoriesRef, { once: true });
const filteredArticles = ARTICLES.filter((article) => { useEffect(() => {
const categoryMatch = selectedCategory === 'all' || article.category === selectedCategory; let cancelled = false;
const title = t(`articles.${article.key}.title` as any);
const excerpt = t(`articles.${article.key}.excerpt` as any);
const searchMatch =
searchQuery === '' ||
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
excerpt.toLowerCase().includes(searchQuery.toLowerCase());
return categoryMatch && searchMatch;
});
const containerVariants = { const load = async () => {
hidden: { opacity: 0, y: 50 }, setLoading(true);
visible: { try {
opacity: 1, const res = await getBlogPosts({
y: 0, category: selectedCategory !== 'all' ? selectedCategory : undefined,
transition: { search: searchQuery || undefined,
duration: 0.6, limit: 50,
staggerChildren: 0.1, });
}, if (!cancelled) {
}, const featured = res.posts.find(p => p.isFeatured) ?? res.posts[0] ?? null;
}; const rest = res.posts.filter(p => p !== featured);
setFeaturedPost(featured);
setPosts(rest);
setTotal(res.total);
}
} catch {
if (!cancelled) {
setPosts([]);
setFeaturedPost(null);
setTotal(0);
}
} finally {
if (!cancelled) setLoading(false);
}
};
const itemVariants = { load();
hidden: { opacity: 0, y: 20 }, return () => {
visible: { cancelled = true;
opacity: 1, };
y: 0, }, [selectedCategory, searchQuery]);
transition: { duration: 0.5 },
},
};
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
<LandingHeader activePage="blog" /> <LandingHeader activePage="blog" />
{/* Hero Section */} {/* Hero Section */}
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"> <section
ref={heroRef}
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
>
<div className="absolute inset-0 opacity-10"> <div className="absolute inset-0 opacity-10">
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" /> <div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" /> <div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
@ -126,7 +155,6 @@ export default function BlogPage() {
{t('intro')} {t('intro')}
</p> </p>
{/* Search Bar */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}} animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
@ -139,7 +167,7 @@ export default function BlogPage() {
type="text" type="text"
placeholder={t('searchPlaceholder')} placeholder={t('searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
className="w-full pl-12 pr-4 py-4 rounded-xl bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none" className="w-full pl-12 pr-4 py-4 rounded-xl bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none"
/> />
</div> </div>
@ -147,7 +175,6 @@ export default function BlogPage() {
</motion.div> </motion.div>
</div> </div>
{/* Wave */}
<div className="absolute bottom-0 left-0 right-0"> <div className="absolute bottom-0 left-0 right-0">
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none"> <svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
<path <path
@ -167,7 +194,7 @@ export default function BlogPage() {
className="max-w-7xl mx-auto px-6 lg:px-8" className="max-w-7xl mx-auto px-6 lg:px-8"
> >
<div className="flex flex-wrap items-center justify-center gap-4"> <div className="flex flex-wrap items-center justify-center gap-4">
{CATEGORIES.map((category) => { {CATEGORIES.map(category => {
const IconComponent = category.icon; const IconComponent = category.icon;
const isActive = selectedCategory === category.key; const isActive = selectedCategory === category.key;
return ( return (
@ -190,64 +217,71 @@ export default function BlogPage() {
</section> </section>
{/* Featured Article */} {/* Featured Article */}
<section className="py-16"> {!loading && featuredPost && (
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <section className="py-16">
<motion.div <div className="max-w-7xl mx-auto px-6 lg:px-8">
initial={{ opacity: 0, y: 30 }} <motion.div
whileInView={{ opacity: 1, y: 0 }} initial={{ opacity: 0, y: 30 }}
viewport={{ once: true }} whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }} viewport={{ once: true }}
> transition={{ duration: 0.8 }}
<Link href="/blog/1"> >
<div className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden group cursor-pointer"> <Link href={`/blog/${featuredPost.slug}`}>
<div className="absolute inset-0 bg-gradient-to-r from-brand-navy via-brand-navy/80 to-transparent z-10" /> <div className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden group cursor-pointer">
<div className="absolute right-0 top-0 bottom-0 w-1/2 bg-brand-turquoise/20 flex items-center justify-center"> <div className="absolute inset-0 bg-gradient-to-r from-brand-navy via-brand-navy/80 to-transparent z-10" />
<Anchor className="w-48 h-48 text-white/10" /> {featuredPost.coverImageUrl ? (
</div> <div
className="absolute right-0 top-0 bottom-0 w-1/2 bg-cover bg-center"
<div className="relative z-20 p-8 lg:p-12"> style={{ backgroundImage: `url(${resolveUrl(featuredPost.coverImageUrl)})` }}
<div className="max-w-2xl"> />
<div className="flex items-center space-x-2 mb-4"> ) : (
<span className="px-3 py-1 bg-brand-turquoise text-white text-sm font-medium rounded-full"> <div className="absolute right-0 top-0 bottom-0 w-1/2 bg-brand-turquoise/20 flex items-center justify-center">
{t('featuredBadge')} <Anchor className="w-48 h-48 text-white/10" />
</span>
<span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full">
{t('categories.technology')}
</span>
</div> </div>
)}
<h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors"> <div className="relative z-20 p-8 lg:p-12">
{t('featured.title')} <div className="max-w-2xl">
</h2> <div className="flex items-center space-x-2 mb-4">
<span className="px-3 py-1 bg-brand-turquoise text-white text-sm font-medium rounded-full">
<p className="text-lg text-white/80 mb-6">{t('featured.excerpt')}</p> {t('featuredBadge')}
</span>
<div className="flex items-center space-x-6 text-white/60 text-sm"> <span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full">
<div className="flex items-center space-x-2"> {t(`categories.${featuredPost.category}`)}
<User className="w-4 h-4" /> </span>
<span>{t('featured.author')}</span>
</div> </div>
<div className="flex items-center space-x-2">
<Calendar className="w-4 h-4" />
<span>{t('featured.date')}</span>
</div>
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>{t('featured.readTime')}</span>
</div>
</div>
<div className="flex items-center space-x-2 mt-6 text-brand-turquoise font-medium opacity-0 group-hover:opacity-100 transition-opacity"> <h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors">
<span>{t('readArticle')}</span> {featuredPost.title}
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" /> </h2>
<p className="text-lg text-white/80 mb-6">{featuredPost.excerpt}</p>
<div className="flex items-center space-x-6 text-white/60 text-sm">
<div className="flex items-center space-x-2">
<User className="w-4 h-4" />
<span>{featuredPost.authorName}</span>
</div>
{featuredPost.publishedAt && (
<div className="flex items-center space-x-2">
<Calendar className="w-4 h-4" />
<span>{formatDate(featuredPost.publishedAt)}</span>
</div>
)}
</div>
<div className="flex items-center space-x-2 mt-6 text-brand-turquoise font-medium opacity-0 group-hover:opacity-100 transition-opacity">
<span>{t('readArticle')}</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </Link>
</Link> </motion.div>
</motion.div> </div>
</div> </section>
</section> )}
{/* Articles Grid */} {/* Articles Grid */}
<section ref={articlesRef} className="py-16 bg-gray-50"> <section ref={articlesRef} className="py-16 bg-gray-50">
@ -259,10 +293,26 @@ export default function BlogPage() {
className="flex items-center justify-between mb-12" className="flex items-center justify-between mb-12"
> >
<h2 className="text-3xl font-bold text-brand-navy">{t('allTitle')}</h2> <h2 className="text-3xl font-bold text-brand-navy">{t('allTitle')}</h2>
<span className="text-gray-500">{t('articlesCount', { count: filteredArticles.length })}</span> <span className="text-gray-500">{t('articlesCount', { count: posts.length })}</span>
</motion.div> </motion.div>
{filteredArticles.length === 0 ? ( {loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{[1, 2, 3].map(i => (
<div
key={i}
className="bg-white rounded-2xl shadow-lg overflow-hidden animate-pulse"
>
<div className="aspect-video bg-gray-200" />
<div className="p-6 space-y-3">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-3 bg-gray-200 rounded" />
<div className="h-3 bg-gray-200 rounded w-5/6" />
</div>
</div>
))}
</div>
) : posts.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" /> <Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-medium text-gray-600">{t('noResults.title')}</h3> <h3 className="text-xl font-medium text-gray-600">{t('noResults.title')}</h3>
@ -275,30 +325,36 @@ export default function BlogPage() {
animate={isArticlesInView ? 'visible' : 'hidden'} animate={isArticlesInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
> >
{filteredArticles.map((article) => ( {posts.map(post => (
<motion.div key={article.id} variants={itemVariants}> <motion.div key={post.id} variants={itemVariants}>
<Link href={`/blog/${article.id}`}> <Link href={`/blog/${post.slug}`}>
<div className="bg-white rounded-2xl shadow-lg overflow-hidden group hover:shadow-xl transition-all h-full flex flex-col"> <div className="bg-white rounded-2xl shadow-lg overflow-hidden group hover:shadow-xl transition-all h-full flex flex-col">
<div className="aspect-video bg-gradient-to-br from-brand-navy/10 to-brand-turquoise/10 flex items-center justify-center relative"> <div className="aspect-video bg-gradient-to-br from-brand-navy/10 to-brand-turquoise/10 flex items-center justify-center relative overflow-hidden">
<Ship className="w-16 h-16 text-brand-navy/20" /> {post.coverImageUrl ? (
<img
src={resolveUrl(post.coverImageUrl)}
alt={post.title}
className="w-full h-full object-cover"
/>
) : (
<Ship className="w-16 h-16 text-brand-navy/20" />
)}
<div className="absolute top-4 left-4"> <div className="absolute top-4 left-4">
<span className="px-3 py-1 bg-white/90 text-brand-navy text-xs font-medium rounded-full"> <span className="px-3 py-1 bg-white/90 text-brand-navy text-xs font-medium rounded-full">
{t(`categories.${article.category}`)} {t(`categories.${post.category}`)}
</span> </span>
</div> </div>
</div> </div>
<div className="p-6 flex-1 flex flex-col"> <div className="p-6 flex-1 flex flex-col">
<h3 className="text-xl font-bold text-brand-navy mb-3 group-hover:text-brand-turquoise transition-colors line-clamp-2"> <h3 className="text-xl font-bold text-brand-navy mb-3 group-hover:text-brand-turquoise transition-colors line-clamp-2">
{t(`articles.${article.key}.title` as any)} {post.title}
</h3> </h3>
<p className="text-gray-600 mb-4 line-clamp-2 flex-1"> <p className="text-gray-600 mb-4 line-clamp-2 flex-1">{post.excerpt}</p>
{t(`articles.${article.key}.excerpt` as any)}
</p>
<div className="flex flex-wrap gap-2 mb-4"> <div className="flex flex-wrap gap-2 mb-4">
{article.tags.map((tag) => ( {post.tags.map(tag => (
<span <span
key={tag} key={tag}
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full" className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full"
@ -313,15 +369,14 @@ export default function BlogPage() {
<div className="w-8 h-8 bg-brand-turquoise/10 rounded-full flex items-center justify-center"> <div className="w-8 h-8 bg-brand-turquoise/10 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-brand-turquoise" /> <User className="w-4 h-4 text-brand-turquoise" />
</div> </div>
<span>{t(`articles.${article.key}.author` as any)}</span> <span>{post.authorName}</span>
</div> </div>
<div className="flex items-center space-x-4"> {post.publishedAt && (
<span>{t(`articles.${article.key}.date` as any)}</span> <div className="flex items-center space-x-1">
<span className="flex items-center space-x-1">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
<span>{t(`articles.${article.key}.readTime` as any)}</span> <span>{formatDate(post.publishedAt)}</span>
</span> </div>
</div> )}
</div> </div>
</div> </div>
</div> </div>
@ -330,21 +385,6 @@ export default function BlogPage() {
))} ))}
</motion.div> </motion.div>
)} )}
{/* Load More */}
{filteredArticles.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
className="text-center mt-12"
>
<button className="px-8 py-4 bg-white border-2 border-brand-turquoise text-brand-turquoise rounded-lg hover:bg-brand-turquoise hover:text-white transition-all font-semibold">
{t('loadMore')}
</button>
</motion.div>
)}
</div> </div>
</section> </section>
@ -357,12 +397,8 @@ export default function BlogPage() {
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
> >
<h2 className="text-4xl font-bold text-white mb-6"> <h2 className="text-4xl font-bold text-white mb-6">{t('newsletter.title')}</h2>
{t('newsletter.title')} <p className="text-xl text-white/80 mb-10">{t('newsletter.body')}</p>
</h2>
<p className="text-xl text-white/80 mb-10">
{t('newsletter.body')}
</p>
<form className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4"> <form className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<input <input
type="email" type="email"
@ -377,9 +413,7 @@ export default function BlogPage() {
<ArrowRight className="w-5 h-5" /> <ArrowRight className="w-5 h-5" />
</button> </button>
</form> </form>
<p className="text-white/50 text-sm mt-4"> <p className="text-white/50 text-sm mt-4">{t('newsletter.disclaimer')}</p>
{t('newsletter.disclaimer')}
</p>
</motion.div> </motion.div>
</div> </div>
</section> </section>

View File

@ -81,9 +81,7 @@ export default function BookingConfirmPage() {
/> />
</svg> </svg>
</div> </div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"> <h1 className="text-2xl font-bold text-gray-900 mb-2">{t('errorTitle')}</h1>
{t('errorTitle')}
</h1>
<p className="text-gray-600">{error}</p> <p className="text-gray-600">{error}</p>
</div> </div>
@ -98,9 +96,7 @@ export default function BookingConfirmPage() {
</ul> </ul>
</div> </div>
<p className="text-sm text-gray-500 text-center"> <p className="text-sm text-gray-500 text-center">{t('errorContact')}</p>
{t('errorContact')}
</p>
</div> </div>
</div> </div>
); );
@ -134,22 +130,14 @@ export default function BookingConfirmPage() {
<div className="absolute inset-0 rounded-full border-4 border-green-200 animate-ping opacity-20"></div> <div className="absolute inset-0 rounded-full border-4 border-green-200 animate-ping opacity-20"></div>
</div> </div>
<h1 className="text-3xl font-bold text-gray-900 mb-3"> <h1 className="text-3xl font-bold text-gray-900 mb-3">{t('successTitle')}</h1>
{t('successTitle')} <p className="text-lg text-gray-600 mb-2">{t('successHeadline')}</p>
</h1> <p className="text-gray-500">{t('successBody')}</p>
<p className="text-lg text-gray-600 mb-2">
{t('successHeadline')}
</p>
<p className="text-gray-500">
{t('successBody')}
</p>
</div> </div>
{/* Booking Summary */} {/* Booking Summary */}
<div className="bg-gray-50 rounded-xl p-6 mb-6"> <div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4"> <h2 className="text-lg font-semibold text-gray-900 mb-4">{t('summaryTitle')}</h2>
{t('summaryTitle')}
</h2>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-200"> <div className="flex justify-between py-2 border-b border-gray-200">
@ -197,14 +185,12 @@ export default function BookingConfirmPage() {
<div className="font-bold text-xl text-green-600"> <div className="font-bold text-xl text-green-600">
{booking.primaryCurrency === 'USD' {booking.primaryCurrency === 'USD'
? `$${booking.priceUSD.toLocaleString()}` ? `$${booking.priceUSD.toLocaleString()}`
: `${booking.priceEUR.toLocaleString()}` : `${booking.priceEUR.toLocaleString()}`}
}
</div> </div>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{booking.primaryCurrency === 'USD' {booking.primaryCurrency === 'USD'
? `(€${booking.priceEUR.toLocaleString()})` ? `(€${booking.priceEUR.toLocaleString()})`
: `($${booking.priceUSD.toLocaleString()})` : `($${booking.priceUSD.toLocaleString()})`}
}
</div> </div>
</div> </div>
</div> </div>
@ -222,7 +208,12 @@ export default function BookingConfirmPage() {
<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">
<h3 className="font-semibold text-blue-900 mb-2 flex items-center"> <h3 className="font-semibold text-blue-900 mb-2 flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
{t('nextStepsTitle')} {t('nextStepsTitle')}
</h3> </h3>
@ -239,10 +230,23 @@ export default function BookingConfirmPage() {
<h3 className="font-semibold text-gray-900 mb-3">{t('labels.documents')}</h3> <h3 className="font-semibold text-gray-900 mb-3">{t('labels.documents')}</h3>
<div className="space-y-2"> <div className="space-y-2">
{booking.documents.map((doc, index) => ( {booking.documents.map((doc, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-white rounded border border-gray-200"> <div
key={index}
className="flex items-center justify-between p-3 bg-white rounded border border-gray-200"
>
<div className="flex items-center"> <div className="flex items-center">
<svg className="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> className="w-5 h-5 text-gray-400 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
<div> <div>
<p className="text-sm font-medium text-gray-900">{doc.fileName}</p> <p className="text-sm font-medium text-gray-900">{doc.fileName}</p>

View File

@ -89,9 +89,7 @@ export default function BookingRejectPage() {
/> />
</svg> </svg>
</div> </div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"> <h1 className="text-2xl font-bold text-gray-900 mb-2">{t('errorTitle')}</h1>
{t('errorTitle')}
</h1>
<p className="text-gray-600">{error}</p> <p className="text-gray-600">{error}</p>
</div> </div>
@ -106,9 +104,7 @@ export default function BookingRejectPage() {
</ul> </ul>
</div> </div>
<p className="text-sm text-gray-500 text-center"> <p className="text-sm text-gray-500 text-center">{t('errorContact')}</p>
{t('errorContact')}
</p>
</div> </div>
</div> </div>
); );
@ -137,21 +133,13 @@ export default function BookingRejectPage() {
</div> </div>
</div> </div>
<h1 className="text-3xl font-bold text-gray-900 mb-3"> <h1 className="text-3xl font-bold text-gray-900 mb-3">{t('rejectedTitle')}</h1>
{t('rejectedTitle')} <p className="text-lg text-gray-600 mb-2">{t('rejectedHeadline')}</p>
</h1> <p className="text-gray-500">{t('rejectedBody')}</p>
<p className="text-lg text-gray-600 mb-2">
{t('rejectedHeadline')}
</p>
<p className="text-gray-500">
{t('rejectedBody')}
</p>
</div> </div>
<div className="bg-gray-50 rounded-xl p-6 mb-6"> <div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4"> <h2 className="text-lg font-semibold text-gray-900 mb-4">{t('summaryTitle')}</h2>
{t('summaryTitle')}
</h2>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-200"> <div className="flex justify-between py-2 border-b border-gray-200">
@ -181,8 +169,7 @@ export default function BookingRejectPage() {
<span className="font-semibold text-gray-900"> <span className="font-semibold text-gray-900">
{booking.primaryCurrency === 'USD' {booking.primaryCurrency === 'USD'
? `$${booking.priceUSD.toLocaleString()}` ? `$${booking.priceUSD.toLocaleString()}`
: `${booking.priceEUR.toLocaleString()}` : `${booking.priceEUR.toLocaleString()}`}
}
</span> </span>
</div> </div>
</div> </div>
@ -200,13 +187,16 @@ export default function BookingRejectPage() {
<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">
<h3 className="font-semibold text-blue-900 mb-2 flex items-center"> <h3 className="font-semibold text-blue-900 mb-2 flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
{t('infoTitle')} {t('infoTitle')}
</h3> </h3>
<p className="text-sm text-blue-800"> <p className="text-sm text-blue-800">{t('infoBody')}</p>
{t('infoBody')}
</p>
</div> </div>
<div className="text-center text-sm text-gray-500"> <div className="text-center text-sm text-gray-500">
@ -259,12 +249,8 @@ export default function BookingRejectPage() {
/> />
</svg> </svg>
</div> </div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"> <h1 className="text-2xl font-bold text-gray-900 mb-2">{t('formTitle')}</h1>
{t('formTitle')} <p className="text-gray-600">{t('formIntro')}</p>
</h1>
<p className="text-gray-600">
{t('formIntro')}
</p>
</div> </div>
<div className="mb-6"> <div className="mb-6">
@ -275,8 +261,18 @@ export default function BookingRejectPage() {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-gray-700">{t('addReason')}</span> <span className="text-gray-700">{t('addReason')}</span>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</div> </div>
</button> </button>
@ -289,18 +285,14 @@ export default function BookingRejectPage() {
id="reason" id="reason"
rows={4} rows={4}
value={reason} value={reason}
onChange={(e) => setReason(e.target.value)} onChange={e => setReason(e.target.value)}
placeholder={t('reasonPlaceholder')} placeholder={t('reasonPlaceholder')}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent resize-none" className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent resize-none"
maxLength={500} maxLength={500}
/> />
<div className="mt-1 flex items-center justify-between"> <div className="mt-1 flex items-center justify-between">
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">{t('reasonHint')}</p>
{t('reasonHint')} <span className="text-xs text-gray-400">{reason.length}/500</span>
</p>
<span className="text-xs text-gray-400">
{reason.length}/500
</span>
</div> </div>
</div> </div>
)} )}
@ -320,16 +312,36 @@ export default function BookingRejectPage() {
> >
{isRejecting ? ( {isRejecting ? (
<> <>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
{t('submitting')} {t('submitting')}
</> </>
) : ( ) : (
<> <>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
{t('submit')} {t('submit')}
</> </>
@ -344,9 +356,7 @@ export default function BookingRejectPage() {
</a> </a>
</div> </div>
<p className="mt-6 text-xs text-center text-gray-500"> <p className="mt-6 text-xs text-center text-gray-500">{t('helpText')}</p>
{t('helpText')}
</p>
</div> </div>
</div> </div>
); );

View File

@ -64,15 +64,76 @@ type JobRecord = {
}; };
const JOBS: JobRecord[] = [ const JOBS: JobRecord[] = [
{ id: 1, key: 'frontend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '65K - 85K €', icon: Code }, {
{ id: 2, key: 'backend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '55K - 75K €', icon: Code }, id: 1,
{ id: 3, key: 'pm', department: 'Product', location: 'Paris', type: 'CDI', remote: true, salary: '60K - 80K €', icon: LineChart }, key: 'frontend',
{ id: 4, key: 'ae', department: 'Sales', location: 'Rotterdam', type: 'CDI', remote: false, salary: '50K - 70K € + variable', icon: Megaphone }, department: 'Engineering',
{ id: 5, key: 'csm', department: 'Customer Success', location: 'Paris', type: 'CDI', remote: true, salary: '45K - 60K €', icon: Headphones }, location: 'Paris',
{ id: 6, key: 'data', department: 'Data', location: 'Hambourg', type: 'CDI', remote: true, salary: '50K - 65K €', icon: LineChart }, type: 'CDI',
remote: true,
salary: '65K - 85K €',
icon: Code,
},
{
id: 2,
key: 'backend',
department: 'Engineering',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '55K - 75K €',
icon: Code,
},
{
id: 3,
key: 'pm',
department: 'Product',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '60K - 80K €',
icon: LineChart,
},
{
id: 4,
key: 'ae',
department: 'Sales',
location: 'Rotterdam',
type: 'CDI',
remote: false,
salary: '50K - 70K € + variable',
icon: Megaphone,
},
{
id: 5,
key: 'csm',
department: 'Customer Success',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '45K - 60K €',
icon: Headphones,
},
{
id: 6,
key: 'data',
department: 'Data',
location: 'Hambourg',
type: 'CDI',
remote: true,
salary: '50K - 65K €',
icon: LineChart,
},
]; ];
const DEPARTMENT_VALUES: DepartmentValue[] = ['all', 'Engineering', 'Product', 'Sales', 'Customer Success', 'Data']; const DEPARTMENT_VALUES: DepartmentValue[] = [
'all',
'Engineering',
'Product',
'Sales',
'Customer Success',
'Data',
];
const LOCATION_VALUES: LocationValue[] = ['all', 'Paris', 'Rotterdam', 'Hambourg']; const LOCATION_VALUES: LocationValue[] = ['all', 'Paris', 'Rotterdam', 'Hambourg'];
const JOB_REQ_KEYS = ['req1', 'req2', 'req3', 'req4'] as const; const JOB_REQ_KEYS = ['req1', 'req2', 'req3', 'req4'] as const;
@ -92,7 +153,7 @@ export default function CareersPage() {
const isJobsInView = useInView(jobsRef, { once: true }); const isJobsInView = useInView(jobsRef, { once: true });
const isCultureInView = useInView(cultureRef, { once: true }); const isCultureInView = useInView(cultureRef, { once: true });
const filteredJobs = JOBS.filter((job) => { const filteredJobs = JOBS.filter(job => {
const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment; const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment;
const locationMatch = selectedLocation === 'all' || job.location === selectedLocation; const locationMatch = selectedLocation === 'all' || job.location === selectedLocation;
return departmentMatch && locationMatch; return departmentMatch && locationMatch;
@ -124,7 +185,10 @@ export default function CareersPage() {
<LandingHeader activePage="careers" /> <LandingHeader activePage="careers" />
{/* Hero Section */} {/* Hero Section */}
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"> <section
ref={heroRef}
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
>
<div className="absolute inset-0 opacity-10"> <div className="absolute inset-0 opacity-10">
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" /> <div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" /> <div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
@ -221,9 +285,7 @@ export default function CareersPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4"> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('benefitsTitle')} {t('benefitsTitle')}
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('benefitsSubtitle')}</p>
{t('benefitsSubtitle')}
</p>
</motion.div> </motion.div>
<motion.div <motion.div
@ -232,7 +294,7 @@ export default function CareersPage() {
animate={isBenefitsInView ? 'visible' : 'hidden'} animate={isBenefitsInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
> >
{BENEFITS.map((benefit) => { {BENEFITS.map(benefit => {
const IconComponent = benefit.icon; const IconComponent = benefit.icon;
return ( return (
<motion.div <motion.div
@ -244,7 +306,9 @@ export default function CareersPage() {
<div className="w-14 h-14 bg-brand-turquoise/10 rounded-xl flex items-center justify-center mb-4"> <div className="w-14 h-14 bg-brand-turquoise/10 rounded-xl flex items-center justify-center mb-4">
<IconComponent className="w-7 h-7 text-brand-turquoise" /> <IconComponent className="w-7 h-7 text-brand-turquoise" />
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`benefits.${benefit.key}.title`)}</h3> <h3 className="text-xl font-bold text-brand-navy mb-2">
{t(`benefits.${benefit.key}.title`)}
</h3>
<p className="text-gray-600">{t(`benefits.${benefit.key}.description`)}</p> <p className="text-gray-600">{t(`benefits.${benefit.key}.description`)}</p>
</motion.div> </motion.div>
); );
@ -254,7 +318,10 @@ export default function CareersPage() {
</section> </section>
{/* Culture Section */} {/* Culture Section */}
<section ref={cultureRef} className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95"> <section
ref={cultureRef}
className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95"
>
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<motion.div <motion.div
@ -265,9 +332,7 @@ export default function CareersPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6"> <h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
{t('cultureTitle')} {t('cultureTitle')}
</h2> </h2>
<p className="text-xl text-white/80 mb-8"> <p className="text-xl text-white/80 mb-8">{t('cultureBody')}</p>
{t('cultureBody')}
</p>
<ul className="space-y-4"> <ul className="space-y-4">
{CULTURE_ITEMS.map((itemKey, index) => ( {CULTURE_ITEMS.map((itemKey, index) => (
<motion.li <motion.li
@ -292,7 +357,7 @@ export default function CareersPage() {
transition={{ duration: 0.8, delay: 0.2 }} transition={{ duration: 0.8, delay: 0.2 }}
className="grid grid-cols-2 gap-4" className="grid grid-cols-2 gap-4"
> >
{[1, 2, 3, 4].map((i) => ( {[1, 2, 3, 4].map(i => (
<div <div
key={i} key={i}
className="aspect-square bg-white/10 rounded-2xl flex items-center justify-center" className="aspect-square bg-white/10 rounded-2xl flex items-center justify-center"
@ -317,9 +382,7 @@ export default function CareersPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4"> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('jobsTitle')} {t('jobsTitle')}
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('jobsSubtitle')}</p>
{t('jobsSubtitle')}
</p>
</motion.div> </motion.div>
{/* Filters */} {/* Filters */}
@ -332,12 +395,14 @@ export default function CareersPage() {
<div className="relative"> <div className="relative">
<select <select
value={selectedDepartment} value={selectedDepartment}
onChange={(e) => setSelectedDepartment(e.target.value as DepartmentValue)} onChange={e => setSelectedDepartment(e.target.value as DepartmentValue)}
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer" className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
> >
{DEPARTMENT_VALUES.map((value) => ( {DEPARTMENT_VALUES.map(value => (
<option key={value} value={value}> <option key={value} value={value}>
{value === 'all' ? t('filters.allDepartments') : t(`departments.${value}` as any)} {value === 'all'
? t('filters.allDepartments')
: t(`departments.${value}` as any)}
</option> </option>
))} ))}
</select> </select>
@ -346,10 +411,10 @@ export default function CareersPage() {
<div className="relative"> <div className="relative">
<select <select
value={selectedLocation} value={selectedLocation}
onChange={(e) => setSelectedLocation(e.target.value as LocationValue)} onChange={e => setSelectedLocation(e.target.value as LocationValue)}
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer" className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
> >
{LOCATION_VALUES.map((value) => ( {LOCATION_VALUES.map(value => (
<option key={value} value={value}> <option key={value} value={value}>
{value === 'all' ? t('filters.allLocations') : t(`locations.${value}` as any)} {value === 'all' ? t('filters.allLocations') : t(`locations.${value}` as any)}
</option> </option>
@ -373,7 +438,7 @@ export default function CareersPage() {
<p className="text-gray-500">{t('noJobs.body')}</p> <p className="text-gray-500">{t('noJobs.body')}</p>
</div> </div>
) : ( ) : (
filteredJobs.map((job) => { filteredJobs.map(job => {
const IconComponent = job.icon; const IconComponent = job.icon;
const isExpanded = expandedJob === job.id; const isExpanded = expandedJob === job.id;
@ -393,7 +458,9 @@ export default function CareersPage() {
<IconComponent className="w-6 h-6 text-brand-turquoise" /> <IconComponent className="w-6 h-6 text-brand-turquoise" />
</div> </div>
<div> <div>
<h3 className="text-xl font-bold text-brand-navy">{t(`jobs.${job.key}.title`)}</h3> <h3 className="text-xl font-bold text-brand-navy">
{t(`jobs.${job.key}.title`)}
</h3>
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500"> <div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span className="flex items-center space-x-1"> <span className="flex items-center space-x-1">
<Building2 className="w-4 h-4" /> <Building2 className="w-4 h-4" />
@ -441,10 +508,15 @@ export default function CareersPage() {
> >
<div className="p-6 bg-gray-50"> <div className="p-6 bg-gray-50">
<p className="text-gray-600 mb-6">{t(`jobs.${job.key}.description`)}</p> <p className="text-gray-600 mb-6">{t(`jobs.${job.key}.description`)}</p>
<h4 className="font-bold text-brand-navy mb-3">{t('jobCard.profile')}</h4> <h4 className="font-bold text-brand-navy mb-3">
{t('jobCard.profile')}
</h4>
<ul className="space-y-2 mb-6"> <ul className="space-y-2 mb-6">
{JOB_REQ_KEYS.map((reqKey) => ( {JOB_REQ_KEYS.map(reqKey => (
<li key={reqKey} className="flex items-start space-x-2 text-gray-600"> <li
key={reqKey}
className="flex items-start space-x-2 text-gray-600"
>
<ChevronRight className="w-5 h-5 text-brand-turquoise flex-shrink-0 mt-0.5" /> <ChevronRight className="w-5 h-5 text-brand-turquoise flex-shrink-0 mt-0.5" />
<span>{t(`jobs.${job.key}.${reqKey}` as any)}</span> <span>{t(`jobs.${job.key}.${reqKey}` as any)}</span>
</li> </li>
@ -483,12 +555,8 @@ export default function CareersPage() {
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
> >
<h2 className="text-4xl font-bold text-brand-navy mb-6"> <h2 className="text-4xl font-bold text-brand-navy mb-6">{t('cta.title')}</h2>
{t('cta.title')} <p className="text-xl text-gray-600 mb-10">{t('cta.body')}</p>
</h2>
<p className="text-xl text-gray-600 mb-10">
{t('cta.body')}
</p>
<Link <Link
href="/contact" href="/contact"
className="inline-flex items-center space-x-2 px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold text-lg" className="inline-flex items-center space-x-2 px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold text-lg"

View File

@ -55,7 +55,10 @@ export default function CarrierAcceptPage() {
errorMessage = t('common.bookingAlreadyAccepted'); errorMessage = t('common.bookingAlreadyAccepted');
} else if (errorMessage.includes('status REJECTED')) { } else if (errorMessage.includes('status REJECTED')) {
errorMessage = t('common.bookingAlreadyRejected'); errorMessage = t('common.bookingAlreadyRejected');
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) { } else if (
errorMessage.includes('not found') ||
errorMessage.includes('Booking not found')
) {
errorMessage = t('common.bookingNotFound'); errorMessage = t('common.bookingNotFound');
} }
@ -65,7 +68,7 @@ export default function CarrierAcceptPage() {
setLoading(false); setLoading(false);
const timer = setInterval(() => { const timer = setInterval(() => {
setCountdown((prev) => { setCountdown(prev => {
if (prev <= 1) { if (prev <= 1) {
clearInterval(timer); clearInterval(timer);
router.push('/'); router.push('/');
@ -91,12 +94,8 @@ export default function CarrierAcceptPage() {
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center"> <div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<Loader2 className="w-16 h-16 text-green-600 mx-auto mb-4 animate-spin" /> <Loader2 className="w-16 h-16 text-green-600 mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-4"> <h1 className="text-2xl font-bold text-gray-900 mb-4">{t('accept.loadingTitle')}</h1>
{t('accept.loadingTitle')} <p className="text-gray-600">{t('accept.loadingMessage')}</p>
</h1>
<p className="text-gray-600">
{t('accept.loadingMessage')}
</p>
</div> </div>
</div> </div>
); );
@ -124,22 +123,14 @@ export default function CarrierAcceptPage() {
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center"> <div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<CheckCircle className="w-20 h-20 text-green-500 mx-auto mb-6" /> <CheckCircle className="w-20 h-20 text-green-500 mx-auto mb-6" />
<h1 className="text-3xl font-bold text-gray-900 mb-4"> <h1 className="text-3xl font-bold text-gray-900 mb-4">{t('accept.thanksTitle')}</h1>
{t('accept.thanksTitle')}
</h1>
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6 mb-6"> <div className="bg-green-50 border-2 border-green-200 rounded-lg p-6 mb-6">
<p className="text-green-800 font-medium text-lg mb-2"> <p className="text-green-800 font-medium text-lg mb-2">{t('accept.successHeadline')}</p>
{t('accept.successHeadline')} <p className="text-green-700 text-sm">{t('accept.successBody')}</p>
</p>
<p className="text-green-700 text-sm">
{t('accept.successBody')}
</p>
</div> </div>
<p className="text-gray-500 text-sm mb-4"> <p className="text-gray-500 text-sm mb-4">{t('common.redirecting', { countdown })}</p>
{t('common.redirecting', { countdown })}
</p>
<button <button
onClick={() => router.push('/')} onClick={() => router.push('/')}

View File

@ -189,7 +189,10 @@ export default function CarrierDocumentsPage() {
throw new Error(t('notAcceptedYet')); throw new Error(t('notAcceptedYet'));
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) { } else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
throw new Error(t('bookingNotFound')); throw new Error(t('bookingNotFound'));
} else if (errorMessage.includes('Mot de passe requis') || errorMessage.includes('required')) { } else if (
errorMessage.includes('Mot de passe requis') ||
errorMessage.includes('required')
) {
setRequirements({ requiresPassword: true, status: 'ACCEPTED' }); setRequirements({ requiresPassword: true, status: 'ACCEPTED' });
setLoading(false); setLoading(false);
return; return;
@ -336,12 +339,11 @@ export default function CarrierDocumentsPage() {
<Lock className="w-8 h-8 text-brand-turquoise" /> <Lock className="w-8 h-8 text-brand-turquoise" />
</div> </div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('password.title')}</h1> <h1 className="text-2xl font-bold text-gray-900 mb-2">{t('password.title')}</h1>
<p className="text-gray-600"> <p className="text-gray-600">{t('password.intro')}</p>
{t('password.intro')}
</p>
{requirements.bookingNumber && ( {requirements.bookingNumber && (
<p className="mt-2 text-sm text-gray-500"> <p className="mt-2 text-sm text-gray-500">
{t('password.bookingLabel')} <span className="font-mono font-bold">{requirements.bookingNumber}</span> {t('password.bookingLabel')}{' '}
<span className="font-mono font-bold">{requirements.bookingNumber}</span>
</p> </p>
)} )}
</div> </div>
@ -467,7 +469,9 @@ export default function CarrierDocumentsPage() {
<div className="text-center p-3 bg-gray-50 rounded-lg"> <div className="text-center p-3 bg-gray-50 rounded-lg">
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" /> <Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">{t('summary.transit')}</p> <p className="text-xs text-gray-500">{t('summary.transit')}</p>
<p className="font-semibold text-gray-900">{t('summary.transitDays', { count: booking.transitDays })}</p> <p className="font-semibold text-gray-900">
{t('summary.transitDays', { count: booking.transitDays })}
</p>
</div> </div>
<div className="text-center p-3 bg-gray-50 rounded-lg"> <div className="text-center p-3 bg-gray-50 rounded-lg">
<Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" /> <Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" />
@ -504,9 +508,7 @@ export default function CarrierDocumentsPage() {
<div className="p-8 text-center"> <div className="p-8 text-center">
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" /> <AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600">{t('list.empty')}</p> <p className="text-gray-600">{t('list.empty')}</p>
<p className="text-gray-500 text-sm mt-1"> <p className="text-gray-500 text-sm mt-1">{t('list.emptyHint')}</p>
{t('list.emptyHint')}
</p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100">
@ -552,9 +554,7 @@ export default function CarrierDocumentsPage() {
</div> </div>
{/* Info */} {/* Info */}
<p className="mt-6 text-center text-sm text-gray-500"> <p className="mt-6 text-center text-sm text-gray-500">{t('footerNote')}</p>
{t('footerNote')}
</p>
</main> </main>
{/* Footer */} {/* Footer */}

View File

@ -55,7 +55,10 @@ export default function CarrierRejectPage() {
errorMessage = t('common.bookingAlreadyRejected'); errorMessage = t('common.bookingAlreadyRejected');
} else if (errorMessage.includes('status ACCEPTED')) { } else if (errorMessage.includes('status ACCEPTED')) {
errorMessage = t('common.bookingAlreadyAccepted'); errorMessage = t('common.bookingAlreadyAccepted');
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) { } else if (
errorMessage.includes('not found') ||
errorMessage.includes('Booking not found')
) {
errorMessage = t('common.bookingNotFound'); errorMessage = t('common.bookingNotFound');
} }
@ -65,7 +68,7 @@ export default function CarrierRejectPage() {
setLoading(false); setLoading(false);
const timer = setInterval(() => { const timer = setInterval(() => {
setCountdown((prev) => { setCountdown(prev => {
if (prev <= 1) { if (prev <= 1) {
clearInterval(timer); clearInterval(timer);
router.push('/'); router.push('/');
@ -91,12 +94,8 @@ export default function CarrierRejectPage() {
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center"> <div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<Loader2 className="w-16 h-16 text-orange-600 mx-auto mb-4 animate-spin" /> <Loader2 className="w-16 h-16 text-orange-600 mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-4"> <h1 className="text-2xl font-bold text-gray-900 mb-4">{t('reject.loadingTitle')}</h1>
{t('reject.loadingTitle')} <p className="text-gray-600">{t('reject.loadingMessage')}</p>
</h1>
<p className="text-gray-600">
{t('reject.loadingMessage')}
</p>
</div> </div>
</div> </div>
); );
@ -124,22 +123,14 @@ export default function CarrierRejectPage() {
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 to-red-50"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 to-red-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center"> <div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<CheckCircle className="w-20 h-20 text-orange-500 mx-auto mb-6" /> <CheckCircle className="w-20 h-20 text-orange-500 mx-auto mb-6" />
<h1 className="text-3xl font-bold text-gray-900 mb-4"> <h1 className="text-3xl font-bold text-gray-900 mb-4">{t('reject.thanksTitle')}</h1>
{t('reject.thanksTitle')}
</h1>
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-6 mb-6"> <div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-6 mb-6">
<p className="text-orange-800 font-medium text-lg mb-2"> <p className="text-orange-800 font-medium text-lg mb-2">{t('reject.successHeadline')}</p>
{t('reject.successHeadline')} <p className="text-orange-700 text-sm">{t('reject.successBody')}</p>
</p>
<p className="text-orange-700 text-sm">
{t('reject.successBody')}
</p>
</div> </div>
<p className="text-gray-500 text-sm mb-4"> <p className="text-gray-500 text-sm mb-4">{t('common.redirecting', { countdown })}</p>
{t('common.redirecting', { countdown })}
</p>
<button <button
onClick={() => router.push('/')} onClick={() => router.push('/')}

View File

@ -77,7 +77,10 @@ export default function CompliancePage() {
<LandingHeader /> <LandingHeader />
{/* Hero Section */} {/* Hero Section */}
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"> <section
ref={heroRef}
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
>
<div className="absolute inset-0 opacity-10"> <div className="absolute inset-0 opacity-10">
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" /> <div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" /> <div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
@ -148,9 +151,7 @@ export default function CompliancePage() {
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('rightsTitle')} {t('rightsTitle')}
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('rightsSubtitle')}</p>
{t('rightsSubtitle')}
</p>
</motion.div> </motion.div>
<motion.div <motion.div
@ -159,7 +160,7 @@ export default function CompliancePage() {
animate={isContentInView ? 'visible' : 'hidden'} animate={isContentInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
> >
{RIGHTS.map((right) => { {RIGHTS.map(right => {
const IconComponent = right.icon; const IconComponent = right.icon;
return ( return (
<motion.div <motion.div
@ -171,7 +172,9 @@ export default function CompliancePage() {
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mx-auto mb-4">
<IconComponent className="w-8 h-8 text-brand-turquoise" /> <IconComponent className="w-8 h-8 text-brand-turquoise" />
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`rights.${right.key}.title`)}</h3> <h3 className="text-xl font-bold text-brand-navy mb-3">
{t(`rights.${right.key}.title`)}
</h3>
<p className="text-gray-600">{t(`rights.${right.key}.description`)}</p> <p className="text-gray-600">{t(`rights.${right.key}.description`)}</p>
</motion.div> </motion.div>
); );
@ -185,9 +188,7 @@ export default function CompliancePage() {
transition={{ duration: 0.8, delay: 0.4 }} transition={{ duration: 0.8, delay: 0.4 }}
className="mt-12 text-center" className="mt-12 text-center"
> >
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">{t('rightsCta.text')}</p>
{t('rightsCta.text')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4"> <div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<Link <Link
href="/login" href="/login"
@ -219,9 +220,7 @@ export default function CompliancePage() {
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('principlesTitle')} {t('principlesTitle')}
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('principlesSubtitle')}</p>
{t('principlesSubtitle')}
</p>
</motion.div> </motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
@ -239,8 +238,12 @@ export default function CompliancePage() {
<div className="w-12 h-12 bg-brand-green/10 rounded-xl flex items-center justify-center mb-4"> <div className="w-12 h-12 bg-brand-green/10 rounded-xl flex items-center justify-center mb-4">
<IconComponent className="w-6 h-6 text-brand-green" /> <IconComponent className="w-6 h-6 text-brand-green" />
</div> </div>
<h3 className="text-lg font-bold text-brand-navy mb-2">{t(`principles.${principle.key}.title`)}</h3> <h3 className="text-lg font-bold text-brand-navy mb-2">
<p className="text-gray-600 text-sm">{t(`principles.${principle.key}.description`)}</p> {t(`principles.${principle.key}.title`)}
</h3>
<p className="text-gray-600 text-sm">
{t(`principles.${principle.key}.description`)}
</p>
</motion.div> </motion.div>
); );
})} })}
@ -261,9 +264,7 @@ export default function CompliancePage() {
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('measuresTitle')} {t('measuresTitle')}
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('measuresSubtitle')}</p>
{t('measuresSubtitle')}
</p>
</motion.div> </motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
@ -278,7 +279,7 @@ export default function CompliancePage() {
> >
<h3 className="text-xl font-bold text-white mb-6">{t(`measures.${key}.title`)}</h3> <h3 className="text-xl font-bold text-white mb-6">{t(`measures.${key}.title`)}</h3>
<ul className="space-y-4"> <ul className="space-y-4">
{MEASURE_ITEMS.map((itemKey) => ( {MEASURE_ITEMS.map(itemKey => (
<li key={itemKey} className="flex items-center space-x-3 text-white/80"> <li key={itemKey} className="flex items-center space-x-3 text-white/80">
<CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" /> <CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" />
<span>{t(`measures.${key}.${itemKey}` as any)}</span> <span>{t(`measures.${key}.${itemKey}` as any)}</span>
@ -306,14 +307,10 @@ export default function CompliancePage() {
<FileText className="w-6 h-6 text-brand-turquoise" /> <FileText className="w-6 h-6 text-brand-turquoise" />
</div> </div>
<div> <div>
<h3 className="text-2xl font-bold text-brand-navy mb-4"> <h3 className="text-2xl font-bold text-brand-navy mb-4">{t('register.title')}</h3>
{t('register.title')} <p className="text-gray-600 mb-6">{t('register.body')}</p>
</h3>
<p className="text-gray-600 mb-6">
{t('register.body')}
</p>
<ul className="space-y-3 text-gray-600"> <ul className="space-y-3 text-gray-600">
{REGISTER_ITEMS.map((itemKey) => ( {REGISTER_ITEMS.map(itemKey => (
<li key={itemKey} className="flex items-center space-x-3"> <li key={itemKey} className="flex items-center space-x-3">
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" /> <CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
<span>{t(`register.${itemKey}` as any)}</span> <span>{t(`register.${itemKey}` as any)}</span>
@ -337,12 +334,8 @@ export default function CompliancePage() {
className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center" className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
> >
<UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" /> <UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
<h3 className="text-2xl font-bold text-white mb-4"> <h3 className="text-2xl font-bold text-white mb-4">{t('dpo.title')}</h3>
{t('dpo.title')} <p className="text-white/80 mb-6 max-w-2xl mx-auto">{t('dpo.body')}</p>
</h3>
<p className="text-white/80 mb-6 max-w-2xl mx-auto">
{t('dpo.body')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4"> <div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<a <a
href="mailto:dpo@xpeditis.com" href="mailto:dpo@xpeditis.com"

View File

@ -34,7 +34,15 @@ const METHODS: { key: MethodKey; icon: LucideIcon; color: string }[] = [
{ key: 'support', icon: Headphones, color: 'from-orange-500 to-red-500' }, { key: 'support', icon: Headphones, color: 'from-orange-500 to-red-500' },
]; ];
const SUBJECTS: SubjectKey[] = ['demo', 'pricing', 'partnership', 'support', 'press', 'careers', 'other']; const SUBJECTS: SubjectKey[] = [
'demo',
'pricing',
'partnership',
'support',
'press',
'careers',
'other',
];
export default function ContactPage() { export default function ContactPage() {
const t = useTranslations('marketing.contact'); const t = useTranslations('marketing.contact');
@ -89,7 +97,7 @@ export default function ContactPage() {
const handleChange = ( const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement> e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => { ) => {
setFormData((prev) => ({ setFormData(prev => ({
...prev, ...prev,
[e.target.name]: e.target.value, [e.target.name]: e.target.value,
})); }));
@ -121,7 +129,10 @@ export default function ContactPage() {
<LandingHeader activePage="contact" /> <LandingHeader activePage="contact" />
{/* Hero Section */} {/* Hero Section */}
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"> <section
ref={heroRef}
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
>
<div className="absolute inset-0 opacity-10"> <div className="absolute inset-0 opacity-10">
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" /> <div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" /> <div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
@ -178,7 +189,7 @@ export default function ContactPage() {
className="max-w-7xl mx-auto px-6 lg:px-8" className="max-w-7xl mx-auto px-6 lg:px-8"
> >
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{METHODS.map((method) => { {METHODS.map(method => {
const IconComponent = method.icon; const IconComponent = method.icon;
return ( return (
<motion.div <motion.div
@ -191,9 +202,15 @@ export default function ContactPage() {
> >
<IconComponent className="w-6 h-6 text-white" /> <IconComponent className="w-6 h-6 text-white" />
</div> </div>
<h3 className="text-lg font-bold text-brand-navy mb-1">{t(`methods.${method.key}.title`)}</h3> <h3 className="text-lg font-bold text-brand-navy mb-1">
<p className="text-gray-500 text-sm mb-2">{t(`methods.${method.key}.description`)}</p> {t(`methods.${method.key}.title`)}
<p className="text-brand-turquoise font-medium">{t(`methods.${method.key}.value`)}</p> </h3>
<p className="text-gray-500 text-sm mb-2">
{t(`methods.${method.key}.description`)}
</p>
<p className="text-brand-turquoise font-medium">
{t(`methods.${method.key}.value`)}
</p>
</motion.div> </motion.div>
); );
})} })}
@ -212,9 +229,7 @@ export default function ContactPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
> >
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('form.title')}</h2> <h2 className="text-3xl font-bold text-brand-navy mb-6">{t('form.title')}</h2>
<p className="text-gray-600 mb-8"> <p className="text-gray-600 mb-8">{t('form.description')}</p>
{t('form.description')}
</p>
{isSubmitted ? ( {isSubmitted ? (
<motion.div <motion.div
@ -225,10 +240,10 @@ export default function ContactPage() {
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle2 className="w-8 h-8 text-green-600" /> <CheckCircle2 className="w-8 h-8 text-green-600" />
</div> </div>
<h3 className="text-2xl font-bold text-green-800 mb-2">{t('form.successTitle')}</h3> <h3 className="text-2xl font-bold text-green-800 mb-2">
<p className="text-green-700 mb-6"> {t('form.successTitle')}
{t('form.successBody')} </h3>
</p> <p className="text-green-700 mb-6">{t('form.successBody')}</p>
<button <button
onClick={() => { onClick={() => {
setIsSubmitted(false); setIsSubmitted(false);
@ -251,7 +266,10 @@ export default function ContactPage() {
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2"> <label
htmlFor="firstName"
className="block text-sm font-medium text-gray-700 mb-2"
>
{t('form.firstName')} * {t('form.firstName')} *
</label> </label>
<input <input
@ -266,7 +284,10 @@ export default function ContactPage() {
/> />
</div> </div>
<div> <div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2"> <label
htmlFor="lastName"
className="block text-sm font-medium text-gray-700 mb-2"
>
{t('form.lastName')} * {t('form.lastName')} *
</label> </label>
<input <input
@ -284,7 +305,10 @@ export default function ContactPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2"> <label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-2"
>
{t('form.email')} * {t('form.email')} *
</label> </label>
<input <input
@ -299,7 +323,10 @@ export default function ContactPage() {
/> />
</div> </div>
<div> <div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2"> <label
htmlFor="phone"
className="block text-sm font-medium text-gray-700 mb-2"
>
{t('form.phone')} {t('form.phone')}
</label> </label>
<input <input
@ -315,7 +342,10 @@ export default function ContactPage() {
</div> </div>
<div> <div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-2"> <label
htmlFor="company"
className="block text-sm font-medium text-gray-700 mb-2"
>
{t('form.company')} {t('form.company')}
</label> </label>
<input <input
@ -330,7 +360,10 @@ export default function ContactPage() {
</div> </div>
<div> <div>
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2"> <label
htmlFor="subject"
className="block text-sm font-medium text-gray-700 mb-2"
>
{t('form.subject')} * {t('form.subject')} *
</label> </label>
<select <select
@ -342,7 +375,7 @@ export default function ContactPage() {
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all" className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
> >
<option value="">{t('subjects.placeholder')}</option> <option value="">{t('subjects.placeholder')}</option>
{SUBJECTS.map((key) => ( {SUBJECTS.map(key => (
<option key={key} value={key}> <option key={key} value={key}>
{t(`subjects.${key}`)} {t(`subjects.${key}`)}
</option> </option>
@ -351,7 +384,10 @@ export default function ContactPage() {
</div> </div>
<div> <div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2"> <label
htmlFor="message"
className="block text-sm font-medium text-gray-700 mb-2"
>
{t('form.message')} * {t('form.message')} *
</label> </label>
<textarea <textarea
@ -400,9 +436,7 @@ export default function ContactPage() {
transition={{ duration: 0.8, delay: 0.2 }} transition={{ duration: 0.8, delay: 0.2 }}
> >
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('office.title')}</h2> <h2 className="text-3xl font-bold text-brand-navy mb-6">{t('office.title')}</h2>
<p className="text-gray-600 mb-8"> <p className="text-gray-600 mb-8">{t('office.subtitle')}</p>
{t('office.subtitle')}
</p>
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-white p-6 rounded-2xl border-2 border-brand-turquoise shadow-lg"> <div className="bg-white p-6 rounded-2xl border-2 border-brand-turquoise shadow-lg">
@ -427,13 +461,19 @@ export default function ContactPage() {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Phone className="w-4 h-4 text-gray-400" /> <Phone className="w-4 h-4 text-gray-400" />
<a href={`tel:${t('office.phone').replace(/\s/g, '')}`} className="hover:text-brand-turquoise"> <a
href={`tel:${t('office.phone').replace(/\s/g, '')}`}
className="hover:text-brand-turquoise"
>
{t('office.phone')} {t('office.phone')}
</a> </a>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Mail className="w-4 h-4 text-gray-400" /> <Mail className="w-4 h-4 text-gray-400" />
<a href={`mailto:${t('office.email')}`} className="hover:text-brand-turquoise"> <a
href={`mailto:${t('office.email')}`}
className="hover:text-brand-turquoise"
>
{t('office.email')} {t('office.email')}
</a> </a>
</div> </div>
@ -463,9 +503,7 @@ export default function ContactPage() {
<span className="font-medium text-gray-400">{t('hours.closed')}</span> <span className="font-medium text-gray-400">{t('hours.closed')}</span>
</div> </div>
</div> </div>
<p className="mt-4 text-sm text-gray-500"> <p className="mt-4 text-sm text-gray-500">{t('hours.supportNote')}</p>
{t('hours.supportNote')}
</p>
</div> </div>
</motion.div> </motion.div>
</div> </div>
@ -511,7 +549,9 @@ export default function ContactPage() {
<div className="w-10 h-10 bg-brand-turquoise/30 rounded-xl flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 bg-brand-turquoise/30 rounded-xl flex items-center justify-center flex-shrink-0">
<CheckCircle2 className="w-5 h-5 text-brand-turquoise" /> <CheckCircle2 className="w-5 h-5 text-brand-turquoise" />
</div> </div>
<h3 className="text-lg font-bold text-white">{t('afterSubmit.commitmentTitle')}</h3> <h3 className="text-lg font-bold text-white">
{t('afterSubmit.commitmentTitle')}
</h3>
</div> </div>
<p className="text-white/80 leading-relaxed"> <p className="text-white/80 leading-relaxed">
{t('afterSubmit.commitmentBody1')} {t('afterSubmit.commitmentBody1')}
@ -532,11 +572,16 @@ export default function ContactPage() {
<div className="w-10 h-10 bg-brand-green/30 rounded-xl flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 bg-brand-green/30 rounded-xl flex items-center justify-center flex-shrink-0">
<Shield className="w-5 h-5 text-brand-green" /> <Shield className="w-5 h-5 text-brand-green" />
</div> </div>
<h3 className="text-lg font-bold text-white">{t('afterSubmit.securityTitle')}</h3> <h3 className="text-lg font-bold text-white">
{t('afterSubmit.securityTitle')}
</h3>
</div> </div>
<p className="text-white/80 leading-relaxed"> <p className="text-white/80 leading-relaxed">
{t('afterSubmit.securityBody1')} {t('afterSubmit.securityBody1')}
<Link href="/privacy" className="text-brand-turquoise font-semibold hover:underline"> <Link
href="/privacy"
className="text-brand-turquoise font-semibold hover:underline"
>
{t('afterSubmit.privacyLink')} {t('afterSubmit.privacyLink')}
</Link> </Link>
{t('afterSubmit.securityBody2')} {t('afterSubmit.securityBody2')}
@ -577,10 +622,14 @@ export default function ContactPage() {
<div className="w-14 h-14 bg-gradient-to-br from-brand-turquoise to-cyan-400 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0"> <div className="w-14 h-14 bg-gradient-to-br from-brand-turquoise to-cyan-400 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
<Zap className="w-7 h-7 text-white" /> <Zap className="w-7 h-7 text-white" />
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t('quickAccess.pricingTitle')}</h3> <h3 className="text-xl font-bold text-brand-navy mb-3">
{t('quickAccess.pricingTitle')}
</h3>
<p className="text-gray-600 leading-relaxed flex-1 mb-6"> <p className="text-gray-600 leading-relaxed flex-1 mb-6">
{t('quickAccess.pricingBody1')} {t('quickAccess.pricingBody1')}
<span className="font-semibold text-brand-navy">{t('quickAccess.pricingHighlight')}</span> <span className="font-semibold text-brand-navy">
{t('quickAccess.pricingHighlight')}
</span>
{t('quickAccess.pricingBody2')} {t('quickAccess.pricingBody2')}
</p> </p>
<Link <Link
@ -603,10 +652,14 @@ export default function ContactPage() {
<div className="w-14 h-14 bg-gradient-to-br from-brand-navy to-brand-navy/80 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0"> <div className="w-14 h-14 bg-gradient-to-br from-brand-navy to-brand-navy/80 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
<BookOpen className="w-7 h-7 text-brand-turquoise" /> <BookOpen className="w-7 h-7 text-brand-turquoise" />
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t('quickAccess.wikiTitle')}</h3> <h3 className="text-xl font-bold text-brand-navy mb-3">
{t('quickAccess.wikiTitle')}
</h3>
<p className="text-gray-600 leading-relaxed flex-1 mb-6"> <p className="text-gray-600 leading-relaxed flex-1 mb-6">
{t('quickAccess.wikiBody1')} {t('quickAccess.wikiBody1')}
<span className="font-semibold text-brand-navy">{t('quickAccess.wikiHighlight')}</span> <span className="font-semibold text-brand-navy">
{t('quickAccess.wikiHighlight')}
</span>
{t('quickAccess.wikiBody2')} {t('quickAccess.wikiBody2')}
</p> </p>
<Link <Link

View File

@ -3,7 +3,16 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Cookie, Settings, BarChart3, Target, Shield, ToggleLeft, Mail, type LucideIcon } from 'lucide-react'; import {
Cookie,
Settings,
BarChart3,
Target,
Shield,
ToggleLeft,
Mail,
type LucideIcon,
} from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout'; import { LandingHeader, LandingFooter } from '@/components/layout';
type CookieTypeKey = 'essential' | 'analytics' | 'marketing' | 'functional'; type CookieTypeKey = 'essential' | 'analytics' | 'marketing' | 'functional';
@ -99,7 +108,10 @@ export default function CookiesPage() {
<LandingHeader /> <LandingHeader />
{/* Hero Section */} {/* Hero Section */}
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"> <section
ref={heroRef}
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
>
<div className="absolute inset-0 opacity-10"> <div className="absolute inset-0 opacity-10">
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" /> <div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" /> <div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
@ -184,7 +196,7 @@ export default function CookiesPage() {
animate={isContentInView ? 'visible' : 'hidden'} animate={isContentInView ? 'visible' : 'hidden'}
className="space-y-8" className="space-y-8"
> >
{COOKIE_TYPES.map((type) => { {COOKIE_TYPES.map(type => {
const IconComponent = type.icon; const IconComponent = type.icon;
return ( return (
<motion.div <motion.div
@ -234,9 +246,11 @@ export default function CookiesPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{type.cookies.map((cookie) => ( {type.cookies.map(cookie => (
<tr key={cookie.name} className="border-b border-gray-100 last:border-0"> <tr key={cookie.name} className="border-b border-gray-100 last:border-0">
<td className="py-3 px-4 font-mono text-brand-turquoise">{cookie.name}</td> <td className="py-3 px-4 font-mono text-brand-turquoise">
{cookie.name}
</td>
<td className="py-3 px-4 text-gray-600"> <td className="py-3 px-4 text-gray-600">
{t(`purposes.${cookie.purposeKey}` as any)} {t(`purposes.${cookie.purposeKey}` as any)}
</td> </td>

View File

@ -0,0 +1,678 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import {
getAllBlogPosts,
createBlogPost,
updateBlogPost,
deleteBlogPost,
type CreateBlogPostRequest,
type UpdateBlogPostRequest,
} from '@/lib/api/admin';
import { upload } from '@/lib/api/client';
import type { BlogPost, BlogPostCategory, BlogPostStatus } from '@/lib/api/blog';
import { PageHeader } from '@/components/ui/PageHeader';
import { RichTextEditor } from '@/components/blog/RichTextEditor';
import {
Plus,
Pencil,
Trash2,
Eye,
EyeOff,
Star,
StarOff,
ExternalLink,
ImageIcon,
X,
Loader2,
} from 'lucide-react';
import { Link } from '@/i18n/navigation';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
function normalizeImageUrl(url: string | null | undefined): string {
if (!url) return '';
if (url.startsWith('http')) return url;
return `${API_BASE_URL}${url}`;
}
function normalizeContentUrls(html: string): string {
return html.replace(/src="(\/api\/v1\/blog\/images\/[^"]+)"/g, `src="${API_BASE_URL}$1"`);
}
const CATEGORIES: { value: BlogPostCategory; label: string }[] = [
{ value: 'industry', label: 'Industrie' },
{ value: 'technology', label: 'Technologie' },
{ value: 'guides', label: 'Guides' },
{ value: 'news', label: 'Actualités' },
];
const STATUS_LABELS: Record<BlogPostStatus, string> = {
draft: 'Brouillon',
published: 'Publié',
archived: 'Archivé',
};
const STATUS_COLORS: Record<BlogPostStatus, string> = {
draft: 'bg-yellow-100 text-yellow-800',
published: 'bg-green-100 text-green-800',
archived: 'bg-gray-100 text-gray-600',
};
const EMPTY_FORM: CreateBlogPostRequest = {
title: '',
slug: '',
excerpt: '',
content: '',
coverImageUrl: '',
category: 'industry',
tags: [],
authorName: '',
};
function generateSlug(title: string): string {
return title
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/\s+/g, '-');
}
export default function AdminBlogPage() {
const [posts, setPosts] = useState<BlogPost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedPost, setSelectedPost] = useState<BlogPost | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [saving, setSaving] = useState(false);
const [uploadingCover, setUploadingCover] = useState(false);
const [tagsInput, setTagsInput] = useState('');
const [editStatus, setEditStatus] = useState<BlogPostStatus>('draft');
const [formData, setFormData] = useState<CreateBlogPostRequest>(EMPTY_FORM);
const coverInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetchPosts();
}, []);
const fetchPosts = async () => {
try {
setLoading(true);
const res = await getAllBlogPosts();
setPosts(res.posts);
setError(null);
} catch (err: any) {
setError(err.message || 'Erreur lors du chargement des articles');
} finally {
setLoading(false);
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await createBlogPost({
...formData,
tags: tagsInput
? tagsInput
.split(',')
.map(t => t.trim())
.filter(Boolean)
: [],
});
await fetchPosts();
closeModal();
} catch (err: any) {
alert(err.message || 'Erreur lors de la création');
} finally {
setSaving(false);
}
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedPost) return;
setSaving(true);
try {
const data: UpdateBlogPostRequest = {
...formData,
status: editStatus,
tags: tagsInput
? tagsInput
.split(',')
.map(t => t.trim())
.filter(Boolean)
: [],
};
await updateBlogPost(selectedPost.id, data);
await fetchPosts();
closeModal();
} catch (err: any) {
alert(err.message || 'Erreur lors de la mise à jour');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!selectedPost) return;
try {
await deleteBlogPost(selectedPost.id);
await fetchPosts();
setShowDeleteConfirm(false);
setSelectedPost(null);
} catch (err: any) {
alert(err.message || 'Erreur lors de la suppression');
}
};
const handleToggleStatus = async (post: BlogPost) => {
const nextStatus: BlogPostStatus = post.status === 'published' ? 'draft' : 'published';
try {
await updateBlogPost(post.id, { status: nextStatus });
await fetchPosts();
} catch (err: any) {
alert(err.message || 'Erreur lors du changement de statut');
}
};
const handleToggleFeatured = async (post: BlogPost) => {
try {
await updateBlogPost(post.id, { isFeatured: !post.isFeatured });
await fetchPosts();
} catch (err: any) {
alert(err.message || 'Erreur lors du changement');
}
};
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
alert('Image trop volumineuse (max 5 Mo)');
return;
}
setUploadingCover(true);
try {
const fd = new FormData();
fd.append('image', file);
const result = await upload<{ url: string; filename: string }>(
'/api/v1/admin/blog/images',
fd
);
const coverUrl = result.url.startsWith('http') ? result.url : `${API_BASE_URL}${result.url}`;
setFormData(prev => ({ ...prev, coverImageUrl: coverUrl }));
} catch (err: any) {
alert(err.message || "Erreur lors de l'upload");
} finally {
setUploadingCover(false);
if (coverInputRef.current) coverInputRef.current.value = '';
}
};
const openCreate = () => {
setFormData(EMPTY_FORM);
setTagsInput('');
setEditStatus('draft');
setSelectedPost(null);
setShowCreateModal(true);
};
const openEdit = (post: BlogPost) => {
setSelectedPost(post);
setFormData({
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
content: normalizeContentUrls(post.content),
coverImageUrl: normalizeImageUrl(post.coverImageUrl),
category: post.category,
tags: post.tags,
authorName: post.authorName,
});
setTagsInput(post.tags.join(', '));
setEditStatus(post.status);
setShowEditModal(true);
};
const closeModal = () => {
setShowCreateModal(false);
setShowEditModal(false);
setSelectedPost(null);
setFormData(EMPTY_FORM);
setTagsInput('');
};
const handleTitleChange = (title: string) => {
setFormData(prev => ({
...prev,
title,
slug:
prev.slug === generateSlug(prev.title) || prev.slug === ''
? generateSlug(title)
: prev.slug,
}));
};
const isOpen = showCreateModal || showEditModal;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<PageHeader
title="Gestion du Blog"
description="Créez, modifiez et publiez les articles du blog"
actions={
<button
onClick={openCreate}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
>
<Plus className="w-4 h-4" />
<span>Nouvel article</span>
</button>
}
/>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error}
</div>
)}
{loading ? (
<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">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Article
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Catégorie
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Auteur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{posts.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
Aucun article. Créez votre premier article !
</td>
</tr>
) : (
posts.map(post => (
<tr key={post.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4">
<div className="flex items-start space-x-3">
{post.coverImageUrl && (
<img
src={post.coverImageUrl}
alt=""
className="w-12 h-12 object-cover rounded flex-shrink-0"
/>
)}
<div>
<div className="flex items-center gap-2">
{post.isFeatured && (
<Star className="w-3.5 h-3.5 text-yellow-500 flex-shrink-0" />
)}
<span className="font-medium text-gray-900 line-clamp-1">
{post.title}
</span>
</div>
<div className="text-xs text-gray-400 font-mono mt-0.5">{post.slug}</div>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{CATEGORIES.find(c => c.value === post.category)?.label ?? post.category}
</td>
<td className="px-6 py-4">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${STATUS_COLORS[post.status]}`}
>
{STATUS_LABELS[post.status]}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600">{post.authorName}</td>
<td className="px-6 py-4 text-sm text-gray-500">
{post.publishedAt
? new Date(post.publishedAt).toLocaleDateString('fr-FR')
: new Date(post.createdAt).toLocaleDateString('fr-FR')}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end space-x-1">
{post.status === 'published' && (
<Link href={`/blog/${post.slug}`} target="_blank">
<button
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors"
title="Voir"
>
<ExternalLink className="w-4 h-4" />
</button>
</Link>
)}
<button
onClick={() => handleToggleFeatured(post)}
className="p-1.5 text-gray-400 hover:text-yellow-500 transition-colors"
title={post.isFeatured ? 'Retirer de la une' : 'Mettre à la une'}
>
{post.isFeatured ? (
<StarOff className="w-4 h-4" />
) : (
<Star className="w-4 h-4" />
)}
</button>
<button
onClick={() => handleToggleStatus(post)}
className="p-1.5 text-gray-400 hover:text-green-600 transition-colors"
title={post.status === 'published' ? 'Dépublier' : 'Publier'}
>
{post.status === 'published' ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
<button
onClick={() => openEdit(post)}
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors"
title="Modifier"
>
<Pencil 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"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* Create / Edit Modal — Full-screen overlay */}
{isOpen && (
<div className="fixed inset-0 z-50 bg-gray-50 overflow-y-auto">
{/* 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">
<h2 className="text-lg font-semibold text-gray-900">
{showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`}
</h2>
<div className="flex items-center gap-3">
{showEditModal && (
<select
value={editStatus}
onChange={e => setEditStatus(e.target.value as BlogPostStatus)}
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="draft">Brouillon</option>
<option value="published">Publié</option>
<option value="archived">Archivé</option>
</select>
)}
<button
type="button"
onClick={closeModal}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Body */}
<form
onSubmit={showCreateModal ? handleCreate : handleUpdate}
className="max-w-6xl mx-auto px-6 py-8 grid grid-cols-1 lg:grid-cols-3 gap-8"
>
{/* Main editor — 2/3 */}
<div className="lg:col-span-2 space-y-6">
{/* Title */}
<div>
<input
type="text"
required
value={formData.title}
onChange={e => handleTitleChange(e.target.value)}
className="w-full text-3xl font-bold text-gray-900 border-0 border-b-2 border-gray-200 focus:border-blue-500 focus:outline-none pb-2 placeholder-gray-300 bg-transparent"
placeholder="Titre de l'article..."
/>
</div>
{/* Excerpt */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Extrait *</label>
<textarea
required
rows={2}
value={formData.excerpt}
onChange={e => setFormData(prev => ({ ...prev, excerpt: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none text-sm resize-none"
placeholder="Courte description visible dans la liste des articles..."
/>
</div>
{/* Rich Text Editor */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Contenu *</label>
<RichTextEditor
content={formData.content}
onChange={html => setFormData(prev => ({ ...prev, content: html }))}
placeholder="Commencez à écrire votre article..."
minHeight={500}
/>
</div>
</div>
{/* Sidebar — 1/3 */}
<div className="space-y-5">
{/* Publish button */}
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
<h3 className="font-semibold text-gray-900 mb-4">Publication</h3>
<button
type="submit"
disabled={saving}
className="w-full py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{saving
? 'Enregistrement...'
: showCreateModal
? 'Créer le brouillon'
: 'Enregistrer'}
</button>
{showCreateModal && (
<p className="text-xs text-gray-400 text-center mt-2">
Créé en brouillon. Publiez depuis la liste.
</p>
)}
</div>
{/* Cover image */}
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
<h3 className="font-semibold text-gray-900 mb-3">Image de couverture</h3>
{formData.coverImageUrl ? (
<div className="relative">
<img
src={formData.coverImageUrl}
alt="Couverture"
className="w-full h-40 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, coverImageUrl: '' }))}
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow-md text-gray-600 hover:text-red-600 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<button
type="button"
onClick={() => coverInputRef.current?.click()}
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"
>
{uploadingCover ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : (
<>
<ImageIcon className="w-6 h-6" />
<span className="text-sm">Cliquer pour uploader</span>
<span className="text-xs">JPG, PNG, WebP max 5 Mo</span>
</>
)}
</button>
)}
<input
ref={coverInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={handleCoverUpload}
/>
{/* Also allow URL */}
<div className="mt-3">
<input
type="url"
value={formData.coverImageUrl ?? ''}
onChange={e =>
setFormData(prev => ({ ...prev, coverImageUrl: e.target.value }))
}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Ou coller une URL..."
/>
</div>
</div>
{/* Metadata */}
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
<h3 className="font-semibold text-gray-900">Métadonnées</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Slug *</label>
<input
type="text"
required
value={formData.slug}
onChange={e => setFormData(prev => ({ ...prev, slug: 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 font-mono"
placeholder="mon-article"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Catégorie *
</label>
<select
required
value={formData.category}
onChange={e =>
setFormData(prev => ({
...prev,
category: e.target.value as BlogPostCategory,
}))
}
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"
>
{CATEGORIES.map(c => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Auteur *</label>
<input
type="text"
required
value={formData.authorName}
onChange={e => setFormData(prev => ({ ...prev, authorName: 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="Nom de l'auteur"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tags <span className="text-gray-400 font-normal">(virgule)</span>
</label>
<input
type="text"
value={tagsInput}
onChange={e => setTagsInput(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="Maritime, LCL, Export"
/>
</div>
</div>
</div>
</form>
</div>
)}
{/* Delete Confirm */}
{showDeleteConfirm && selectedPost && (
<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">
<h2 className="text-lg font-semibold text-gray-900 mb-2">Supprimer l&apos;article</h2>
<p className="text-gray-600 mb-6">
Êtes-vous sûr de vouloir supprimer <strong>«&nbsp;{selectedPost.title}&nbsp;»</strong>{' '}
? Cette action est irréversible.
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => {
setShowDeleteConfirm(false);
setSelectedPost(null);
}}
className="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Annuler
</button>
<button
onClick={handleDelete}
className="px-6 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
>
Supprimer
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -100,7 +100,14 @@ export default function AdminBookingsPage() {
const getStatusLabel = (status: string) => { const getStatusLabel = (status: string) => {
const key = status.toUpperCase(); const key = status.toUpperCase();
const allowed = ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED']; const allowed = [
'PENDING_PAYMENT',
'PENDING_BANK_TRANSFER',
'PENDING',
'ACCEPTED',
'REJECTED',
'CANCELLED',
];
if (allowed.includes(key)) { if (allowed.includes(key)) {
return t(`status.${key}` as any); return t(`status.${key}` as any);
} }
@ -143,9 +150,7 @@ export default function AdminBookingsPage() {
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">{t('title')}</h1> <h1 className="text-2xl font-bold text-gray-900">{t('title')}</h1>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">{t('subtitle')}</p>
{t('subtitle')}
</p>
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
@ -155,13 +160,17 @@ export default function AdminBookingsPage() {
<div className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div> <div className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div>
</div> </div>
<div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4"> <div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4">
<div className="text-xs text-amber-700 uppercase tracking-wide">{t('stats.pendingBankTransfer')}</div> <div className="text-xs text-amber-700 uppercase tracking-wide">
{t('stats.pendingBankTransfer')}
</div>
<div className="text-2xl font-bold text-amber-700 mt-1"> <div className="text-2xl font-bold text-amber-700 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length} {bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
</div> </div>
</div> </div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.pendingCarrier')}</div> <div className="text-xs text-gray-500 uppercase tracking-wide">
{t('stats.pendingCarrier')}
</div>
<div className="text-2xl font-bold text-yellow-600 mt-1"> <div className="text-2xl font-bold text-yellow-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length} {bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
</div> </div>
@ -184,7 +193,9 @@ export default function AdminBookingsPage() {
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('search.label')}</label> <label className="block text-sm font-medium text-gray-700 mb-1">
{t('search.label')}
</label>
<input <input
type="text" type="text"
placeholder={t('search.placeholder')} placeholder={t('search.placeholder')}
@ -194,7 +205,9 @@ export default function AdminBookingsPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('filter.label')}</label> <label className="block text-sm font-medium text-gray-700 mb-1">
{t('filter.label')}
</label>
<select <select
value={filterStatus} value={filterStatus}
onChange={e => setFilterStatus(e.target.value)} onChange={e => setFilterStatus(e.target.value)}
@ -261,7 +274,9 @@ export default function AdminBookingsPage() {
{/* N° Booking */} {/* N° Booking */}
<td className="px-4 py-4 whitespace-nowrap"> <td className="px-4 py-4 whitespace-nowrap">
{booking.bookingNumber && ( {booking.bookingNumber && (
<div className="text-sm font-semibold text-gray-900">{booking.bookingNumber}</div> <div className="text-sm font-semibold text-gray-900">
{booking.bookingNumber}
</div>
)} )}
<div className="text-xs text-gray-400 font-mono">{getShortId(booking)}</div> <div className="text-xs text-gray-400 font-mono">{getShortId(booking)}</div>
</td> </td>
@ -278,11 +293,15 @@ export default function AdminBookingsPage() {
<div className="text-sm text-gray-900"> <div className="text-sm text-gray-900">
{booking.containerType} {booking.containerType}
{booking.palletCount != null && ( {booking.palletCount != null && (
<span className="ml-1 text-gray-500">· {booking.palletCount} {t('table.pallets')}</span> <span className="ml-1 text-gray-500">
· {booking.palletCount} {t('table.pallets')}
</span>
)} )}
</div> </div>
<div className="text-xs text-gray-500 space-x-2"> <div className="text-xs text-gray-500 space-x-2">
{booking.weightKG != null && <span>{booking.weightKG.toLocaleString(dateLocale)} kg</span>} {booking.weightKG != null && (
<span>{booking.weightKG.toLocaleString(dateLocale)} kg</span>
)}
{booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>} {booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>}
</div> </div>
</td> </td>
@ -294,20 +313,24 @@ export default function AdminBookingsPage() {
{/* Statut */} {/* Statut */}
<td className="px-4 py-4 whitespace-nowrap"> <td className="px-4 py-4 whitespace-nowrap">
<span className={`px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}> <span
className={`px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}
>
{getStatusLabel(booking.status)} {getStatusLabel(booking.status)}
</span> </span>
</td> </td>
{/* Date */} {/* Date */}
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString(dateLocale)} {new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString(
dateLocale
)}
</td> </td>
{/* Actions */} {/* Actions */}
<td className="px-4 py-4 whitespace-nowrap text-right text-sm"> <td className="px-4 py-4 whitespace-nowrap text-right text-sm">
<button <button
onClick={(e) => { onClick={e => {
if (openMenuId === booking.id) { if (openMenuId === booking.id) {
setOpenMenuId(null); setOpenMenuId(null);
setMenuPosition(null); setMenuPosition(null);
@ -319,7 +342,11 @@ export default function AdminBookingsPage() {
}} }}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
> >
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20"> <svg
className="w-5 h-5 text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" /> <path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg> </svg>
</button> </button>
@ -336,7 +363,10 @@ export default function AdminBookingsPage() {
<> <>
<div <div
className="fixed inset-0 z-[998]" className="fixed inset-0 z-[998]"
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }} onClick={() => {
setOpenMenuId(null);
setMenuPosition(null);
}}
/> />
<div <div
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]" className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
@ -355,9 +385,24 @@ export default function AdminBookingsPage() {
}} }}
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200" className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
> >
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> className="w-5 h-5 text-blue-600"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg> </svg>
<span className="text-sm font-medium text-gray-700">{t('menu.viewDetails')}</span> <span className="text-sm font-medium text-gray-700">{t('menu.viewDetails')}</span>
</button> </button>
@ -374,10 +419,22 @@ export default function AdminBookingsPage() {
disabled={validatingId === openMenuId} disabled={validatingId === openMenuId}
className="w-full px-4 py-3 text-left hover:bg-green-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-gray-200" className="w-full px-4 py-3 text-left hover:bg-green-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-gray-200"
> >
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> className="w-5 h-5 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<span className="text-sm font-medium text-green-700">{t('menu.validateTransfer')}</span> <span className="text-sm font-medium text-green-700">
{t('menu.validateTransfer')}
</span>
</button> </button>
) : null; ) : null;
})()} })()}
@ -391,8 +448,18 @@ export default function AdminBookingsPage() {
disabled={deletingId === openMenuId} disabled={deletingId === openMenuId}
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3" className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
> >
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> className="w-5 h-5 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg> </svg>
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span> <span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
</button> </button>
@ -408,11 +475,19 @@ export default function AdminBookingsPage() {
<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
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }} onClick={() => {
setShowDetailsModal(false);
setSelectedBooking(null);
}}
className="text-gray-400 hover:text-gray-600" className="text-gray-400 hover:text-gray-600"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
</div> </div>
@ -420,60 +495,98 @@ export default function AdminBookingsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.bookingNumber')}</label> <label className="block text-sm font-medium text-gray-500">
{t('modal.bookingNumber')}
</label>
<div className="mt-1 text-lg font-semibold text-gray-900"> <div className="mt-1 text-lg font-semibold text-gray-900">
{selectedBooking.bookingNumber || getShortId(selectedBooking)} {selectedBooking.bookingNumber || getShortId(selectedBooking)}
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.status')}</label> <label className="block text-sm font-medium text-gray-500">
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}> {t('modal.status')}
</label>
<span
className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}
>
{getStatusLabel(selectedBooking.status)} {getStatusLabel(selectedBooking.status)}
</span> </span>
</div> </div>
</div> </div>
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.routeSection')}</h3> <h3 className="text-sm font-medium text-gray-900 mb-3">
{t('modal.routeSection')}
</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.origin')}</label> <label className="block text-sm font-medium text-gray-500">
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || t('modal.none')}</div> {t('modal.origin')}
</label>
<div className="mt-1 font-semibold text-gray-900">
{selectedBooking.origin || t('modal.none')}
</div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.destination')}</label> <label className="block text-sm font-medium text-gray-500">
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || t('modal.none')}</div> {t('modal.destination')}
</label>
<div className="mt-1 font-semibold text-gray-900">
{selectedBooking.destination || t('modal.none')}
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.cargoSection')}</h3> <h3 className="text-sm font-medium text-gray-900 mb-3">
{t('modal.cargoSection')}
</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.carrier')}</label> <label className="block text-sm font-medium text-gray-500">
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || t('modal.none')}</div> {t('modal.carrier')}
</label>
<div className="mt-1 font-semibold text-gray-900">
{selectedBooking.carrierName || t('modal.none')}
</div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.containerType')}</label> <label className="block text-sm font-medium text-gray-500">
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.containerType}</div> {t('modal.containerType')}
</label>
<div className="mt-1 font-semibold text-gray-900">
{selectedBooking.containerType}
</div>
</div> </div>
{selectedBooking.palletCount != null && ( {selectedBooking.palletCount != null && (
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.pallets')}</label> <label className="block text-sm font-medium text-gray-500">
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.palletCount}</div> {t('modal.pallets')}
</label>
<div className="mt-1 font-semibold text-gray-900">
{selectedBooking.palletCount}
</div>
</div> </div>
)} )}
{selectedBooking.weightKG != null && ( {selectedBooking.weightKG != null && (
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.weight')}</label> <label className="block text-sm font-medium text-gray-500">
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString(dateLocale)} kg</div> {t('modal.weight')}
</label>
<div className="mt-1 font-semibold text-gray-900">
{selectedBooking.weightKG.toLocaleString(dateLocale)} kg
</div>
</div> </div>
)} )}
{selectedBooking.volumeCBM != null && ( {selectedBooking.volumeCBM != null && (
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.volume')}</label> <label className="block text-sm font-medium text-gray-500">
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.volumeCBM} CBM</div> {t('modal.volume')}
</label>
<div className="mt-1 font-semibold text-gray-900">
{selectedBooking.volumeCBM} CBM
</div>
</div> </div>
)} )}
</div> </div>
@ -481,18 +594,24 @@ export default function AdminBookingsPage() {
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && ( {(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.priceSection')}</h3> <h3 className="text-sm font-medium text-gray-900 mb-3">
{t('modal.priceSection')}
</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{selectedBooking.priceEUR != null && ( {selectedBooking.priceEUR != null && (
<div> <div>
<label className="block text-sm font-medium text-gray-500">EUR</label> <label className="block text-sm font-medium text-gray-500">EUR</label>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceEUR.toLocaleString(dateLocale)} </div> <div className="mt-1 text-xl font-bold text-blue-600">
{selectedBooking.priceEUR.toLocaleString(dateLocale)}
</div>
</div> </div>
)} )}
{selectedBooking.priceUSD != null && ( {selectedBooking.priceUSD != null && (
<div> <div>
<label className="block text-sm font-medium text-gray-500">USD</label> <label className="block text-sm font-medium text-gray-500">USD</label>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceUSD.toLocaleString(dateLocale)} $</div> <div className="mt-1 text-xl font-bold text-blue-600">
{selectedBooking.priceUSD.toLocaleString(dateLocale)} $
</div>
</div> </div>
)} )}
</div> </div>
@ -500,18 +619,24 @@ export default function AdminBookingsPage() {
)} )}
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.datesSection')}</h3> <h3 className="text-sm font-medium text-gray-900 mb-3">
{t('modal.datesSection')}
</h3>
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<label className="block text-gray-500">{t('modal.createdAt')}</label> <label className="block text-gray-500">{t('modal.createdAt')}</label>
<div className="mt-1 text-gray-900"> <div className="mt-1 text-gray-900">
{new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString(dateLocale)} {new Date(
selectedBooking.requestedAt || selectedBooking.createdAt || ''
).toLocaleString(dateLocale)}
</div> </div>
</div> </div>
{selectedBooking.updatedAt && ( {selectedBooking.updatedAt && (
<div> <div>
<label className="block text-gray-500">{t('modal.updatedAt')}</label> <label className="block text-gray-500">{t('modal.updatedAt')}</label>
<div className="mt-1 text-gray-900">{new Date(selectedBooking.updatedAt).toLocaleString(dateLocale)}</div> <div className="mt-1 text-gray-900">
{new Date(selectedBooking.updatedAt).toLocaleString(dateLocale)}
</div>
</div> </div>
)} )}
</div> </div>
@ -528,7 +653,9 @@ export default function AdminBookingsPage() {
disabled={validatingId === selectedBooking.id} disabled={validatingId === selectedBooking.id}
className="w-full px-4 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed" className="w-full px-4 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{validatingId === selectedBooking.id ? t('modal.validating') : t('modal.validateButton')} {validatingId === selectedBooking.id
? t('modal.validating')
: t('modal.validateButton')}
</button> </button>
</div> </div>
)} )}
@ -536,7 +663,10 @@ export default function AdminBookingsPage() {
<div className="flex justify-end mt-6 pt-4 border-t"> <div className="flex justify-end mt-6 pt-4 border-t">
<button <button
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }} onClick={() => {
setShowDetailsModal(false);
setSelectedBooking(null);
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50" className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
> >
{t('modal.close')} {t('modal.close')}

View File

@ -73,9 +73,7 @@ export default function AdminCsvRatesPage() {
{/* Page Header */} {/* Page Header */}
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1> <h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">{t('subtitle')}</p>
{t('subtitle')}
</p>
<Badge variant="destructive" className="mt-2"> <Badge variant="destructive" className="mt-2">
{t('adminBadge')} {t('adminBadge')}
</Badge> </Badge>
@ -90,9 +88,7 @@ export default function AdminCsvRatesPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle>{t('cardTitle')}</CardTitle> <CardTitle>{t('cardTitle')}</CardTitle>
<CardDescription> <CardDescription>{t('cardDescription')}</CardDescription>
{t('cardDescription')}
</CardDescription>
</div> </div>
<Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}> <Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}>
{loading ? ( {loading ? (
@ -115,9 +111,7 @@ export default function AdminCsvRatesPage() {
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div> </div>
) : files.length === 0 ? ( ) : files.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-muted-foreground">{t('empty')}</div>
{t('empty')}
</div>
) : ( ) : (
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
@ -132,15 +126,17 @@ export default function AdminCsvRatesPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{files.map((file) => ( {files.map(file => (
<TableRow key={file.filename}> <TableRow key={file.filename}>
<TableCell className="font-medium font-mono text-xs">{file.filename}</TableCell> <TableCell className="font-medium font-mono text-xs">
<TableCell> {file.filename}
{(file.size / 1024).toFixed(2)} KB
</TableCell> </TableCell>
<TableCell>{(file.size / 1024).toFixed(2)} KB</TableCell>
<TableCell> <TableCell>
{file.rowCount ? ( {file.rowCount ? (
<span className="font-semibold">{t('table.rowCount', { count: file.rowCount })}</span> <span className="font-semibold">
{t('table.rowCount', { count: file.rowCount })}
</span>
) : ( ) : (
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
)} )}

View File

@ -171,12 +171,16 @@ export default function AdminDocumentsPage() {
const uniqueUsers = Array.from( const uniqueUsers = Array.from(
new Map( new Map(
documents.map(doc => [doc.userId, { id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' }]) documents.map(doc => [
doc.userId,
{ id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' },
])
).values() ).values()
); );
const filteredDocuments = documents.filter(doc => { const filteredDocuments = documents.filter(doc => {
const matchesSearch = searchTerm === '' || const matchesSearch =
searchTerm === '' ||
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) || (doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
(doc.name && doc.name.toLowerCase().includes(searchTerm.toLowerCase())) || (doc.name && doc.name.toLowerCase().includes(searchTerm.toLowerCase())) ||
(doc.type && doc.type.toLowerCase().includes(searchTerm.toLowerCase())) || (doc.type && doc.type.toLowerCase().includes(searchTerm.toLowerCase())) ||
@ -184,7 +188,8 @@ export default function AdminDocumentsPage() {
const matchesUser = filterUserId === 'all' || doc.userId === filterUserId; const matchesUser = filterUserId === 'all' || doc.userId === filterUserId;
const matchesQuote = filterQuoteNumber === '' || const matchesQuote =
filterQuoteNumber === '' ||
doc.quoteNumber.toLowerCase().includes(filterQuoteNumber.toLowerCase()); doc.quoteNumber.toLowerCase().includes(filterQuoteNumber.toLowerCase());
return matchesSearch && matchesUser && matchesQuote; return matchesSearch && matchesUser && matchesQuote;
@ -201,7 +206,7 @@ export default function AdminDocumentsPage() {
const getDocumentIcon = (type: string): ReactNode => { const getDocumentIcon = (type: string): ReactNode => {
const typeLower = type.toLowerCase(); const typeLower = type.toLowerCase();
const cls = "h-6 w-6"; const cls = 'h-6 w-6';
const iconMap: Record<string, ReactNode> = { const iconMap: Record<string, ReactNode> = {
'application/pdf': <FileText className={`${cls} text-red-500`} />, 'application/pdf': <FileText className={`${cls} text-red-500`} />,
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />, 'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
@ -304,10 +309,7 @@ export default function AdminDocumentsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader title={t('title')} description={t('subtitle')} />
title={t('title')}
description={t('subtitle')}
/>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@ -437,7 +439,9 @@ export default function AdminDocumentsPage() {
<div className="text-sm text-gray-900">{doc.route}</div> <div className="text-sm text-gray-900">{doc.route}</div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}> <span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}
>
{doc.status} {doc.status}
</span> </span>
</td> </td>
@ -448,7 +452,7 @@ export default function AdminDocumentsPage() {
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right"> <td className="px-6 py-4 whitespace-nowrap text-right">
<button <button
onClick={(e) => { onClick={e => {
const menuKey = `${doc.bookingId}::${doc.id}`; const menuKey = `${doc.bookingId}::${doc.id}`;
if (openMenuId === menuKey) { if (openMenuId === menuKey) {
setOpenMenuId(null); setOpenMenuId(null);
@ -461,7 +465,11 @@ export default function AdminDocumentsPage() {
}} }}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
> >
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20"> <svg
className="w-5 h-5 text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" /> <path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg> </svg>
</button> </button>
@ -494,9 +502,14 @@ export default function AdminDocumentsPage() {
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> <div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div> <div>
<p className="text-sm text-gray-700"> <p className="text-sm text-gray-700">
{t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span> {t('pagination.to')}{' '} {t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span>{' '}
<span className="font-medium">{Math.min(endIndex, filteredDocuments.length)}</span> {t('pagination.on')}{' '} {t('pagination.to')}{' '}
<span className="font-medium">{filteredDocuments.length}</span> {t('pagination.results')} <span className="font-medium">
{Math.min(endIndex, filteredDocuments.length)}
</span>{' '}
{t('pagination.on')}{' '}
<span className="font-medium">{filteredDocuments.length}</span>{' '}
{t('pagination.results')}
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -504,7 +517,7 @@ export default function AdminDocumentsPage() {
<label className="text-sm text-gray-700">{t('pagination.perPage')}</label> <label className="text-sm text-gray-700">{t('pagination.perPage')}</label>
<select <select
value={itemsPerPage} value={itemsPerPage}
onChange={(e) => { onChange={e => {
setItemsPerPage(Number(e.target.value)); setItemsPerPage(Number(e.target.value));
setCurrentPage(1); setCurrentPage(1);
}} }}
@ -517,7 +530,10 @@ export default function AdminDocumentsPage() {
<option value={100}>100</option> <option value={100}>100</option>
</select> </select>
</div> </div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination"> <nav
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
<button <button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))} onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
@ -525,7 +541,11 @@ export default function AdminDocumentsPage() {
> >
<span className="sr-only">{t('pagination.previous')}</span> <span className="sr-only">{t('pagination.previous')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg> </svg>
</button> </button>
@ -564,7 +584,11 @@ export default function AdminDocumentsPage() {
> >
<span className="sr-only">{t('pagination.next')}</span> <span className="sr-only">{t('pagination.next')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg> </svg>
</button> </button>
</nav> </nav>
@ -578,7 +602,10 @@ export default function AdminDocumentsPage() {
<> <>
<div <div
className="fixed inset-0 z-[998]" className="fixed inset-0 z-[998]"
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }} onClick={() => {
setOpenMenuId(null);
setMenuPosition(null);
}}
/> />
<div <div
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]" className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
@ -595,14 +622,29 @@ export default function AdminDocumentsPage() {
onClick={() => { onClick={() => {
setOpenMenuId(null); setOpenMenuId(null);
setMenuPosition(null); setMenuPosition(null);
handleDownload(doc.filePath || doc.url || '', doc.fileName || doc.name || 'document'); handleDownload(
doc.filePath || doc.url || '',
doc.fileName || doc.name || 'document'
);
}} }}
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200" className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
> >
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> className="w-5 h-5 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg> </svg>
<span className="text-sm font-medium text-gray-700">{t('menu.download')}</span> <span className="text-sm font-medium text-gray-700">
{t('menu.download')}
</span>
</button> </button>
<button <button
onClick={() => { onClick={() => {
@ -615,8 +657,18 @@ export default function AdminDocumentsPage() {
disabled={deletingId === doc.id} disabled={deletingId === doc.id}
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3" className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
> >
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> className="w-5 h-5 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg> </svg>
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span> <span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
</button> </button>

View File

@ -72,7 +72,9 @@ const LEVEL_ROW_BG: Record<string, string> = {
function LevelBadge({ level }: { level: string }) { function LevelBadge({ level }: { level: string }) {
const style = LEVEL_STYLES[level] || 'bg-gray-100 text-gray-600'; const style = LEVEL_STYLES[level] || 'bg-gray-100 text-gray-600';
return ( return (
<span className={`inline-block px-2 py-0.5 rounded text-xs font-mono font-semibold uppercase ${style}`}> <span
className={`inline-block px-2 py-0.5 rounded text-xs font-mono font-semibold uppercase ${style}`}
>
{level} {level}
</span> </span>
); );
@ -148,16 +150,14 @@ export default function AdminLogsPage() {
if (fmt) params.set('format', fmt); if (fmt) params.set('format', fmt);
return params.toString(); return params.toString();
}, },
[filters], [filters]
); );
const fetchLogs = useCallback(async () => { const fetchLogs = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data = await get<LogsResponse>( const data = await get<LogsResponse>(`${LOGS_PREFIX}/export?${buildQueryString('json')}`);
`${LOGS_PREFIX}/export?${buildQueryString('json')}`,
);
setLogs(data.logs || []); setLogs(data.logs || []);
setTotal(data.total || 0); setTotal(data.total || 0);
} catch (err: any) { } catch (err: any) {
@ -175,10 +175,7 @@ export default function AdminLogsPage() {
setExportLoading(true); setExportLoading(true);
try { try {
const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`; const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`;
await download( await download(`${LOGS_PREFIX}/export?${buildQueryString(format)}`, filename);
`${LOGS_PREFIX}/export?${buildQueryString(format)}`,
filename,
);
} catch (err: any) { } catch (err: any) {
setError(err.message); setError(err.message);
} finally { } finally {
@ -187,8 +184,7 @@ export default function AdminLogsPage() {
}; };
// Stats // Stats
const countByLevel = (level: string) => const countByLevel = (level: string) => logs.filter(l => l.level === level).length;
logs.filter(l => l.level === level).length;
const setFilter = (key: keyof Filters, value: string) => const setFilter = (key: keyof Filters, value: string) =>
setFilters(prev => ({ ...prev, [key]: value })); setFilters(prev => ({ ...prev, [key]: value }));
@ -214,7 +210,9 @@ export default function AdminLogsPage() {
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-white bg-[#10183A] rounded-lg hover:bg-[#1a2550] transition-colors disabled:opacity-50" className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-white bg-[#10183A] rounded-lg hover:bg-[#1a2550] transition-colors disabled:opacity-50"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
<span className="hidden sm:inline">{exportLoading ? t('exporting') : t('export')}</span> <span className="hidden sm:inline">
{exportLoading ? t('exporting') : t('export')}
</span>
</button> </button>
<div className="absolute right-0 mt-1 w-36 bg-white rounded-lg shadow-lg border border-gray-200 z-10 hidden group-hover:block"> <div className="absolute right-0 mt-1 w-36 bg-white rounded-lg shadow-lg border border-gray-200 z-10 hidden group-hover:block">
<button <button
@ -272,7 +270,9 @@ export default function AdminLogsPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
{/* Service */} {/* Service */}
<div> <div>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.service')}</label> <label className="block text-xs font-medium text-gray-500 mb-1">
{t('filters.service')}
</label>
<select <select
value={filters.service} value={filters.service}
onChange={e => setFilter('service', e.target.value)} onChange={e => setFilter('service', e.target.value)}
@ -289,7 +289,9 @@ export default function AdminLogsPage() {
{/* Level */} {/* Level */}
<div> <div>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.level')}</label> <label className="block text-xs font-medium text-gray-500 mb-1">
{t('filters.level')}
</label>
<select <select
value={filters.level} value={filters.level}
onChange={e => setFilter('level', e.target.value)} onChange={e => setFilter('level', e.target.value)}
@ -306,7 +308,9 @@ export default function AdminLogsPage() {
{/* Search */} {/* Search */}
<div> <div>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.search')}</label> <label className="block text-xs font-medium text-gray-500 mb-1">
{t('filters.search')}
</label>
<input <input
type="text" type="text"
placeholder={t('filters.searchPlaceholder')} placeholder={t('filters.searchPlaceholder')}
@ -319,7 +323,9 @@ export default function AdminLogsPage() {
{/* Start */} {/* Start */}
<div> <div>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.start')}</label> <label className="block text-xs font-medium text-gray-500 mb-1">
{t('filters.start')}
</label>
<input <input
type="datetime-local" type="datetime-local"
value={filters.startDate} value={filters.startDate}
@ -330,7 +336,9 @@ export default function AdminLogsPage() {
{/* End */} {/* End */}
<div> <div>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.end')}</label> <label className="block text-xs font-medium text-gray-500 mb-1">
{t('filters.end')}
</label>
<input <input
type="datetime-local" type="datetime-local"
value={filters.endDate} value={filters.endDate}
@ -372,9 +380,7 @@ export default function AdminLogsPage() {
<span className="text-sm"> <span className="text-sm">
{t('errorBanner')} <strong>{error}</strong> {t('errorBanner')} <strong>{error}</strong>
<br /> <br />
<span className="text-xs text-red-500"> <span className="text-xs text-red-500">{t('errorHint')}</span>
{t('errorHint')}
</span>
</span> </span>
</div> </div>
)} )}
@ -389,9 +395,7 @@ export default function AdminLogsPage() {
</span> </span>
</div> </div>
{!loading && logs.length > 0 && ( {!loading && logs.length > 0 && (
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">{t('clickHint')}</span>
{t('clickHint')}
</span>
)} )}
</div> </div>
@ -469,8 +473,7 @@ export default function AdminLogsPage() {
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap"> <td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
{log.req_method && ( {log.req_method && (
<span> <span>
<span className="font-semibold">{log.req_method}</span>{' '} <span className="font-semibold">{log.req_method}</span> {log.req_url}{' '}
{log.req_url}{' '}
{log.res_status && ( {log.res_status && (
<span <span
className={ className={
@ -495,29 +498,37 @@ export default function AdminLogsPage() {
<td colSpan={6} className="px-4 py-3"> <td colSpan={6} className="px-4 py-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div> <div>
<span className="font-semibold text-gray-600">{t('detail.timestamp')}</span> <span className="font-semibold text-gray-600">
{t('detail.timestamp')}
</span>
<p className="font-mono text-gray-800 mt-0.5">{log.timestamp}</p> <p className="font-mono text-gray-800 mt-0.5">{log.timestamp}</p>
</div> </div>
{log.reqId && ( {log.reqId && (
<div> <div>
<span className="font-semibold text-gray-600">{t('detail.requestId')}</span> <span className="font-semibold text-gray-600">
<p className="font-mono text-gray-800 mt-0.5 truncate">{log.reqId}</p> {t('detail.requestId')}
</span>
<p className="font-mono text-gray-800 mt-0.5 truncate">
{log.reqId}
</p>
</div> </div>
)} )}
{log.response_time_ms && ( {log.response_time_ms && (
<div> <div>
<span className="font-semibold text-gray-600">{t('detail.duration')}</span> <span className="font-semibold text-gray-600">
{t('detail.duration')}
</span>
<p className="font-mono text-gray-800 mt-0.5"> <p className="font-mono text-gray-800 mt-0.5">
{log.response_time_ms} ms {log.response_time_ms} ms
</p> </p>
</div> </div>
)} )}
<div className="col-span-2 md:col-span-4"> <div className="col-span-2 md:col-span-4">
<span className="font-semibold text-gray-600">{t('detail.fullMessage')}</span> <span className="font-semibold text-gray-600">
{t('detail.fullMessage')}
</span>
<pre className="mt-0.5 p-2 bg-white rounded border font-mono text-gray-800 overflow-x-auto whitespace-pre-wrap break-all"> <pre className="mt-0.5 p-2 bg-white rounded border font-mono text-gray-800 overflow-x-auto whitespace-pre-wrap break-all">
{log.error {log.error ? `[ERROR] ${log.error}\n\n${log.message}` : log.message}
? `[ERROR] ${log.error}\n\n${log.message}`
: log.message}
</pre> </pre>
</div> </div>
</div> </div>

View File

@ -159,7 +159,12 @@ export default function AdminOrganizationsPage() {
setVerifyingId(orgId); setVerifyingId(orgId);
const result = await verifySiret(orgId); const result = await verifySiret(orgId);
if (result.verified) { if (result.verified) {
alert(t('siretVerified', { companyName: result.companyName || 'N/A', address: result.address || 'N/A' })); alert(
t('siretVerified', {
companyName: result.companyName || 'N/A',
address: result.address || 'N/A',
})
);
await fetchOrganizations(); await fetchOrganizations();
} else { } else {
alert(result.message || t('siretInvalid')); alert(result.message || t('siretInvalid'));
@ -264,17 +269,23 @@ export default function AdminOrganizationsPage() {
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{org.name}</h3> <h3 className="text-lg font-semibold text-gray-900">{org.name}</h3>
<span className={`inline-block mt-2 px-2 py-1 text-xs font-semibold rounded-full ${ <span
org.type === 'FREIGHT_FORWARDER' ? 'bg-blue-100 text-blue-800' : className={`inline-block mt-2 px-2 py-1 text-xs font-semibold rounded-full ${
org.type === 'CARRIER' ? 'bg-green-100 text-green-800' : org.type === 'FREIGHT_FORWARDER'
'bg-purple-100 text-purple-800' ? 'bg-blue-100 text-blue-800'
}`}> : org.type === 'CARRIER'
? 'bg-green-100 text-green-800'
: 'bg-purple-100 text-purple-800'
}`}
>
{getTypeLabel(org.type)} {getTypeLabel(org.type)}
</span> </span>
</div> </div>
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${ <span
org.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' className={`px-2 py-1 text-xs font-semibold rounded-full ${
}`}> org.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{org.isActive ? t('active') : t('inactive')} {org.isActive ? t('active') : t('inactive')}
</span> </span>
</div> </div>
@ -315,7 +326,8 @@ export default function AdminOrganizationsPage() {
</div> </div>
)} )}
<div> <div>
<span className="font-medium">{t('location')}:</span> {org.address.city}, {org.address.country} <span className="font-medium">{t('location')}:</span> {org.address.city},{' '}
{org.address.country}
</div> </div>
</div> </div>
@ -373,7 +385,9 @@ export default function AdminOrganizationsPage() {
<form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4"> <form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">{t('modal.name')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.name')}
</label>
<input <input
type="text" type="text"
required required
@ -384,7 +398,9 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.type')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.type')}
</label>
<select <select
value={formData.type} value={formData.type}
onChange={e => setFormData({ ...formData, type: e.target.value })} onChange={e => setFormData({ ...formData, type: e.target.value })}
@ -398,20 +414,26 @@ export default function AdminOrganizationsPage() {
{formData.type === 'CARRIER' && ( {formData.type === 'CARRIER' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.scacLabel')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.scacLabel')}
</label>
<input <input
type="text" type="text"
required={formData.type === 'CARRIER'} required={formData.type === 'CARRIER'}
maxLength={4} maxLength={4}
value={formData.scac} value={formData.scac}
onChange={e => setFormData({ ...formData, scac: e.target.value.toUpperCase() })} onChange={e =>
setFormData({ ...formData, scac: e.target.value.toUpperCase() })
}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/> />
</div> </div>
)} )}
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.sirenLabel')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.sirenLabel')}
</label>
<input <input
type="text" type="text"
maxLength={9} maxLength={9}
@ -422,19 +444,25 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.siretLabel')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.siretLabel')}
</label>
<input <input
type="text" type="text"
maxLength={14} maxLength={14}
value={formData.siret} value={formData.siret}
onChange={e => setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })} onChange={e =>
setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })
}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
placeholder={t('modal.siretPlaceholder')} placeholder={t('modal.siretPlaceholder')}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.eoriLabel')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.eoriLabel')}
</label>
<input <input
type="text" type="text"
value={formData.eori} value={formData.eori}
@ -444,7 +472,9 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.contactPhone')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.contactPhone')}
</label>
<input <input
type="tel" type="tel"
value={formData.contact_phone} value={formData.contact_phone}
@ -454,7 +484,9 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.contactEmail')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.contactEmail')}
</label>
<input <input
type="email" type="email"
value={formData.contact_email} value={formData.contact_email}
@ -464,77 +496,99 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">{t('modal.street')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.street')}
</label>
<input <input
type="text" type="text"
required required
value={formData.address.street} value={formData.address.street}
onChange={e => setFormData({ onChange={e =>
...formData, setFormData({
address: { ...formData.address, street: e.target.value } ...formData,
})} address: { ...formData.address, street: e.target.value },
})
}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.city')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.city')}
</label>
<input <input
type="text" type="text"
required required
value={formData.address.city} value={formData.address.city}
onChange={e => setFormData({ onChange={e =>
...formData, setFormData({
address: { ...formData.address, city: e.target.value } ...formData,
})} address: { ...formData.address, city: e.target.value },
})
}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.postalCode')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.postalCode')}
</label>
<input <input
type="text" type="text"
required required
value={formData.address.postalCode} value={formData.address.postalCode}
onChange={e => setFormData({ onChange={e =>
...formData, setFormData({
address: { ...formData.address, postalCode: e.target.value } ...formData,
})} address: { ...formData.address, postalCode: e.target.value },
})
}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.state')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.state')}
</label>
<input <input
type="text" type="text"
value={formData.address.state} value={formData.address.state}
onChange={e => setFormData({ onChange={e =>
...formData, setFormData({
address: { ...formData.address, state: e.target.value } ...formData,
})} address: { ...formData.address, state: e.target.value },
})
}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.country')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.country')}
</label>
<input <input
type="text" type="text"
required required
maxLength={2} maxLength={2}
value={formData.address.country} value={formData.address.country}
onChange={e => setFormData({ onChange={e =>
...formData, setFormData({
address: { ...formData.address, country: e.target.value.toUpperCase() } ...formData,
})} address: { ...formData.address, country: e.target.value.toUpperCase() },
})
}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/> />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">{t('modal.logoUrl')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.logoUrl')}
</label>
<input <input
type="url" type="url"
value={formData.logoUrl} value={formData.logoUrl}

View File

@ -228,11 +228,15 @@ export default function AdminUsersPage() {
<div className="text-sm text-gray-500">{user.email}</div> <div className="text-sm text-gray-500">{user.email}</div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${ <span
user.role === 'ADMIN' ? 'bg-purple-100 text-purple-800' : className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' : user.role === 'ADMIN'
'bg-gray-100 text-gray-800' ? 'bg-purple-100 text-purple-800'
}`}> : user.role === 'MANAGER'
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{getRoleLabel(user.role)} {getRoleLabel(user.role)}
</span> </span>
</td> </td>
@ -240,9 +244,11 @@ export default function AdminUsersPage() {
{user.organizationName || user.organizationId} {user.organizationName || user.organizationId}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${ <span
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
}`}> user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{user.isActive ? t('active') : t('inactive')} {user.isActive ? t('active') : t('inactive')}
</span> </span>
</td> </td>
@ -273,7 +279,9 @@ export default function AdminUsersPage() {
<h2 className="text-xl font-bold mb-4">{t('modal.createTitle')}</h2> <h2 className="text-xl font-bold mb-4">{t('modal.createTitle')}</h2>
<form onSubmit={handleCreate} className="space-y-4"> <form onSubmit={handleCreate} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.email')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.email')}
</label>
<input <input
type="email" type="email"
required required
@ -283,7 +291,9 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.firstName')}
</label>
<input <input
type="text" type="text"
required required
@ -293,7 +303,9 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.lastName')}
</label>
<input <input
type="text" type="text"
required required
@ -316,7 +328,9 @@ export default function AdminUsersPage() {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.organization')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.organization')}
</label>
<select <select
required required
value={formData.organizationId} value={formData.organizationId}
@ -372,7 +386,9 @@ export default function AdminUsersPage() {
<h2 className="text-xl font-bold mb-4">{t('modal.editTitle')}</h2> <h2 className="text-xl font-bold mb-4">{t('modal.editTitle')}</h2>
<form onSubmit={handleUpdate} className="space-y-4"> <form onSubmit={handleUpdate} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.emailReadOnly')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.emailReadOnly')}
</label>
<input <input
type="email" type="email"
disabled disabled
@ -381,7 +397,9 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.firstName')}
</label>
<input <input
type="text" type="text"
required required
@ -391,7 +409,9 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.lastName')}
</label>
<input <input
type="text" type="text"
required required
@ -443,7 +463,10 @@ export default function AdminUsersPage() {
<div className="bg-white rounded-lg p-6 max-w-md w-full"> <div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4 text-red-600">{t('deleteConfirm.title')}</h2> <h2 className="text-xl font-bold mb-4 text-red-600">{t('deleteConfirm.title')}</h2>
<p className="text-gray-700 mb-6"> <p className="text-gray-700 mb-6">
{t('deleteConfirm.message', { firstName: selectedUser.firstName, lastName: selectedUser.lastName })} {t('deleteConfirm.message', {
firstName: selectedUser.firstName,
lastName: selectedUser.lastName,
})}
</p> </p>
<div className="flex justify-end space-x-2"> <div className="flex justify-end space-x-2">
<button <button

View File

@ -210,9 +210,7 @@ export default function PayCommissionPage() {
selectedMethod === 'card' ? 'border-blue-500 bg-blue-500' : 'border-gray-300' selectedMethod === 'card' ? 'border-blue-500 bg-blue-500' : 'border-gray-300'
}`} }`}
> >
{selectedMethod === 'card' && ( {selectedMethod === 'card' && <div className="w-2 h-2 rounded-full bg-white" />}
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div> </div>
</div> </div>
</button> </button>

View File

@ -32,9 +32,7 @@ export default function PaymentSuccessPage() {
setStatus('success'); setStatus('success');
} catch (err) { } catch (err) {
console.error('Payment confirmation error:', err); console.error('Payment confirmation error:', err);
setError( setError(err instanceof Error ? err.message : 'Erreur lors de la confirmation du paiement');
err instanceof Error ? err.message : 'Erreur lors de la confirmation du paiement'
);
setStatus('error'); setStatus('error');
} }
} }
@ -68,7 +66,8 @@ export default function PaymentSuccessPage() {
<Loader2 className="h-16 w-16 animate-spin text-blue-600 mx-auto mb-6" /> <Loader2 className="h-16 w-16 animate-spin text-blue-600 mx-auto mb-6" />
<h2 className="text-xl font-bold text-gray-900 mb-2">Confirmation du paiement...</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">Confirmation du paiement...</h2>
<p className="text-gray-600"> <p className="text-gray-600">
Veuillez patienter pendant que nous verifions votre paiement et activons votre booking. Veuillez patienter pendant que nous verifions votre paiement et activons votre
booking.
</p> </p>
</> </>
)} )}
@ -82,15 +81,14 @@ 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 demande de booking. Votre commission a ete payee avec succes. Un email a ete envoye au transporteur 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">
<div className="flex items-center justify-center space-x-2 text-blue-700"> <div className="flex items-center justify-center space-x-2 text-blue-700">
<Mail className="h-5 w-5" /> <Mail className="h-5 w-5" />
<span className="text-sm font-medium"> <span className="text-sm font-medium">Email envoye au transporteur</span>
Email envoye au transporteur
</span>
</div> </div>
<p className="text-xs text-blue-600 mt-1"> <p className="text-xs text-blue-600 mt-1">
Vous recevrez une notification des que le transporteur repond (sous 7 jours max) Vous recevrez une notification des que le transporteur repond (sous 7 jours max)
@ -113,7 +111,8 @@ export default function PaymentSuccessPage() {
<h2 className="text-xl font-bold text-gray-900 mb-2">Erreur de confirmation</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">Erreur de confirmation</h2>
<p className="text-gray-600 mb-2">{error}</p> <p className="text-gray-600 mb-2">{error}</p>
<p className="text-sm text-gray-500 mb-6"> <p className="text-sm text-gray-500 mb-6">
Si votre paiement a ete debite, contactez le support. Votre booking sera active manuellement. Si votre paiement a ete debite, contactez le support. Votre booking sera active
manuellement.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<button <button

View File

@ -41,7 +41,7 @@ const DOCUMENT_TYPES = [
{ value: 'BILL_OF_LADING', label: 'Bill of Lading (Connaissement)' }, { value: 'BILL_OF_LADING', label: 'Bill of Lading (Connaissement)' },
{ value: 'PACKING_LIST', label: 'Packing List (Liste de colisage)' }, { value: 'PACKING_LIST', label: 'Packing List (Liste de colisage)' },
{ value: 'COMMERCIAL_INVOICE', label: 'Commercial Invoice (Facture commerciale)' }, { value: 'COMMERCIAL_INVOICE', label: 'Commercial Invoice (Facture commerciale)' },
{ value: 'CERTIFICATE_OF_ORIGIN', label: 'Certificate of Origin (Certificat d\'origine)' }, { value: 'CERTIFICATE_OF_ORIGIN', label: "Certificate of Origin (Certificat d'origine)" },
{ value: 'OTHER', label: 'Autre document' }, { value: 'OTHER', label: 'Autre document' },
]; ];
@ -187,7 +187,7 @@ function NewBookingPageContent() {
} }
// Append documents // Append documents
formData.documents.forEach((file) => { formData.documents.forEach(file => {
formDataToSend.append('documents', file); formDataToSend.append('documents', file);
}); });
@ -228,7 +228,9 @@ function NewBookingPageContent() {
</button> </button>
<div className="bg-white rounded-lg shadow-md p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Nouvelle demande de réservation</h1> <h1 className="text-3xl font-bold text-gray-900 mb-2">
Nouvelle demande de réservation
</h1>
<p className="text-gray-600"> <p className="text-gray-600">
Envoyez une demande de réservation directement au transporteur Envoyez une demande de réservation directement au transporteur
</p> </p>
@ -243,9 +245,7 @@ function NewBookingPageContent() {
<div className="flex items-center"> <div className="flex items-center">
<div <div
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${ className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
currentStep >= step currentStep >= step ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-600'
}`} }`}
> >
{step} {step}
@ -292,16 +292,12 @@ function NewBookingPageContent() {
{/* Step 1: Transport Details (Read-only) */} {/* Step 1: Transport Details (Read-only) */}
{currentStep === 1 && ( {currentStep === 1 && (
<div> <div>
<h2 className="text-2xl font-bold text-gray-900 mb-6"> <h2 className="text-2xl font-bold text-gray-900 mb-6">Détails du transport</h2>
Détails du transport
</h2>
<div className="space-y-6"> <div className="space-y-6">
{/* Carrier Info */} {/* Carrier Info */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6"> <div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> <h3 className="text-lg font-semibold text-gray-900 mb-4">Transporteur</h3>
Transporteur
</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<p className="text-sm text-gray-600">Nom</p> <p className="text-sm text-gray-600">Nom</p>
@ -316,9 +312,7 @@ function NewBookingPageContent() {
{/* Route Info */} {/* Route Info */}
<div className="bg-gray-50 rounded-lg p-6"> <div className="bg-gray-50 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> <h3 className="text-lg font-semibold text-gray-900 mb-4">Trajet</h3>
Trajet
</h3>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-center"> <div className="text-center">
<p className="text-sm text-gray-600 mb-1">Origine</p> <p className="text-sm text-gray-600 mb-1">Origine</p>
@ -327,7 +321,9 @@ function NewBookingPageContent() {
<div className="flex-1 mx-8"> <div className="flex-1 mx-8">
<div className="border-t-2 border-dashed border-gray-300 relative"> <div className="border-t-2 border-dashed border-gray-300 relative">
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white px-3"> <div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white px-3">
<span className="text-sm text-gray-600">{formData.transitDays} jours</span> <span className="text-sm text-gray-600">
{formData.transitDays} jours
</span>
</div> </div>
</div> </div>
</div> </div>
@ -350,7 +346,9 @@ function NewBookingPageContent() {
</div> </div>
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Palettes</p> <p className="text-xs text-gray-600 mb-1">Palettes</p>
<p className="text-lg font-bold text-gray-900">{formData.palletCount || 'N/A'}</p> <p className="text-lg font-bold text-gray-900">
{formData.palletCount || 'N/A'}
</p>
</div> </div>
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Type</p> <p className="text-xs text-gray-600 mb-1">Type</p>
@ -360,9 +358,7 @@ function NewBookingPageContent() {
{/* Pricing */} {/* Pricing */}
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6"> <div className="bg-green-50 border-2 border-green-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> <h3 className="text-lg font-semibold text-gray-900 mb-4">Prix estimé</h3>
Prix estimé
</h3>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
<p className="text-sm text-gray-600">Fret ({formData.freightCurrency})</p> <p className="text-sm text-gray-600">Fret ({formData.freightCurrency})</p>
@ -382,7 +378,10 @@ function NewBookingPageContent() {
<div className="border-t border-green-200 pt-4 flex items-center justify-between"> <div className="border-t border-green-200 pt-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-700">Prix total</p> <p className="text-sm font-semibold text-gray-700">Prix total</p>
<p className="text-3xl font-bold text-green-600"> <p className="text-3xl font-bold text-green-600">
{formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')} {formatPrice(
formData.totalPriceForSorting,
formData.primaryCurrency || 'EUR'
)}
</p> </p>
</div> </div>
</div> </div>
@ -403,9 +402,7 @@ function NewBookingPageContent() {
{/* Step 2: Document Upload */} {/* Step 2: Document Upload */}
{currentStep === 2 && ( {currentStep === 2 && (
<div> <div>
<h2 className="text-2xl font-bold text-gray-900 mb-6"> <h2 className="text-2xl font-bold text-gray-900 mb-6">Documents requis</h2>
Documents requis
</h2>
<div className="mb-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg p-4"> <div className="mb-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg p-4">
<div className="flex items-start"> <div className="flex items-start">
@ -428,7 +425,7 @@ function NewBookingPageContent() {
id="file-upload" id="file-upload"
multiple multiple
accept={ACCEPTED_FILE_TYPES.join(',')} accept={ACCEPTED_FILE_TYPES.join(',')}
onChange={(e) => handleFileChange(e.target.files)} onChange={e => handleFileChange(e.target.files)}
className="hidden" className="hidden"
/> />
<label <label
@ -451,9 +448,7 @@ function NewBookingPageContent() {
<p className="text-lg font-semibold text-gray-700 mb-2"> <p className="text-lg font-semibold text-gray-700 mb-2">
Cliquez pour sélectionner des fichiers Cliquez pour sélectionner des fichiers
</p> </p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">ou glissez-déposez vos documents ici</p>
ou glissez-déposez vos documents ici
</p>
</label> </label>
</div> </div>
@ -522,7 +517,7 @@ function NewBookingPageContent() {
</label> </label>
<textarea <textarea
value={formData.notes || ''} value={formData.notes || ''}
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))} onChange={e => setFormData(prev => ({ ...prev, notes: e.target.value }))}
rows={4} rows={4}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Ajoutez des instructions spéciales pour le transporteur..." placeholder="Ajoutez des instructions spéciales pour le transporteur..."
@ -550,16 +545,12 @@ function NewBookingPageContent() {
{/* Step 3: Review & Submit */} {/* Step 3: Review & Submit */}
{currentStep === 3 && ( {currentStep === 3 && (
<div> <div>
<h2 className="text-2xl font-bold text-gray-900 mb-6"> <h2 className="text-2xl font-bold text-gray-900 mb-6">Révision et envoi</h2>
Révision et envoi
</h2>
<div className="space-y-6 mb-8"> <div className="space-y-6 mb-8">
{/* Summary */} {/* Summary */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6"> <div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> <h3 className="text-lg font-semibold text-gray-900 mb-4">Récapitulatif</h3>
Récapitulatif
</h3>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Transporteur :</span> <span className="text-gray-600">Transporteur :</span>
@ -602,7 +593,10 @@ function NewBookingPageContent() {
<div className="flex justify-between border-t pt-3 mt-3"> <div className="flex justify-between border-t pt-3 mt-3">
<span className="text-gray-900 font-semibold">Prix total :</span> <span className="text-gray-900 font-semibold">Prix total :</span>
<span className="text-2xl font-bold text-green-600"> <span className="text-2xl font-bold text-green-600">
{formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')} {formatPrice(
formData.totalPriceForSorting,
formData.primaryCurrency || 'EUR'
)}
</span> </span>
</div> </div>
</div> </div>
@ -617,7 +611,8 @@ function NewBookingPageContent() {
<li className="flex items-start"> <li className="flex items-start">
<span className="mr-2">1.</span> <span className="mr-2">1.</span>
<span> <span>
Votre demande sera <strong>envoyée par email</strong> au transporteur ({formData.carrierEmail}) Votre demande sera <strong>envoyée par email</strong> au transporteur (
{formData.carrierEmail})
</span> </span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
@ -629,13 +624,15 @@ function NewBookingPageContent() {
<li className="flex items-start"> <li className="flex items-start">
<span className="mr-2">3.</span> <span className="mr-2">3.</span>
<span> <span>
Il pourra <strong>accepter ou refuser</strong> la demande directement depuis son email Il pourra <strong>accepter ou refuser</strong> la demande directement depuis
son email
</span> </span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="mr-2">4.</span> <span className="mr-2">4.</span>
<span> <span>
Vous recevrez une <strong>notification</strong> dès que le transporteur répond (sous 7 jours maximum) Vous recevrez une <strong>notification</strong> dès que le transporteur
répond (sous 7 jours maximum)
</span> </span>
</li> </li>
</ul> </ul>
@ -647,7 +644,7 @@ function NewBookingPageContent() {
<input <input
type="checkbox" type="checkbox"
checked={termsAccepted} checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)} onChange={e => setTermsAccepted(e.target.checked)}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/> />
<span className="ml-3 text-sm text-gray-700"> <span className="ml-3 text-sm text-gray-700">
@ -693,7 +690,9 @@ function NewBookingPageContent() {
export default function NewBookingPage() { export default function NewBookingPage() {
return ( return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Chargement...</div>}> <Suspense
fallback={<div className="min-h-screen flex items-center justify-center">Chargement...</div>}
>
<NewBookingPageContent /> <NewBookingPageContent />
</Suspense> </Suspense>
); );

View File

@ -124,7 +124,9 @@ export default function BookingDetailPage() {
</div> </div>
{booking.specialInstructions && ( {booking.specialInstructions && (
<div> <div>
<dt className="text-sm font-medium text-gray-500">{t('cargo.specialInstructions')}</dt> <dt className="text-sm font-medium text-gray-500">
{t('cargo.specialInstructions')}
</dt>
<dd className="mt-1 text-sm text-gray-900">{booking.specialInstructions}</dd> <dd className="mt-1 text-sm text-gray-900">{booking.specialInstructions}</dd>
</div> </div>
)} )}
@ -145,7 +147,9 @@ export default function BookingDetailPage() {
</div> </div>
{container.containerNumber && ( {container.containerNumber && (
<div> <div>
<p className="text-sm font-medium text-gray-500">{t('containers.number')}</p> <p className="text-sm font-medium text-gray-500">
{t('containers.number')}
</p>
<p className="text-sm text-gray-900">{container.containerNumber}</p> <p className="text-sm text-gray-900">{container.containerNumber}</p>
</div> </div>
)} )}
@ -243,7 +247,9 @@ export default function BookingDetailPage() {
</div> </div>
<div className="min-w-0 flex-1 pt-1.5"> <div className="min-w-0 flex-1 pt-1.5">
<div> <div>
<p className="text-sm text-gray-900 font-medium">{t('timeline.created')}</p> <p className="text-sm text-gray-900 font-medium">
{t('timeline.created')}
</p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{new Date(booking.createdAt).toLocaleString(dateLocale)} {new Date(booking.createdAt).toLocaleString(dateLocale)}
</p> </p>

View File

@ -30,7 +30,11 @@ export default function BookingsListPage() {
} }
}, [searchParams]); }, [searchParams]);
const { data: csvData, isLoading, error: csvError } = useQuery({ const {
data: csvData,
isLoading,
error: csvError,
} = useQuery({
queryKey: ['csv-bookings'], queryKey: ['csv-bookings'],
queryFn: () => queryFn: () =>
listCsvBookings({ listCsvBookings({
@ -64,10 +68,15 @@ export default function BookingsListPage() {
case 'status': case 'status':
return booking.status?.toLowerCase().includes(term); return booking.status?.toLowerCase().includes(term);
case 'date': case 'date':
const date = new Date(booking.requestedPickupDate || booking.requestedAt).toLocaleDateString(dateLocale); const date = new Date(
booking.requestedPickupDate || booking.requestedAt
).toLocaleDateString(dateLocale);
return date.includes(term); return date.includes(term);
case 'quote': case 'quote':
return booking.id?.toLowerCase().includes(term) || booking.quoteNumber?.toLowerCase().includes(term); return (
booking.id?.toLowerCase().includes(term) ||
booking.quoteNumber?.toLowerCase().includes(term)
);
default: default:
return true; return true;
} }
@ -77,7 +86,9 @@ export default function BookingsListPage() {
return filtered; return filtered;
}; };
const filteredBookings = filterBookings((csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const }))); const filteredBookings = filterBookings(
(csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const }))
);
const totalBookings = filteredBookings.length; const totalBookings = filteredBookings.length;
const totalPages = Math.ceil(totalBookings / ITEMS_PER_PAGE); const totalPages = Math.ceil(totalBookings / ITEMS_PER_PAGE);
@ -141,12 +152,15 @@ export default function BookingsListPage() {
<Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" /> <Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div> <div>
<p className="font-medium text-amber-800">{t('transferBanner.title')}</p> <p className="font-medium text-amber-800">{t('transferBanner.title')}</p>
<p className="text-sm text-amber-700 mt-0.5"> <p className="text-sm text-amber-700 mt-0.5">{t('transferBanner.message')}</p>
{t('transferBanner.message')}
</p>
</div> </div>
</div> </div>
<button onClick={() => setShowTransferBanner(false)} className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0"></button> <button
onClick={() => setShowTransferBanner(false)}
className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0"
>
</button>
</div> </div>
)} )}
<PageHeader <PageHeader
@ -159,14 +173,18 @@ export default function BookingsListPage() {
filename={t('exportFilename')} filename={t('exportFilename')}
columns={[ columns={[
{ key: 'id', label: t('export.id') }, { key: 'id', label: t('export.id') },
{ key: 'palletCount', label: t('export.pallets'), format: (v) => `${v || 0}` }, { key: 'palletCount', label: t('export.pallets'), format: v => `${v || 0}` },
{ key: 'weightKG', label: t('export.weight'), format: (v) => `${v || 0}` }, { key: 'weightKG', label: t('export.weight'), format: v => `${v || 0}` },
{ key: 'volumeCBM', label: t('export.volume'), format: (v) => `${v || 0}` }, { key: 'volumeCBM', label: t('export.volume'), format: v => `${v || 0}` },
{ key: 'origin', label: t('export.origin') }, { key: 'origin', label: t('export.origin') },
{ key: 'destination', label: t('export.destination') }, { key: 'destination', label: t('export.destination') },
{ key: 'carrierName', label: t('export.carrier') }, { key: 'carrierName', label: t('export.carrier') },
{ key: 'status', label: t('export.status'), format: (v) => getStatusLabel(v) }, { key: 'status', label: t('export.status'), format: v => getStatusLabel(v) },
{ key: 'createdAt', label: t('export.createdAt'), format: (v) => v ? new Date(v).toLocaleDateString(dateLocale) : '' }, {
key: 'createdAt',
label: t('export.createdAt'),
format: v => (v ? new Date(v).toLocaleDateString(dateLocale) : ''),
},
]} ]}
/> />
<Link <Link
@ -280,13 +298,17 @@ export default function BookingsListPage() {
{booking.type === 'csv' ? booking.carrierName : booking.carrier || ''} {booking.type === 'csv' ? booking.carrierName : booking.carrier || ''}
</div> </div>
</div> </div>
<span className={`px-2 py-1 text-xs font-semibold rounded-full flex-shrink-0 ${getStatusColor(booking.status)}`}> <span
className={`px-2 py-1 text-xs font-semibold rounded-full flex-shrink-0 ${getStatusColor(booking.status)}`}
>
{getStatusLabel(booking.status)} {getStatusLabel(booking.status)}
</span> </span>
</div> </div>
<div className="grid grid-cols-3 gap-2 text-xs"> <div className="grid grid-cols-3 gap-2 text-xs">
<div> <div>
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.pallets')}</div> <div className="text-gray-400 uppercase tracking-wide">
{t('mobile.pallets')}
</div>
<div className="font-medium text-gray-900 mt-0.5"> <div className="font-medium text-gray-900 mt-0.5">
{booking.type === 'csv' {booking.type === 'csv'
? t('units.palletsShort', { count: booking.palletCount }) ? t('units.palletsShort', { count: booking.palletCount })
@ -294,25 +316,36 @@ export default function BookingsListPage() {
</div> </div>
</div> </div>
<div> <div>
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.weight')}</div> <div className="text-gray-400 uppercase tracking-wide">
{t('mobile.weight')}
</div>
<div className="font-medium text-gray-900 mt-0.5"> <div className="font-medium text-gray-900 mt-0.5">
{booking.type === 'csv' {booking.type === 'csv'
? t('units.kg', { value: booking.weightKG }) ? t('units.kg', { value: booking.weightKG })
: booking.totalWeight ? t('units.kg', { value: booking.totalWeight }) : 'N/A'} : booking.totalWeight
? t('units.kg', { value: booking.totalWeight })
: 'N/A'}
</div> </div>
</div> </div>
<div> <div>
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.date')}</div> <div className="text-gray-400 uppercase tracking-wide">
{t('mobile.date')}
</div>
<div className="font-medium text-gray-900 mt-0.5"> <div className="font-medium text-gray-900 mt-0.5">
{(booking.createdAt || booking.requestedAt) {booking.createdAt || booking.requestedAt
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(dateLocale, { day: '2-digit', month: '2-digit', year: '2-digit' }) ? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(
dateLocale,
{ day: '2-digit', month: '2-digit', year: '2-digit' }
)
: 'N/A'} : 'N/A'}
</div> </div>
</div> </div>
</div> </div>
<div className="text-xs text-gray-400"> <div className="text-xs text-gray-400">
{booking.type === 'csv' {booking.type === 'csv'
? t('mobile.ref', { id: booking.bookingId || booking.id.slice(0, 8).toUpperCase() }) ? t('mobile.ref', {
id: booking.bookingId || booking.id.slice(0, 8).toUpperCase(),
})
: t('mobile.booking', { number: booking.bookingNumber || '-' })} : t('mobile.booking', { number: booking.bookingNumber || '-' })}
</div> </div>
</div> </div>
@ -353,7 +386,9 @@ export default function BookingsListPage() {
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
{booking.type === 'csv' {booking.type === 'csv'
? t('units.palletsCount', { count: booking.palletCount }) ? t('units.palletsCount', { count: booking.palletCount })
: t('units.containersCount', { count: booking.containers?.length || 0 })} : t('units.containersCount', {
count: booking.containers?.length || 0,
})}
</div> </div>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'} {booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
@ -364,15 +399,15 @@ export default function BookingsListPage() {
{booking.type === 'csv' {booking.type === 'csv'
? t('units.kg', { value: booking.weightKG }) ? t('units.kg', { value: booking.weightKG })
: booking.totalWeight : booking.totalWeight
? t('units.kg', { value: booking.totalWeight }) ? t('units.kg', { value: booking.totalWeight })
: 'N/A'} : 'N/A'}
</div> </div>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
{booking.type === 'csv' {booking.type === 'csv'
? t('units.cbm', { value: booking.volumeCBM }) ? t('units.cbm', { value: booking.volumeCBM })
: booking.totalVolume : booking.totalVolume
? t('units.cbm', { value: booking.totalVolume }) ? t('units.cbm', { value: booking.totalVolume })
: ''} : ''}
</div> </div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
@ -397,21 +432,24 @@ export default function BookingsListPage() {
</span> </span>
</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(dateLocale, { ? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(
day: '2-digit', dateLocale,
month: '2-digit', {
year: 'numeric', day: '2-digit',
}) month: '2-digit',
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-left text-sm font-medium">
{booking.type === 'csv' {booking.type === 'csv'
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}` ? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`} : booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy">
{booking.bookingNumber || '-'} {booking.bookingNumber || '-'}
</td> </td>
</tr> </tr>
))} ))}
@ -444,12 +482,15 @@ export default function BookingsListPage() {
start: startIndex + 1, start: startIndex + 1,
end: Math.min(endIndex, totalBookings), end: Math.min(endIndex, totalBookings),
total: totalBookings, total: totalBookings,
b: (chunks) => <span className="font-medium">{chunks}</span>, b: chunks => <span className="font-medium">{chunks}</span>,
})} })}
</p> </p>
</div> </div>
<div> <div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination"> <nav
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
<button <button
onClick={() => setPage(page - 1)} onClick={() => setPage(page - 1)}
disabled={page === 1} disabled={page === 1}
@ -457,21 +498,40 @@ export default function BookingsListPage() {
> >
<span className="sr-only">{t('pagination.previous')}</span> <span className="sr-only">{t('pagination.previous')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg> </svg>
</button> </button>
{[...Array(totalPages)].map((_, idx) => { {[...Array(totalPages)].map((_, idx) => {
const pageNum = idx + 1; const pageNum = idx + 1;
const showPage = pageNum === 1 || const showPage =
pageNum === totalPages || pageNum === 1 ||
(pageNum >= page - 1 && pageNum <= page + 1); pageNum === totalPages ||
(pageNum >= page - 1 && pageNum <= page + 1);
if (!showPage && pageNum === 2) { if (!showPage && pageNum === 2) {
return <span key={pageNum} className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">...</span>; return (
<span
key={pageNum}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700"
>
...
</span>
);
} }
if (!showPage && pageNum === totalPages - 1) { if (!showPage && pageNum === totalPages - 1) {
return <span key={pageNum} className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">...</span>; return (
<span
key={pageNum}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700"
>
...
</span>
);
} }
if (!showPage) return null; if (!showPage) return null;
@ -497,7 +557,11 @@ export default function BookingsListPage() {
> >
<span className="sr-only">{t('pagination.next')}</span> <span className="sr-only">{t('pagination.next')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg> </svg>
</button> </button>
</nav> </nav>
@ -523,9 +587,7 @@ export default function BookingsListPage() {
</svg> </svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('empty.title')}</h3> <h3 className="mt-2 text-sm font-medium text-gray-900">{t('empty.title')}</h3>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
{searchTerm || statusFilter {searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings')}
? t('empty.hasFilters')
: t('empty.noBookings')}
</p> </p>
<div className="mt-6"> <div className="mt-6">
<Link <Link

View File

@ -61,8 +61,17 @@ export default function UserDocumentsPage() {
const getFileType = (fileName: string): string => { const getFileType = (fileName: string): string => {
const ext = fileName.split('.').pop()?.toLowerCase() || ''; const ext = fileName.split('.').pop()?.toLowerCase() || '';
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
pdf: 'PDF', doc: 'Word', docx: 'Word', xls: 'Excel', xlsx: 'Excel', pdf: 'PDF',
jpg: 'Image', jpeg: 'Image', png: 'Image', gif: 'Image', txt: 'Text', csv: 'CSV', doc: 'Word',
docx: 'Word',
xls: 'Excel',
xlsx: 'Excel',
jpg: 'Image',
jpeg: 'Image',
png: 'Image',
gif: 'Image',
txt: 'Text',
csv: 'CSV',
}; };
return typeMap[ext] || ext.toUpperCase(); return typeMap[ext] || ext.toUpperCase();
}; };
@ -151,7 +160,7 @@ export default function UserDocumentsPage() {
const getDocumentIcon = (type: string): ReactNode => { const getDocumentIcon = (type: string): ReactNode => {
const typeLower = type.toLowerCase(); const typeLower = type.toLowerCase();
const cls = "h-6 w-6"; const cls = 'h-6 w-6';
const iconMap: Record<string, ReactNode> = { const iconMap: Record<string, ReactNode> = {
'application/pdf': <FileText className={`${cls} text-red-500`} />, 'application/pdf': <FileText className={`${cls} text-red-500`} />,
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />, 'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
@ -356,8 +365,12 @@ export default function UserDocumentsPage() {
{ key: 'quoteNumber', label: t('export.quoteNumber') }, { key: 'quoteNumber', label: t('export.quoteNumber') },
{ key: 'route', label: t('export.route') }, { key: 'route', label: t('export.route') },
{ key: 'carrierName', label: t('export.carrier') }, { key: 'carrierName', label: t('export.carrier') },
{ key: 'status', label: t('export.status'), format: (v) => getStatusLabel(v) }, { key: 'status', label: t('export.status'), format: v => getStatusLabel(v) },
{ key: 'uploadedAt', label: t('export.uploadedAt'), format: (v) => v ? new Date(v).toLocaleDateString(locale) : '' }, {
key: 'uploadedAt',
label: t('export.uploadedAt'),
format: v => (v ? new Date(v).toLocaleDateString(locale) : ''),
},
]} ]}
/> />
<button <button
@ -365,8 +378,18 @@ export default function UserDocumentsPage() {
disabled={bookingsAvailableForDocuments.length === 0} disabled={bookingsAvailableForDocuments.length === 0}
className="inline-flex items-center px-3 sm:px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="inline-flex items-center px-3 sm:px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
<svg className="w-4 h-4 sm:mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> className="w-4 h-4 sm:mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg> </svg>
<span className="hidden sm:inline">{t('addDocument.buttonLabel')}</span> <span className="hidden sm:inline">{t('addDocument.buttonLabel')}</span>
</button> </button>
@ -396,7 +419,9 @@ export default function UserDocumentsPage() {
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.search')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">
{t('filters.search')}
</label>
<input <input
type="text" type="text"
placeholder={t('filters.searchPlaceholder')} placeholder={t('filters.searchPlaceholder')}
@ -406,7 +431,9 @@ export default function UserDocumentsPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.quoteNumber')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">
{t('filters.quoteNumber')}
</label>
<input <input
type="text" type="text"
placeholder="Ex: #F2CAD5E1" placeholder="Ex: #F2CAD5E1"
@ -416,7 +443,9 @@ export default function UserDocumentsPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.status')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">
{t('filters.status')}
</label>
<select <select
value={filterStatus} value={filterStatus}
onChange={e => setFilterStatus(e.target.value)} onChange={e => setFilterStatus(e.target.value)}
@ -443,13 +472,27 @@ export default function UserDocumentsPage() {
<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>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.documentName')}</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('table.type')}</th> {t('table.documentName')}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.quoteNumber')}</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.route')}</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('table.carrier')}</th> {t('table.type')}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.status')}</th> </th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.actions')}</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.quoteNumber')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.route')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.carrier')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.status')}
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.actions')}
</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
@ -481,14 +524,16 @@ export default function UserDocumentsPage() {
<div className="text-sm text-gray-900">{doc.carrierName}</div> <div className="text-sm text-gray-900">{doc.carrierName}</div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}> <span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}
>
{getStatusLabel(doc.status)} {getStatusLabel(doc.status)}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-center"> <td className="px-6 py-4 whitespace-nowrap text-center">
<div className="relative inline-block text-left"> <div className="relative inline-block text-left">
<button <button
onClick={(e) => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
toggleDropdown(`${doc.bookingId}-${doc.id}`); toggleDropdown(`${doc.bookingId}-${doc.id}`);
}} }}
@ -505,7 +550,7 @@ export default function UserDocumentsPage() {
{openDropdownId === `${doc.bookingId}-${doc.id}` && ( {openDropdownId === `${doc.bookingId}-${doc.id}` && (
<div <div
className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50" className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"
onClick={(e) => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<div className="py-1"> <div className="py-1">
<button <button
@ -515,8 +560,18 @@ export default function UserDocumentsPage() {
}} }}
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
> >
<svg className="w-4 h-4 mr-3 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> className="w-4 h-4 mr-3 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg> </svg>
{t('actions.download')} {t('actions.download')}
</button> </button>
@ -524,8 +579,18 @@ export default function UserDocumentsPage() {
onClick={() => handleReplaceClick(doc)} onClick={() => handleReplaceClick(doc)}
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
> >
<svg className="w-4 h-4 mr-3 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> className="w-4 h-4 mr-3 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg> </svg>
{t('actions.replace')} {t('actions.replace')}
</button> </button>
@ -587,7 +652,10 @@ export default function UserDocumentsPage() {
<option value={100}>100</option> <option value={100}>100</option>
</select> </select>
</div> </div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination"> <nav
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
<button <button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))} onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
@ -595,7 +663,11 @@ export default function UserDocumentsPage() {
> >
<span className="sr-only">{t('pagination.previous')}</span> <span className="sr-only">{t('pagination.previous')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg> </svg>
</button> </button>
@ -633,7 +705,11 @@ export default function UserDocumentsPage() {
> >
<span className="sr-only">{t('pagination.next')}</span> <span className="sr-only">{t('pagination.next')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg> </svg>
</button> </button>
</nav> </nav>
@ -647,20 +723,37 @@ export default function UserDocumentsPage() {
{showAddModal && ( {showAddModal && (
<div className="fixed inset-0 z-50 overflow-y-auto"> <div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> <div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={handleCloseModal} /> <div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={handleCloseModal}
/>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> <div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start"> <div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10"> <div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg> </svg>
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1"> <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 className="text-lg leading-6 font-medium text-gray-900">{t('addDocument.modalTitle')}</h3> <h3 className="text-lg leading-6 font-medium text-gray-900">
{t('addDocument.modalTitle')}
</h3>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('addDocument.selectBooking')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">
{t('addDocument.selectBooking')}
</label>
<select <select
value={selectedBookingId || ''} value={selectedBookingId || ''}
onChange={e => setSelectedBookingId(e.target.value)} onChange={e => setSelectedBookingId(e.target.value)}
@ -669,13 +762,19 @@ export default function UserDocumentsPage() {
<option value="">{t('addDocument.selectBookingPlaceholder')}</option> <option value="">{t('addDocument.selectBookingPlaceholder')}</option>
{bookingsAvailableForDocuments.map(booking => ( {bookingsAvailableForDocuments.map(booking => (
<option key={booking.id} value={booking.id}> <option key={booking.id} value={booking.id}>
{getQuoteNumber(booking)} - {booking.origin} {booking.destination} ({booking.status === 'PENDING' ? t('statuses.PENDING') : t('statuses.ACCEPTED')}) {getQuoteNumber(booking)} - {booking.origin} {booking.destination} (
{booking.status === 'PENDING'
? t('statuses.PENDING')
: t('statuses.ACCEPTED')}
)
</option> </option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('addDocument.filesToAdd')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">
{t('addDocument.filesToAdd')}
</label>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@ -683,7 +782,9 @@ export default function UserDocumentsPage() {
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif" accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/> />
<p className="mt-1 text-xs text-gray-500">{t('addDocument.acceptedFormats')}</p> <p className="mt-1 text-xs text-gray-500">
{t('addDocument.acceptedFormats')}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -698,13 +799,30 @@ export default function UserDocumentsPage() {
> >
{uploadingFiles ? ( {uploadingFiles ? (
<> <>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg> </svg>
{t('addDocument.uploading')} {t('addDocument.uploading')}
</> </>
) : t('addDocument.add')} ) : (
t('addDocument.add')
)}
</button> </button>
<button <button
type="button" type="button"
@ -723,34 +841,58 @@ export default function UserDocumentsPage() {
{showReplaceModal && documentToReplace && ( {showReplaceModal && documentToReplace && (
<div className="fixed inset-0 z-50 overflow-y-auto"> <div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> <div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={handleCloseReplaceModal} /> <div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={handleCloseReplaceModal}
/>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> <div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start"> <div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10"> <div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg> </svg>
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1"> <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 className="text-lg leading-6 font-medium text-gray-900">{t('replaceDocument.modalTitle')}</h3> <h3 className="text-lg leading-6 font-medium text-gray-900">
{t('replaceDocument.modalTitle')}
</h3>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div className="bg-gray-50 rounded-lg p-3"> <div className="bg-gray-50 rounded-lg p-3">
<p className="text-sm text-gray-500">{t('replaceDocument.currentDocument')}</p> <p className="text-sm text-gray-500">
<p className="text-sm font-medium text-gray-900 mt-1">{documentToReplace.fileName}</p> {t('replaceDocument.currentDocument')}
</p>
<p className="text-sm font-medium text-gray-900 mt-1">
{documentToReplace.fileName}
</p>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
{t('replaceDocument.booking')}: {documentToReplace.quoteNumber} - {documentToReplace.route} {t('replaceDocument.booking')}: {documentToReplace.quoteNumber} -{' '}
{documentToReplace.route}
</p> </p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('replaceDocument.newFile')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">
{t('replaceDocument.newFile')}
</label>
<input <input
ref={replaceFileInputRef} ref={replaceFileInputRef}
type="file" type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif" accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/> />
<p className="mt-1 text-xs text-gray-500">{t('replaceDocument.acceptedFormats')}</p> <p className="mt-1 text-xs text-gray-500">
{t('replaceDocument.acceptedFormats')}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -765,13 +907,30 @@ export default function UserDocumentsPage() {
> >
{replacingFile ? ( {replacingFile ? (
<> <>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg> </svg>
{t('replaceDocument.replacing')} {t('replaceDocument.replacing')}
</> </>
) : t('replaceDocument.replace')} ) : (
t('replaceDocument.replace')
)}
</button> </button>
<button <button
type="button" type="button"

View File

@ -52,17 +52,39 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return null; return null;
} }
const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: PlanFeature }> = [ const navigation: Array<{
name: string;
href: string;
icon: any;
requiredFeature?: PlanFeature;
}> = [
{ name: t('nav.dashboard'), href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' }, { name: t('nav.dashboard'), href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
{ name: t('nav.bookings'), href: '/dashboard/bookings', icon: Package }, { name: t('nav.bookings'), href: '/dashboard/bookings', icon: Package },
{ name: t('nav.documents'), href: '/dashboard/documents', icon: FileText }, { name: t('nav.documents'), href: '/dashboard/documents', icon: FileText },
{ name: t('nav.tracking'), href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' }, {
name: t('nav.tracking'),
href: '/dashboard/track-trace',
icon: Search,
requiredFeature: 'dashboard',
},
{ name: t('nav.wiki'), href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' }, { name: t('nav.wiki'), href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
{ name: t('nav.organization'), href: '/dashboard/settings/organization', icon: Building2 }, { name: t('nav.organization'), href: '/dashboard/settings/organization', icon: Building2 },
{ name: t('nav.apiKeys'), href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature }, {
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [ name: t('nav.apiKeys'),
{ name: t('nav.users'), href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature }, href: '/dashboard/settings/api-keys',
] : []), icon: Key,
requiredFeature: 'api_access' as PlanFeature,
},
...(user?.role === 'ADMIN' || user?.role === 'MANAGER'
? [
{
name: t('nav.users'),
href: '/dashboard/settings/users',
icon: Users,
requiredFeature: 'user_management' as PlanFeature,
},
]
: []),
]; ];
const isActive = (href: string) => { const isActive = (href: string) => {
@ -89,14 +111,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex items-center justify-between h-16 px-6 border-b"> <div className="flex items-center justify-between h-16 px-6 border-b">
<Link href="/dashboard" className="text-2xl font-bold text-blue-600"> <Link href="/dashboard" className="text-2xl font-bold text-blue-600">
<Image <Image
src="/assets/logos/logo-black.svg" src="/assets/logos/logo-black.svg"
alt="Xpeditis" alt="Xpeditis"
width={50} width={50}
height={60} height={60}
priority priority
className="h-auto" className="h-auto"
/> />
</Link> </Link>
<button <button
className="lg:hidden text-gray-500 hover:text-gray-700" className="lg:hidden text-gray-500 hover:text-gray-700"
@ -153,9 +175,10 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<p className="text-sm font-medium text-gray-900 truncate"> <p className="text-sm font-medium text-gray-900 truncate">
{user?.firstName} {user?.lastName} {user?.firstName} {user?.lastName}
</p> </p>
{subscription?.planDetails?.statusBadge && subscription.planDetails.statusBadge !== 'none' && ( {subscription?.planDetails?.statusBadge &&
<StatusBadge badge={subscription.planDetails.statusBadge} size="sm" /> subscription.planDetails.statusBadge !== 'none' && (
)} <StatusBadge badge={subscription.planDetails.statusBadge} size="sm" />
)}
</div> </div>
<p className="text-xs text-gray-500 truncate">{user?.email}</p> <p className="text-xs text-gray-500 truncate">{user?.email}</p>
</div> </div>
@ -195,8 +218,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<LanguageSwitcher variant="light" /> <LanguageSwitcher variant="light" />
<NotificationDropdown /> <NotificationDropdown />
<Link href="/dashboard/profile" className="w-8 h-8 lg:w-9 lg:h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors"> <Link
{user?.firstName?.[0]}{user?.lastName?.[0]} href="/dashboard/profile"
className="w-8 h-8 lg:w-9 lg:h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors"
>
{user?.firstName?.[0]}
{user?.lastName?.[0]}
</Link> </Link>
</div> </div>
</div> </div>
@ -212,8 +239,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{ href: '/dashboard/documents', icon: FileText, label: t('bottomNav.documents') }, { href: '/dashboard/documents', icon: FileText, label: t('bottomNav.documents') },
{ href: '/dashboard/track-trace', icon: Search, label: t('bottomNav.tracking') }, { href: '/dashboard/track-trace', icon: Search, label: t('bottomNav.tracking') },
{ href: '/dashboard/profile', icon: User, label: t('bottomNav.profile') }, { href: '/dashboard/profile', icon: User, label: t('bottomNav.profile') },
].map((item) => { ].map(item => {
const active = item.href === '/dashboard' ? pathname === item.href : pathname.startsWith(item.href); const active =
item.href === '/dashboard' ? pathname === item.href : pathname.startsWith(item.href);
return ( return (
<Link <Link
key={item.href} key={item.href}

View File

@ -11,7 +11,24 @@ import {
deleteNotification, deleteNotification,
} from '@/lib/api'; } from '@/lib/api';
import type { NotificationResponse } from '@/types/api'; import type { NotificationResponse } from '@/types/api';
import { Trash2, CheckCheck, Filter, Bell, ChevronLeft, ChevronRight, Package, RefreshCw, XCircle, CheckCircle, Mail, Clock, FileText, Megaphone, User, Building2 } from 'lucide-react'; import {
Trash2,
CheckCheck,
Filter,
Bell,
ChevronLeft,
ChevronRight,
Package,
RefreshCw,
XCircle,
CheckCircle,
Mail,
Clock,
FileText,
Megaphone,
User,
Building2,
} from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
export default function NotificationsPage() { export default function NotificationsPage() {
@ -83,7 +100,9 @@ export default function NotificationsPage() {
medium: 'border-l-4 border-yellow-500 bg-yellow-50 hover:bg-yellow-100', medium: 'border-l-4 border-yellow-500 bg-yellow-50 hover:bg-yellow-100',
low: 'border-l-4 border-blue-500 bg-blue-50 hover:bg-blue-100', low: 'border-l-4 border-blue-500 bg-blue-50 hover:bg-blue-100',
}; };
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-100'; return (
colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-100'
);
}; };
const getPriorityLabel = (priority: string) => { const getPriorityLabel = (priority: string) => {
@ -97,7 +116,7 @@ export default function NotificationsPage() {
}; };
const getNotificationIcon = (type: string): ReactNode => { const getNotificationIcon = (type: string): ReactNode => {
const iconClass = "h-8 w-8"; const iconClass = 'h-8 w-8';
const icons: Record<string, ReactNode> = { const icons: Record<string, ReactNode> = {
booking_created: <Package className={`${iconClass} text-blue-600`} />, booking_created: <Package className={`${iconClass} text-blue-600`} />,
booking_updated: <RefreshCw className={`${iconClass} text-orange-500`} />, booking_updated: <RefreshCw className={`${iconClass} text-orange-500`} />,
@ -173,7 +192,7 @@ export default function NotificationsPage() {
<Filter className="w-5 h-5 text-gray-500" /> <Filter className="w-5 h-5 text-gray-500" />
<span className="text-sm font-medium text-gray-700">{t('filter.label')}</span> <span className="text-sm font-medium text-gray-700">{t('filter.label')}</span>
<div className="flex space-x-2"> <div className="flex space-x-2">
{(['all', 'unread', 'read'] as const).map((filter) => ( {(['all', 'unread', 'read'] as const).map(filter => (
<button <button
key={filter} key={filter}
onClick={() => { onClick={() => {
@ -209,7 +228,9 @@ export default function NotificationsPage() {
) : notifications.length === 0 ? ( ) : notifications.length === 0 ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<div className="text-center"> <div className="text-center">
<div className="mb-4 flex justify-center"><Bell className="h-16 w-16 text-gray-300" /></div> <div className="mb-4 flex justify-center">
<Bell className="h-16 w-16 text-gray-300" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">{t('empty.title')}</h3> <h3 className="text-xl font-semibold text-gray-900 mb-2">{t('empty.title')}</h3>
<p className="text-gray-500"> <p className="text-gray-500">
{selectedFilter === 'unread' ? t('empty.upToDate') : t('empty.none')} {selectedFilter === 'unread' ? t('empty.upToDate') : t('empty.none')}
@ -245,7 +266,7 @@ export default function NotificationsPage() {
)} )}
</div> </div>
<button <button
onClick={(e) => handleDelete(e, notification.id)} onClick={e => handleDelete(e, notification.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg" className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg"
title={t('deleteTitle')} title={t('deleteTitle')}
> >
@ -273,7 +294,9 @@ export default function NotificationsPage() {
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
<span className="font-medium">{formatTime(notification.createdAt)}</span> <span className="font-medium">
{formatTime(notification.createdAt)}
</span>
</span> </span>
<span className="px-3 py-1 bg-gray-100 rounded-full text-gray-700 font-medium"> <span className="px-3 py-1 bg-gray-100 rounded-full text-gray-700 font-medium">
{notification.type.replace(/_/g, ' ').toUpperCase()} {notification.type.replace(/_/g, ' ').toUpperCase()}
@ -284,10 +307,10 @@ export default function NotificationsPage() {
notification.priority === 'urgent' notification.priority === 'urgent'
? 'bg-red-100 text-red-700' ? 'bg-red-100 text-red-700'
: notification.priority === 'high' : notification.priority === 'high'
? 'bg-orange-100 text-orange-700' ? 'bg-orange-100 text-orange-700'
: notification.priority === 'medium' : notification.priority === 'medium'
? 'bg-yellow-100 text-yellow-700' ? 'bg-yellow-100 text-yellow-700'
: 'bg-blue-100 text-blue-700' : 'bg-blue-100 text-blue-700'
}`} }`}
> >
{getPriorityLabel(notification.priority)} {getPriorityLabel(notification.priority)}
@ -329,12 +352,12 @@ export default function NotificationsPage() {
current: currentPage, current: currentPage,
total: totalPages, total: totalPages,
items: total, items: total,
b: (chunks) => <span className="font-semibold">{chunks}</span>, b: chunks => <span className="font-semibold">{chunks}</span>,
})} })}
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<button <button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
@ -370,7 +393,7 @@ export default function NotificationsPage() {
})} })}
</div> </div>
<button <button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >

View File

@ -80,9 +80,7 @@ export default function DashboardPage() {
<div className="flex items-center justify-between pb-3 sm:pb-4 border-b border-gray-200"> <div className="flex items-center justify-between pb-3 sm:pb-4 border-b border-gray-200">
<div> <div>
<h1 className="text-xl sm:text-3xl font-semibold text-gray-900">{t('title')}</h1> <h1 className="text-xl sm:text-3xl font-semibold text-gray-900">{t('title')}</h1>
<p className="text-gray-600 mt-0.5 sm:mt-1 text-xs sm:text-sm"> <p className="text-gray-600 mt-0.5 sm:mt-1 text-xs sm:text-sm">{t('subtitle')}</p>
{t('subtitle')}
</p>
</div> </div>
<div className="flex items-center space-x-2 sm:space-x-3"> <div className="flex items-center space-x-2 sm:space-x-3">
@ -94,10 +92,26 @@ export default function DashboardPage() {
{ key: 'totalBookings', label: t('export.totalBookings') }, { key: 'totalBookings', label: t('export.totalBookings') },
{ key: 'acceptedBookings', label: t('export.accepted') }, { key: 'acceptedBookings', label: t('export.accepted') },
{ key: 'rejectedBookings', label: t('export.rejected') }, { key: 'rejectedBookings', label: t('export.rejected') },
{ key: 'totalWeightKG', label: t('export.totalWeight'), format: (v) => v?.toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-US') || '0' }, {
{ key: 'totalVolumeCBM', label: t('export.totalVolume'), format: (v) => v?.toFixed(2) || '0' }, key: 'totalWeightKG',
{ key: 'acceptanceRate', label: t('export.acceptanceRate'), format: (v) => v?.toFixed(1) || '0' }, label: t('export.totalWeight'),
{ key: 'avgPriceUSD', label: t('export.avgPrice'), format: (v) => v?.toFixed(2) || '0' }, format: v => v?.toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-US') || '0',
},
{
key: 'totalVolumeCBM',
label: t('export.totalVolume'),
format: v => v?.toFixed(2) || '0',
},
{
key: 'acceptanceRate',
label: t('export.acceptanceRate'),
format: v => v?.toFixed(1) || '0',
},
{
key: 'avgPriceUSD',
label: t('export.avgPrice'),
format: v => v?.toFixed(2) || '0',
},
]} ]}
/> />
<Link href="/dashboard/search-advanced"> <Link href="/dashboard/search-advanced">
@ -167,9 +181,7 @@ export default function DashboardPage() {
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" /> <div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : ( ) : (
<> <>
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">{csvKpis?.totalPending || 0}</p>
{csvKpis?.totalPending || 0}
</p>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
{t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })} {t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })}
</p> </p>
@ -282,7 +294,9 @@ export default function DashboardPage() {
<div className="h-10 w-10 rounded-lg bg-green-100 flex items-center justify-center mb-2"> <div className="h-10 w-10 rounded-lg bg-green-100 flex items-center justify-center mb-2">
<TrendingUp className="h-5 w-5 text-green-600" /> <TrendingUp className="h-5 w-5 text-green-600" />
</div> </div>
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.acceptanceRate')}</p> <p className="text-xs font-medium text-gray-600 mb-1">
{t('performance.acceptanceRate')}
</p>
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
{csvKpisLoading ? '--' : `${(csvKpis?.acceptanceRate ?? 0).toFixed(1)}%`} {csvKpisLoading ? '--' : `${(csvKpis?.acceptanceRate ?? 0).toFixed(1)}%`}
</p> </p>
@ -296,7 +310,9 @@ export default function DashboardPage() {
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2"> <div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
<Package className="h-5 w-5 text-blue-600" /> <Package className="h-5 w-5 text-blue-600" />
</div> </div>
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.totalBookings')}</p> <p className="text-xs font-medium text-gray-600 mb-1">
{t('performance.totalBookings')}
</p>
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
{csvKpisLoading {csvKpisLoading
? '--' ? '--'
@ -314,7 +330,9 @@ export default function DashboardPage() {
<div className="h-10 w-10 rounded-lg bg-purple-100 flex items-center justify-center mb-2"> <div className="h-10 w-10 rounded-lg bg-purple-100 flex items-center justify-center mb-2">
<Weight className="h-5 w-5 text-purple-600" /> <Weight className="h-5 w-5 text-purple-600" />
</div> </div>
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.totalVolume')}</p> <p className="text-xs font-medium text-gray-600 mb-1">
{t('performance.totalVolume')}
</p>
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
{csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`} {csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`}
<span className="text-sm font-normal text-gray-500 ml-1">CBM</span> <span className="text-sm font-normal text-gray-500 ml-1">CBM</span>
@ -373,7 +391,9 @@ export default function DashboardPage() {
{carrier.carrierName} {carrier.carrierName}
</h3> </h3>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5"> <div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
<span>{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}</span> <span>
{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}
</span>
<span></span> <span></span>
<span>{numberFormat.format(carrier.totalWeightKG)} KG</span> <span>{numberFormat.format(carrier.totalWeightKG)} KG</span>
</div> </div>

View File

@ -251,7 +251,10 @@ export default function ProfilePage() {
<form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6"> <form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2"> <label
htmlFor="firstName"
className="block text-sm font-medium text-gray-700 mb-2"
>
{t('profileForm.firstName')} {t('profileForm.firstName')}
</label> </label>
<input <input
@ -268,7 +271,10 @@ export default function ProfilePage() {
</div> </div>
<div> <div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2"> <label
htmlFor="lastName"
className="block text-sm font-medium text-gray-700 mb-2"
>
{t('profileForm.lastName')} {t('profileForm.lastName')}
</label> </label>
<input <input
@ -305,14 +311,19 @@ export default function ProfilePage() {
disabled={updateProfileMutation.isPending} disabled={updateProfileMutation.isPending}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed" className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{updateProfileMutation.isPending ? t('profileForm.saving') : t('profileForm.save')} {updateProfileMutation.isPending
? t('profileForm.saving')
: t('profileForm.save')}
</button> </button>
</div> </div>
</form> </form>
) : ( ) : (
<form onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)} className="space-y-6"> <form onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)} className="space-y-6">
<div> <div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700 mb-2"> <label
htmlFor="currentPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
{t('passwordForm.current')} {t('passwordForm.current')}
</label> </label>
<input <input
@ -330,7 +341,10 @@ export default function ProfilePage() {
</div> </div>
<div> <div>
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-2"> <label
htmlFor="newPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
{t('passwordForm.new')} {t('passwordForm.new')}
</label> </label>
<input <input
@ -349,7 +363,10 @@ export default function ProfilePage() {
</div> </div>
<div> <div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2"> <label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
{t('passwordForm.confirm')} {t('passwordForm.confirm')}
</label> </label>
<input <input
@ -372,7 +389,9 @@ export default function ProfilePage() {
disabled={updatePasswordMutation.isPending} disabled={updatePasswordMutation.isPending}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed" className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{updatePasswordMutation.isPending ? t('passwordForm.submitting') : t('passwordForm.submit')} {updatePasswordMutation.isPending
? t('passwordForm.submitting')
: t('passwordForm.submit')}
</button> </button>
</div> </div>
</form> </form>

View File

@ -5,16 +5,16 @@ import { useRouter } from '@/i18n/navigation';
import { Search, Loader2 } from 'lucide-react'; import { Search, Loader2 } 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 { import { getAvailableOrigins, getAvailableDestinations, RoutePortInfo } from '@/lib/api/rates';
getAvailableOrigins,
getAvailableDestinations,
RoutePortInfo,
} from '@/lib/api/rates';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
const PortRouteMapLoader = () => { const PortRouteMapLoader = () => {
const t = useTranslations('dashboard.rateSearch'); const t = useTranslations('dashboard.rateSearch');
return <div className="h-80 bg-gray-100 rounded-lg flex items-center justify-center">{t('mapLoading')}</div>; return (
<div className="h-80 bg-gray-100 rounded-lg flex items-center justify-center">
{t('mapLoading')}
</div>
);
}; };
const PortRouteMap = dynamic(() => import('@/components/PortRouteMap'), { const PortRouteMap = dynamic(() => import('@/components/PortRouteMap'), {
@ -87,7 +87,9 @@ export default function AdvancedSearchPage() {
const [showOriginDropdown, setShowOriginDropdown] = useState(false); const [showOriginDropdown, setShowOriginDropdown] = useState(false);
const [showDestinationDropdown, setShowDestinationDropdown] = useState(false); const [showDestinationDropdown, setShowDestinationDropdown] = useState(false);
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null); const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(null); const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(
null
);
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({ const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
queryKey: ['available-origins'], queryKey: ['available-origins'],
@ -211,7 +213,10 @@ export default function AdvancedSearchPage() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="relative"> <div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
{t('step1.originLabel')} {searchForm.origin && <span className="text-green-600 text-xs">{t('step1.selected')}</span>} {t('step1.originLabel')}{' '}
{searchForm.origin && (
<span className="text-green-600 text-xs">{t('step1.selected')}</span>
)}
</label> </label>
<div className="relative"> <div className="relative">
<input <input
@ -269,16 +274,24 @@ export default function AdvancedSearchPage() {
)} )}
</div> </div>
)} )}
{showOriginDropdown && filteredOrigins.length === 0 && !isLoadingOrigins && originsData && ( {showOriginDropdown &&
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50"> filteredOrigins.length === 0 &&
<p className="text-sm text-gray-500">{t('step1.noOrigin', { query: originSearch })}</p> !isLoadingOrigins &&
</div> originsData && (
)} <div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
<p className="text-sm text-gray-500">
{t('step1.noOrigin', { query: originSearch })}
</p>
</div>
)}
</div> </div>
<div className="relative"> <div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
{t('step1.destinationLabel')} {searchForm.destination && <span className="text-green-600 text-xs">{t('step1.selected')}</span>} {t('step1.destinationLabel')}{' '}
{searchForm.destination && (
<span className="text-green-600 text-xs">{t('step1.selected')}</span>
)}
</label> </label>
<div className="relative"> <div className="relative">
<input <input
@ -287,7 +300,10 @@ export default function AdvancedSearchPage() {
onChange={e => { onChange={e => {
setDestinationSearch(e.target.value); setDestinationSearch(e.target.value);
setShowDestinationDropdown(true); setShowDestinationDropdown(true);
if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) { if (
selectedDestinationPort &&
e.target.value !== selectedDestinationPort.displayName
) {
setSearchForm({ ...searchForm, destination: '' }); setSearchForm({ ...searchForm, destination: '' });
setSelectedDestinationPort(null); setSelectedDestinationPort(null);
} }
@ -295,7 +311,11 @@ export default function AdvancedSearchPage() {
onFocus={() => setShowDestinationDropdown(true)} onFocus={() => setShowDestinationDropdown(true)}
onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)} onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
disabled={!searchForm.origin} disabled={!searchForm.origin}
placeholder={searchForm.origin ? t('step1.destinationPlaceholder') : t('step1.destinationDisabled')} placeholder={
searchForm.origin
? t('step1.destinationPlaceholder')
: t('step1.destinationDisabled')
}
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${ className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300' searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300'
} ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`} } ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
@ -308,7 +328,10 @@ export default function AdvancedSearchPage() {
</div> </div>
{searchForm.origin && destinationsData?.total !== undefined && ( {searchForm.origin && destinationsData?.total !== undefined && (
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
{t('step1.availableDestinations', { count: destinationsData.total, port: selectedOriginPort?.name || searchForm.origin })} {t('step1.availableDestinations', {
count: destinationsData.total,
port: selectedOriginPort?.name || searchForm.origin,
})}
</p> </p>
)} )}
{showDestinationDropdown && filteredDestinations.length > 0 && ( {showDestinationDropdown && filteredDestinations.length > 0 && (
@ -338,37 +361,47 @@ export default function AdvancedSearchPage() {
)} )}
</div> </div>
)} )}
{showDestinationDropdown && filteredDestinations.length === 0 && !isLoadingDestinations && searchForm.origin && destinationsData && ( {showDestinationDropdown &&
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50"> filteredDestinations.length === 0 &&
<p className="text-sm text-gray-500">{t('step1.noDestination', { query: destinationSearch })}</p> !isLoadingDestinations &&
</div> searchForm.origin &&
)} destinationsData && (
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
<p className="text-sm text-gray-500">
{t('step1.noDestination', { query: destinationSearch })}
</p>
</div>
)}
</div> </div>
</div> </div>
{selectedOriginPort && selectedDestinationPort && selectedOriginPort.latitude && selectedDestinationPort.latitude && ( {selectedOriginPort &&
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden"> selectedDestinationPort &&
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200"> selectedOriginPort.latitude &&
<h3 className="text-sm font-semibold text-gray-900"> selectedDestinationPort.latitude && (
{t('step1.routeTitle', { origin: selectedOriginPort.name, destination: selectedDestinationPort.name })} <div className="mt-6 border border-gray-200 rounded-lg overflow-hidden">
</h3> <div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<p className="text-xs text-gray-500 mt-1"> <h3 className="text-sm font-semibold text-gray-900">
{t('step1.routeDescription')} {t('step1.routeTitle', {
</p> origin: selectedOriginPort.name,
destination: selectedDestinationPort.name,
})}
</h3>
<p className="text-xs text-gray-500 mt-1">{t('step1.routeDescription')}</p>
</div>
<PortRouteMap
portA={{
lat: selectedOriginPort.latitude,
lng: selectedOriginPort.longitude!,
}}
portB={{
lat: selectedDestinationPort.latitude,
lng: selectedDestinationPort.longitude!,
}}
height="400px"
/>
</div> </div>
<PortRouteMap )}
portA={{
lat: selectedOriginPort.latitude,
lng: selectedOriginPort.longitude!,
}}
portB={{
lat: selectedDestinationPort.latitude,
lng: selectedDestinationPort.longitude!,
}}
height="400px"
/>
</div>
)}
</div> </div>
); );
@ -390,7 +423,9 @@ export default function AdvancedSearchPage() {
{searchForm.packages.map((pkg, index) => ( {searchForm.packages.map((pkg, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4 space-y-4"> <div key={index} className="border border-gray-200 rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900">{t('step2.packageNumber', { number: index + 1 })}</h3> <h3 className="font-medium text-gray-900">
{t('step2.packageNumber', { number: index + 1 })}
</h3>
{searchForm.packages.length > 1 && ( {searchForm.packages.length > 1 && (
<button <button
type="button" type="button"
@ -404,7 +439,9 @@ export default function AdvancedSearchPage() {
<div className="grid grid-cols-5 gap-3"> <div className="grid grid-cols-5 gap-3">
<div> <div>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.type')}</label> <label className="block text-xs font-medium text-gray-700 mb-1">
{t('step2.type')}
</label>
<select <select
value={pkg.type} value={pkg.type}
onChange={e => updatePackage(index, 'type', e.target.value)} onChange={e => updatePackage(index, 'type', e.target.value)}
@ -418,7 +455,9 @@ export default function AdvancedSearchPage() {
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.quantity')}</label> <label className="block text-xs font-medium text-gray-700 mb-1">
{t('step2.quantity')}
</label>
<input <input
type="number" type="number"
min="1" min="1"
@ -429,7 +468,9 @@ export default function AdvancedSearchPage() {
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.length')}</label> <label className="block text-xs font-medium text-gray-700 mb-1">
{t('step2.length')}
</label>
<input <input
type="number" type="number"
min="1" min="1"
@ -440,7 +481,9 @@ export default function AdvancedSearchPage() {
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.width')}</label> <label className="block text-xs font-medium text-gray-700 mb-1">
{t('step2.width')}
</label>
<input <input
type="number" type="number"
min="1" min="1"
@ -451,7 +494,9 @@ export default function AdvancedSearchPage() {
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.height')}</label> <label className="block text-xs font-medium text-gray-700 mb-1">
{t('step2.height')}
</label>
<input <input
type="number" type="number"
min="1" min="1"
@ -464,7 +509,9 @@ export default function AdvancedSearchPage() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.weight')}</label> <label className="block text-xs font-medium text-gray-700 mb-1">
{t('step2.weight')}
</label>
<input <input
type="number" type="number"
min="1" min="1"
@ -538,12 +585,12 @@ export default function AdvancedSearchPage() {
<input <input
type="checkbox" type="checkbox"
checked={searchForm.exportAssistance} checked={searchForm.exportAssistance}
onChange={e => onChange={e => setSearchForm({ ...searchForm, exportAssistance: e.target.checked })}
setSearchForm({ ...searchForm, exportAssistance: e.target.checked })
}
className="h-4 w-4 text-blue-600 border-gray-300 rounded" className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/> />
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.exportAssistance')}</span> <span className="ml-2 text-sm text-gray-700">
{t('step3.customs.exportAssistance')}
</span>
</label> </label>
</div> </div>
</div> </div>
@ -581,9 +628,7 @@ export default function AdvancedSearchPage() {
<input <input
type="checkbox" type="checkbox"
checked={searchForm.specialHandling} checked={searchForm.specialHandling}
onChange={e => onChange={e => setSearchForm({ ...searchForm, specialHandling: e.target.checked })}
setSearchForm({ ...searchForm, specialHandling: e.target.checked })
}
className="h-4 w-4 text-blue-600 border-gray-300 rounded" className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/> />
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.special')}</span> <span className="ml-2 text-sm text-gray-700">{t('step3.handling.special')}</span>
@ -649,9 +694,7 @@ export default function AdvancedSearchPage() {
<div className="max-w-7xl mx-auto space-y-6"> <div className="max-w-7xl mx-auto space-y-6">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1> <h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">{t('subtitle')}</p>
{t('subtitle')}
</p>
</div> </div>
<div className="flex items-center justify-center space-x-4"> <div className="flex items-center justify-center space-x-4">
@ -666,9 +709,7 @@ export default function AdvancedSearchPage() {
</div> </div>
{step < 3 && ( {step < 3 && (
<div <div
className={`w-20 h-1 mx-2 ${ className={`w-20 h-1 mx-2 ${currentStep > step ? 'bg-blue-600' : 'bg-gray-200'}`}
currentStep > step ? 'bg-blue-600' : 'bg-gray-200'
}`}
/> />
)} )}
</div> </div>
@ -711,7 +752,6 @@ export default function AdvancedSearchPage() {
)} )}
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@ -6,7 +6,16 @@ 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 type { CsvRateSearchResult } from '@/types/rates'; import type { CsvRateSearchResult } from '@/types/rates';
import { Search, Lightbulb, DollarSign, Scale, Zap, Trophy, XCircle, AlertTriangle } from 'lucide-react'; import {
Search,
Lightbulb,
DollarSign,
Scale,
Zap,
Trophy,
XCircle,
AlertTriangle,
} from 'lucide-react';
interface BestOptions { interface BestOptions {
eco: CsvRateSearchResult; eco: CsvRateSearchResult;
@ -76,8 +85,12 @@ export default function SearchResultsPage() {
}; };
} }
const sorted = [...results].sort((a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting); const sorted = [...results].sort(
const fastest = [...results].sort((a, b) => (a.adjustedTransitDays ?? a.transitDays) - (b.adjustedTransitDays ?? b.transitDays)); (a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting
);
const fastest = [...results].sort(
(a, b) => (a.adjustedTransitDays ?? a.transitDays) - (b.adjustedTransitDays ?? b.transitDays)
);
return { return {
eco: sorted[0], eco: sorted[0],
@ -116,7 +129,9 @@ export default function SearchResultsPage() {
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4"> <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center"> <div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
<div className="mb-4 flex justify-center"><XCircle className="h-16 w-16 text-red-500" /></div> <div className="mb-4 flex justify-center">
<XCircle className="h-16 w-16 text-red-500" />
</div>
<h3 className="text-xl font-bold text-red-900 mb-2">{t('errorTitle')}</h3> <h3 className="text-xl font-bold text-red-900 mb-2">{t('errorTitle')}</h3>
<p className="text-red-700 mb-4">{error}</p> <p className="text-red-700 mb-4">{error}</p>
<button <button
@ -143,11 +158,11 @@ export default function SearchResultsPage() {
</button> </button>
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center"> <div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
<div className="mb-4 flex justify-center"><Search className="h-16 w-16 text-yellow-500" /></div> <div className="mb-4 flex justify-center">
<Search className="h-16 w-16 text-yellow-500" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">{t('noResultsTitle')}</h3> <h3 className="text-xl font-bold text-gray-900 mb-2">{t('noResultsTitle')}</h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">{t('noResultsMessage', { origin, destination })}</p>
{t('noResultsMessage', { origin, destination })}
</p>
<div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6"> <div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6">
<h4 className="font-semibold text-gray-900 mb-2 flex items-center"> <h4 className="font-semibold text-gray-900 mb-2 flex items-center">
<Lightbulb className="h-5 w-5 mr-2 text-yellow-500" /> {t('suggestions')} <Lightbulb className="h-5 w-5 mr-2 text-yellow-500" /> {t('suggestions')}
@ -226,10 +241,14 @@ export default function SearchResultsPage() {
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('resultsTitle')}</h1> <h1 className="text-3xl font-bold text-gray-900 mb-2">{t('resultsTitle')}</h1>
<p className="text-gray-600"> <p className="text-gray-600">
<span className="font-semibold">{origin}</span> <span className="font-semibold">{destination}</span>{' '} <span className="font-semibold">{origin}</span> {' '}
{' '} <span className="font-semibold">{destination}</span> {' '}
{palletCount > 0 {palletCount > 0
? t('summaryWithPallets', { volume: volumeCBM, weight: weightKG, count: palletCount }) ? t('summaryWithPallets', {
volume: volumeCBM,
weight: weightKG,
count: palletCount,
})
: t('summary', { volume: volumeCBM, weight: weightKG })} : t('summary', { volume: volumeCBM, weight: weightKG })}
</p> </p>
</div> </div>
@ -274,23 +293,31 @@ export default function SearchResultsPage() {
<div className="bg-white rounded-lg p-4 mb-4"> <div className="bg-white rounded-lg p-4 mb-4">
<div className="text-center mb-3"> <div className="text-center mb-3">
<p className="text-sm text-gray-600 mb-1">{t('totalPrice')}</p> <p className="text-sm text-gray-600 mb-1">{t('totalPrice')}</p>
<p className="text-3xl font-bold text-gray-900">{formatPrice(card.option.priceBreakdown.totalPriceForSorting)}</p> <p className="text-3xl font-bold text-gray-900">
{formatPrice(card.option.priceBreakdown.totalPriceForSorting)}
</p>
</div> </div>
<div className="border-t border-gray-200 pt-3 space-y-2 text-sm"> <div className="border-t border-gray-200 pt-3 space-y-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">{t('carrier')}</span> <span className="text-gray-600">{t('carrier')}</span>
<span className="font-semibold text-gray-900">{card.option.companyName}</span> <span className="font-semibold text-gray-900">
{card.option.companyName}
</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">{t('transit')}</span> <span className="text-gray-600">{t('transit')}</span>
<span className="font-semibold text-gray-900"> <span className="font-semibold text-gray-900">
{t('transitDays', { days: card.option.adjustedTransitDays ?? card.option.transitDays })} {t('transitDays', {
days: card.option.adjustedTransitDays ?? card.option.transitDays,
})}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">{t('type')}</span> <span className="text-gray-600">{t('type')}</span>
<span className="font-semibold text-gray-900">{card.option.containerType}</span> <span className="font-semibold text-gray-900">
{card.option.containerType}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -298,7 +325,9 @@ export default function SearchResultsPage() {
<button <button
onClick={() => { onClick={() => {
const rateData = encodeURIComponent(JSON.stringify(card.option)); const rateData = encodeURIComponent(JSON.stringify(card.option));
router.push(`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`); router.push(
`/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`} className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors`}
> >
@ -314,11 +343,16 @@ export default function SearchResultsPage() {
{/* All Results */} {/* All Results */}
<div> <div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">{t('allResults', { count: results.length })}</h2> <h2 className="text-2xl font-bold text-gray-900 mb-6">
{t('allResults', { count: results.length })}
</h2>
<div className="space-y-4"> <div className="space-y-4">
{results.map((result, index) => ( {results.map((result, index) => (
<div key={index} className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow"> <div
key={index}
className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow"
>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
<h3 className="text-xl font-bold text-gray-900">{result.companyName}</h3> <h3 className="text-xl font-bold text-gray-900">{result.companyName}</h3>
@ -327,20 +361,26 @@ export default function SearchResultsPage() {
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-3xl font-bold text-blue-600">{formatPrice(result.priceBreakdown.totalPriceForSorting)}</p> <p className="text-3xl font-bold text-blue-600">
{formatPrice(result.priceBreakdown.totalPriceForSorting)}
</p>
<p className="text-sm text-gray-500">{t('totalPrice')}</p> <p className="text-sm text-gray-500">{t('totalPrice')}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="bg-gray-50 rounded-lg p-3"> <div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">Fret ({result.priceBreakdown.freightCurrency})</p> <p className="text-xs text-gray-600 mb-1">
Fret ({result.priceBreakdown.freightCurrency})
</p>
<p className="font-semibold text-gray-900"> <p className="font-semibold text-gray-900">
{formatPrice(result.priceBreakdown.totalFreight)} {formatPrice(result.priceBreakdown.totalFreight)}
</p> </p>
</div> </div>
<div className="bg-gray-50 rounded-lg p-3"> <div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">FOB ({result.priceBreakdown.fobCurrency})</p> <p className="text-xs text-gray-600 mb-1">
FOB ({result.priceBreakdown.fobCurrency})
</p>
<p className="font-semibold text-gray-900"> <p className="font-semibold text-gray-900">
{formatPrice(result.priceBreakdown.totalFob)} {formatPrice(result.priceBreakdown.totalFob)}
</p> </p>
@ -359,7 +399,11 @@ export default function SearchResultsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-sm text-gray-600"> <div className="flex items-center space-x-4 text-sm text-gray-600">
<span>{t('validUntil', { date: new Date(result.validUntil).toLocaleDateString(dateLocale) })}</span> <span>
{t('validUntil', {
date: new Date(result.validUntil).toLocaleDateString(dateLocale),
})}
</span>
{result.dgSurchargeStatus === 'not_accepted' && ( {result.dgSurchargeStatus === 'not_accepted' && (
<span className="text-orange-600 flex items-center"> <span className="text-orange-600 flex items-center">
<AlertTriangle className="h-4 w-4 mr-1" /> DG non accepté <AlertTriangle className="h-4 w-4 mr-1" /> DG non accepté
@ -374,7 +418,9 @@ export default function SearchResultsPage() {
<button <button
onClick={() => { onClick={() => {
const rateData = encodeURIComponent(JSON.stringify(result)); const rateData = encodeURIComponent(JSON.stringify(result));
router.push(`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`); router.push(
`/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" className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
> >

View File

@ -136,7 +136,9 @@ export default function RateSearchPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Origin Port */} {/* Origin Port */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">Port d'origine *</label> <label className="block text-sm font-medium text-gray-700 mb-2">
Port d'origine *
</label>
<input <input
type="text" type="text"
required required
@ -317,7 +319,9 @@ export default function RateSearchPage() {
{/* Error */} {/* Error */}
{searchError && ( {searchError && (
<div className="bg-red-50 border border-red-200 rounded-md p-4"> <div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-800">La recherche de tarifs a échoué. Veuillez réessayer.</div> <div className="text-sm text-red-800">
La recherche de tarifs a échoué. Veuillez réessayer.
</div>
</div> </div>
)} )}
@ -341,7 +345,9 @@ export default function RateSearchPage() {
</div> </div>
<div> <div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Fourchette de prix (USD)</h3> <h3 className="text-sm font-semibold text-gray-900 mb-3">
Fourchette de prix (USD)
</h3>
<div className="space-y-2"> <div className="space-y-2">
<input <input
type="range" type="range"
@ -400,7 +406,9 @@ export default function RateSearchPage() {
<div className="lg:col-span-3 space-y-4"> <div className="lg:col-span-3 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900">
{filteredAndSortedQuotes.length} tarif{filteredAndSortedQuotes.length !== 1 ? 's' : ''} trouvé{filteredAndSortedQuotes.length !== 1 ? 's' : ''} {filteredAndSortedQuotes.length} tarif
{filteredAndSortedQuotes.length !== 1 ? 's' : ''} trouvé
{filteredAndSortedQuotes.length !== 1 ? 's' : ''}
</h2> </h2>
</div> </div>
@ -592,9 +600,12 @@ export default function RateSearchPage() {
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/> />
</svg> </svg>
<h3 className="mt-4 text-lg font-medium text-gray-900">Rechercher des tarifs maritimes</h3> <h3 className="mt-4 text-lg font-medium text-gray-900">
Rechercher des tarifs maritimes
</h3>
<p className="mt-2 text-sm text-gray-500"> <p className="mt-2 text-sm text-gray-500">
Saisissez votre origine, destination et détails du conteneur pour comparer les tarifs de plusieurs transporteurs Saisissez votre origine, destination et détails du conteneur pour comparer les tarifs de
plusieurs transporteurs
</p> </p>
</div> </div>
)} )}

View File

@ -169,8 +169,7 @@ function CreateKeyModal({
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1.5"> <label className="block text-sm font-medium text-gray-700 mb-1.5">
{t('expiry')}{' '} {t('expiry')} <span className="text-gray-400 font-normal">{t('optional')}</span>
<span className="text-gray-400 font-normal">{t('optional')}</span>
</label> </label>
<input <input
type="date" type="date"
@ -346,9 +345,7 @@ export default function ApiKeysPage() {
onClose={() => setShowCreateModal(false)} onClose={() => setShowCreateModal(false)}
/> />
)} )}
{createdKey && ( {createdKey && <CreatedKeyModal result={createdKey} onClose={() => setCreatedKey(null)} />}
<CreatedKeyModal result={createdKey} onClose={() => setCreatedKey(null)} />
)}
{revokeTarget && ( {revokeTarget && (
<RevokeConfirmModal <RevokeConfirmModal
apiKey={revokeTarget} apiKey={revokeTarget}

View File

@ -174,7 +174,12 @@ export default function OrganizationSettingsPage() {
label: t('tabs.information'), label: t('tabs.information'),
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
), ),
}, },
@ -183,31 +188,53 @@ export default function OrganizationSettingsPage() {
label: t('tabs.address'), label: t('tabs.address'),
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /> <path
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /> strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg> </svg>
), ),
}, },
...(canViewBilling ? [ ...(canViewBilling
{ ? [
id: 'subscription' as TabType, {
label: t('tabs.subscription'), id: 'subscription' as TabType,
icon: ( label: t('tabs.subscription'),
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> icon: (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <path
), strokeLinecap="round"
}, strokeLinejoin="round"
{ strokeWidth={2}
id: 'licenses' as TabType, d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
label: t('tabs.licenses'), />
icon: ( </svg>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> ),
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /> },
</svg> {
), id: 'licenses' as TabType,
}, label: t('tabs.licenses'),
] : []), icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
),
},
]
: []),
]; ];
return ( return (
@ -220,8 +247,18 @@ export default function OrganizationSettingsPage() {
{successMessage && (activeTab === 'information' || activeTab === 'address') && ( {successMessage && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4"> <div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center"> <div className="flex items-center">
<svg className="w-5 h-5 text-green-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> className="w-5 h-5 text-green-600 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg> </svg>
<p className="text-green-800 font-medium">{successMessage}</p> <p className="text-green-800 font-medium">{successMessage}</p>
</div> </div>
@ -231,8 +268,18 @@ export default function OrganizationSettingsPage() {
{error && (activeTab === 'information' || activeTab === 'address') && ( {error && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4"> <div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center"> <div className="flex items-center">
<svg className="w-5 h-5 text-red-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> className="w-5 h-5 text-red-600 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
<p className="text-red-800 font-medium">{error}</p> <p className="text-red-800 font-medium">{error}</p>
</div> </div>
@ -242,8 +289,18 @@ export default function OrganizationSettingsPage() {
{!canEdit && (activeTab === 'information' || activeTab === 'address') && ( {!canEdit && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4"> <div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center"> <div className="flex items-center">
<svg className="w-5 h-5 text-blue-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> className="w-5 h-5 text-blue-600 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<p className="text-blue-800 font-medium">{t('readOnlyWarning')}</p> <p className="text-blue-800 font-medium">{t('readOnlyWarning')}</p>
</div> </div>
@ -253,7 +310,7 @@ export default function OrganizationSettingsPage() {
<div className="bg-white rounded-lg shadow-md"> <div className="bg-white rounded-lg shadow-md">
<div className="border-b border-gray-200"> <div className="border-b border-gray-200">
<nav className="flex -mb-px overflow-x-auto"> <nav className="flex -mb-px overflow-x-auto">
{tabs.map((tab) => ( {tabs.map(tab => (
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
@ -298,7 +355,9 @@ export default function OrganizationSettingsPage() {
<input <input
type="text" type="text"
value={formData.siren} value={formData.siren}
onChange={e => handleChange('siren', e.target.value.replace(/\D/g, '').slice(0, 9))} onChange={e =>
handleChange('siren', e.target.value.replace(/\D/g, '').slice(0, 9))
}
disabled={!canEdit} disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder={t('information.sirenPlaceholder')} placeholder={t('information.sirenPlaceholder')}
@ -325,7 +384,9 @@ export default function OrganizationSettingsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('information.phone')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">
{t('information.phone')}
</label>
<input <input
type="tel" type="tel"
value={formData.contact_phone} value={formData.contact_phone}
@ -337,7 +398,9 @@ export default function OrganizationSettingsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('information.email')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">
{t('information.email')}
</label>
<input <input
type="email" type="email"
value={formData.contact_email} value={formData.contact_email}

View File

@ -199,7 +199,10 @@ export default function UsersManagementPage() {
}; };
const handleRoleChange = (userId: string, newRole: string) => { const handleRoleChange = (userId: string, newRole: string) => {
changeRoleMutation.mutate({ id: userId, role: newRole as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' }); changeRoleMutation.mutate({
id: userId,
role: newRole as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER',
});
}; };
const handleToggleActive = (userId: string, isActive: boolean) => { const handleToggleActive = (userId: string, isActive: boolean) => {
@ -235,7 +238,10 @@ export default function UsersManagementPage() {
const pagedUsers = allUsers.slice((usersPage - 1) * PAGE_SIZE, usersPage * PAGE_SIZE); const pagedUsers = allUsers.slice((usersPage - 1) * PAGE_SIZE, usersPage * PAGE_SIZE);
const allPending = (pendingInvitations || []).filter(inv => !inv.isUsed); const allPending = (pendingInvitations || []).filter(inv => !inv.isUsed);
const pagedInvitations = allPending.slice((invitationsPage - 1) * PAGE_SIZE, invitationsPage * PAGE_SIZE); const pagedInvitations = allPending.slice(
(invitationsPage - 1) * PAGE_SIZE,
invitationsPage * PAGE_SIZE
);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -244,16 +250,26 @@ export default function UsersManagementPage() {
<div className="flex items-start"> <div className="flex items-start">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<svg className="h-5 w-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-5 w-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg> </svg>
</div> </div>
<div className="ml-3 flex-1"> <div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-amber-800">{t('license.limitTitle')}</h3> <h3 className="text-sm font-medium text-amber-800">{t('license.limitTitle')}</h3>
<p className="mt-1 text-sm text-amber-700"> <p className="mt-1 text-sm text-amber-700">
{t('license.limitMessage', { used: licenseStatus.usedLicenses, max: licenseStatus.maxLicenses })} {t('license.limitMessage', {
used: licenseStatus.usedLicenses,
max: licenseStatus.maxLicenses,
})}
</p> </p>
<div className="mt-3"> <div className="mt-3">
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"> <Link
href="/dashboard/settings/subscription"
className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"
>
{t('license.upgradeLink')} {t('license.upgradeLink')}
</Link> </Link>
</div> </div>
@ -262,27 +278,37 @@ export default function UsersManagementPage() {
</div> </div>
)} )}
{licenseStatus && licenseStatus.canInvite && licenseStatus.availableLicenses <= 2 && licenseStatus.maxLicenses !== -1 && ( {licenseStatus &&
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> licenseStatus.canInvite &&
<div className="flex items-center justify-between"> licenseStatus.availableLicenses <= 2 &&
<div className="flex items-center"> licenseStatus.maxLicenses !== -1 && (
<svg className="h-5 w-5 text-blue-400 mr-2" fill="currentColor" viewBox="0 0 20 20"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" /> <div className="flex items-center justify-between">
</svg> <div className="flex items-center">
<span className="text-sm text-blue-800"> <svg className="h-5 w-5 text-blue-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
{t('license.remaining', { <path
count: licenseStatus.availableLicenses, fillRule="evenodd"
used: licenseStatus.usedLicenses, d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
max: licenseStatus.maxLicenses, clipRule="evenodd"
})} />
</span> </svg>
<span className="text-sm text-blue-800">
{t('license.remaining', {
count: licenseStatus.availableLicenses,
used: licenseStatus.usedLicenses,
max: licenseStatus.maxLicenses,
})}
</span>
</div>
<Link
href="/dashboard/settings/subscription"
className="text-sm font-medium text-blue-600 hover:text-blue-800"
>
{t('license.manageLink')}
</Link>
</div> </div>
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-blue-600 hover:text-blue-800">
{t('license.manageLink')}
</Link>
</div> </div>
</div> )}
)}
<PageHeader <PageHeader
title={t('header.title')} title={t('header.title')}
@ -296,9 +322,21 @@ export default function UsersManagementPage() {
{ key: 'firstName', label: t('export.firstName') }, { key: 'firstName', label: t('export.firstName') },
{ key: 'lastName', label: t('export.lastName') }, { key: 'lastName', label: t('export.lastName') },
{ key: 'email', label: t('export.email') }, { key: 'email', label: t('export.email') },
{ key: 'role', label: t('export.role'), format: (v) => t(`modal.rolesExport.${v}` as any) || v }, {
{ key: 'isActive', label: t('export.status'), format: (v) => v ? t('users.active') : t('users.inactive') }, key: 'role',
{ key: 'createdAt', label: t('export.createdAt'), format: (v) => v ? new Date(v).toLocaleDateString(dateLocale) : '' }, label: t('export.role'),
format: v => t(`modal.rolesExport.${v}` as any) || v,
},
{
key: 'isActive',
label: t('export.status'),
format: v => (v ? t('users.active') : t('users.inactive')),
},
{
key: 'createdAt',
label: t('export.createdAt'),
format: v => (v ? new Date(v).toLocaleDateString(dateLocale) : ''),
},
]} ]}
/> />
{licenseStatus?.canInvite ? ( {licenseStatus?.canInvite ? (
@ -340,7 +378,9 @@ export default function UsersManagementPage() {
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">{t('users.title')}</h2> <h2 className="text-lg font-medium text-gray-900">{t('users.title')}</h2>
{allUsers.length > 0 && ( {allUsers.length > 0 && (
<p className="text-sm text-gray-500 mt-1">{t('users.membersCount', { count: allUsers.length })}</p> <p className="text-sm text-gray-500 mt-1">
{t('users.membersCount', { count: allUsers.length })}
</p>
)} )}
</div> </div>
{isLoading ? ( {isLoading ? (
@ -354,12 +394,24 @@ export default function UsersManagementPage() {
<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>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.user')}</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('users.table.email')}</th> {t('users.table.user')}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.role')}</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.status')}</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('users.table.createdAt')}</th> {t('users.table.email')}
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.actions')}</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.table.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.table.createdAt')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.table.actions')}
</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
@ -368,10 +420,13 @@ export default function UsersManagementPage() {
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-semibold"> <div className="flex-shrink-0 h-10 w-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-semibold">
{user.firstName[0]}{user.lastName[0]} {user.firstName[0]}
{user.lastName[0]}
</div> </div>
<div className="ml-4"> <div className="ml-4">
<div className="text-sm font-medium text-gray-900">{user.firstName} {user.lastName}</div> <div className="text-sm font-medium text-gray-900">
{user.firstName} {user.lastName}
</div>
<div className="text-sm text-gray-500">{user.email}</div> <div className="text-sm text-gray-500">{user.email}</div>
</div> </div>
</div> </div>
@ -390,14 +445,18 @@ export default function UsersManagementPage() {
user.id === currentUser?.id user.id === currentUser?.id
} }
> >
{currentUser?.role === 'ADMIN' && <option value="ADMIN">{t('modal.roles.ADMIN')}</option>} {currentUser?.role === 'ADMIN' && (
<option value="ADMIN">{t('modal.roles.ADMIN')}</option>
)}
<option value="MANAGER">{t('modal.roles.MANAGER')}</option> <option value="MANAGER">{t('modal.roles.MANAGER')}</option>
<option value="USER">{t('modal.roles.USER')}</option> <option value="USER">{t('modal.roles.USER')}</option>
<option value="VIEWER">{t('modal.roles.VIEWER')}</option> <option value="VIEWER">{t('modal.roles.VIEWER')}</option>
</select> </select>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}> <span
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}
>
{user.isActive ? t('users.active') : t('users.inactive')} {user.isActive ? t('users.active') : t('users.inactive')}
</span> </span>
</td> </td>
@ -406,7 +465,7 @@ export default function UsersManagementPage() {
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button <button
onClick={(e) => { onClick={e => {
if (openMenuId === user.id) { if (openMenuId === user.id) {
setOpenMenuId(null); setOpenMenuId(null);
setMenuPosition(null); setMenuPosition(null);
@ -418,7 +477,11 @@ export default function UsersManagementPage() {
}} }}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
> >
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20"> <svg
className="w-5 h-5 text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" /> <path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg> </svg>
</button> </button>
@ -428,23 +491,46 @@ export default function UsersManagementPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
<Pagination page={usersPage} total={allUsers.length} onPage={p => { setUsersPage(p); setOpenMenuId(null); }} /> <Pagination
page={usersPage}
total={allUsers.length}
onPage={p => {
setUsersPage(p);
setOpenMenuId(null);
}}
/>
</> </>
) : ( ) : (
<div className="px-6 py-12 text-center"> <div className="px-6 py-12 text-center">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /> className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg> </svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('users.empty.title')}</h3> <h3 className="mt-2 text-sm font-medium text-gray-900">{t('users.empty.title')}</h3>
<p className="mt-1 text-sm text-gray-500">{t('users.empty.description')}</p> <p className="mt-1 text-sm text-gray-500">{t('users.empty.description')}</p>
<div className="mt-6"> <div className="mt-6">
{licenseStatus?.canInvite ? ( {licenseStatus?.canInvite ? (
<button onClick={() => setShowInviteModal(true)} 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"> <button
onClick={() => setShowInviteModal(true)}
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"
>
<span className="mr-2">+</span> <span className="mr-2">+</span>
{t('actions.invite')} {t('actions.invite')}
</button> </button>
) : ( ) : (
<Link href="/dashboard/settings/subscription" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"> <Link
href="/dashboard/settings/subscription"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
>
<span className="mr-2">+</span> <span className="mr-2">+</span>
{t('actions.upgrade')} {t('actions.upgrade')}
</Link> </Link>
@ -466,12 +552,24 @@ export default function UsersManagementPage() {
<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>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.user')}</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('invitations.table.email')}</th> {t('invitations.table.user')}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.role')}</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.expires')}</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('invitations.table.status')}</th> {t('invitations.table.email')}
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.actions')}</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('invitations.table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('invitations.table.expires')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('invitations.table.status')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('invitations.table.actions')}
</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
@ -482,16 +580,23 @@ export default function UsersManagementPage() {
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 bg-gray-200 rounded-full flex items-center justify-center text-gray-500 font-semibold"> <div className="flex-shrink-0 h-10 w-10 bg-gray-200 rounded-full flex items-center justify-center text-gray-500 font-semibold">
{inv.firstName[0]}{inv.lastName[0]} {inv.firstName[0]}
{inv.lastName[0]}
</div> </div>
<div className="ml-4"> <div className="ml-4">
<div className="text-sm font-medium text-gray-900">{inv.firstName} {inv.lastName}</div> <div className="text-sm font-medium text-gray-900">
{inv.firstName} {inv.lastName}
</div>
</div> </div>
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{inv.email}</td> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{inv.email}
</td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getRoleBadgeColor(inv.role)}`}> <span
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getRoleBadgeColor(inv.role)}`}
>
{inv.role} {inv.role}
</span> </span>
</td> </td>
@ -499,18 +604,32 @@ export default function UsersManagementPage() {
{new Date(inv.expiresAt).toLocaleDateString(dateLocale)} {new Date(inv.expiresAt).toLocaleDateString(dateLocale)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${isExpired ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'}`}> <span
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${isExpired ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'}`}
>
{isExpired ? t('invitations.expired') : t('invitations.pending')} {isExpired ? t('invitations.expired') : t('invitations.pending')}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right"> <td className="px-6 py-4 whitespace-nowrap text-right">
<button <button
onClick={() => handleCancelInvitation(inv.id, `${inv.firstName} ${inv.lastName}`)} onClick={() =>
handleCancelInvitation(inv.id, `${inv.firstName} ${inv.lastName}`)
}
disabled={cancelInvitationMutation.isPending} disabled={cancelInvitationMutation.isPending}
className="inline-flex items-center px-3 py-1 text-xs font-medium text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors disabled:opacity-50" className="inline-flex items-center px-3 py-1 text-xs font-medium text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors disabled:opacity-50"
> >
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
{t('invitations.cancel')} {t('invitations.cancel')}
</button> </button>
@ -521,7 +640,11 @@ export default function UsersManagementPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
<Pagination page={invitationsPage} total={allPending.length} onPage={setInvitationsPage} /> <Pagination
page={invitationsPage}
total={allPending.length}
onPage={setInvitationsPage}
/>
</div> </div>
)} )}
@ -529,7 +652,10 @@ export default function UsersManagementPage() {
<> <>
<div <div
className="fixed inset-0 z-[998]" className="fixed inset-0 z-[998]"
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }} onClick={() => {
setOpenMenuId(null);
setMenuPosition(null);
}}
/> />
<div <div
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]" className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
@ -548,17 +674,41 @@ export default function UsersManagementPage() {
> >
{users?.users.find(u => u.id === openMenuId)?.isActive ? ( {users?.users.find(u => u.id === openMenuId)?.isActive ? (
<> <>
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" /> className="w-5 h-5 text-orange-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg> </svg>
<span className="text-sm font-medium text-gray-700">{t('users.actions.deactivate')}</span> <span className="text-sm font-medium text-gray-700">
{t('users.actions.deactivate')}
</span>
</> </>
) : ( ) : (
<> <>
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> className="w-5 h-5 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<span className="text-sm font-medium text-gray-700">{t('users.actions.activate')}</span> <span className="text-sm font-medium text-gray-700">
{t('users.actions.activate')}
</span>
</> </>
)} )}
</button> </button>
@ -571,10 +721,22 @@ export default function UsersManagementPage() {
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3" className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
> >
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> className="w-5 h-5 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg> </svg>
<span className="text-sm font-medium text-red-600">{t('users.actions.delete')}</span> <span className="text-sm font-medium text-red-600">
{t('users.actions.delete')}
</span>
</button> </button>
</div> </div>
</div> </div>
@ -584,21 +746,34 @@ export default function UsersManagementPage() {
{showInviteModal && ( {showInviteModal && (
<div className="fixed inset-0 z-50 overflow-y-auto"> <div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"> <div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" onClick={() => setShowInviteModal(false)} /> <div
className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
onClick={() => setShowInviteModal(false)}
/>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"> <div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">{t('modal.title')}</h3> <h3 className="text-lg font-medium text-gray-900">{t('modal.title')}</h3>
<button onClick={() => setShowInviteModal(false)} className="text-gray-400 hover:text-gray-500"> <button
onClick={() => setShowInviteModal(false)}
className="text-gray-400 hover:text-gray-500"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
</div> </div>
<form onSubmit={handleInvite} className="space-y-4"> <form onSubmit={handleInvite} className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')} *</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.firstName')} *
</label>
<input <input
type="text" type="text"
required required
@ -608,7 +783,9 @@ export default function UsersManagementPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')} *</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.lastName')} *
</label>
<input <input
type="text" type="text"
required required
@ -619,7 +796,9 @@ export default function UsersManagementPage() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.email')} *</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.email')} *
</label>
<input <input
type="email" type="email"
required required
@ -629,7 +808,9 @@ export default function UsersManagementPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.role')} *</label> <label className="block text-sm font-medium text-gray-700">
{t('modal.role')} *
</label>
<select <select
value={inviteForm.role} value={inviteForm.role}
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })} onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}

View File

@ -33,16 +33,99 @@ interface SearchHistoryItem {
type CarrierDescKey = 'containerOrBl' | 'containerBlOrBooking' | 'containerOnly'; type CarrierDescKey = 'containerOrBl' | 'containerBlOrBooking' | 'containerOnly';
const carriers = [ const carriers = [
{ id: 'maersk', name: 'Maersk', color: '#00243D', textColor: 'text-white', trackingUrl: 'https://www.maersk.com/tracking/', placeholder: 'Ex: MSKU1234567', descKey: 'containerOrBl' as CarrierDescKey }, {
{ id: 'msc', name: 'MSC', color: '#002B5C', textColor: 'text-white', trackingUrl: 'https://www.msc.com/track-a-shipment?query=', placeholder: 'Ex: MSCU1234567', descKey: 'containerBlOrBooking' as CarrierDescKey }, id: 'maersk',
{ id: 'cma-cgm', name: 'CMA CGM', color: '#E30613', textColor: 'text-white', trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=', placeholder: 'Ex: CMAU1234567', descKey: 'containerOrBl' as CarrierDescKey }, name: 'Maersk',
{ id: 'hapag-lloyd', name: 'Hapag-Lloyd', color: '#FF6600', textColor: 'text-white', trackingUrl: 'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=', placeholder: 'Ex: HLCU1234567', descKey: 'containerOnly' as CarrierDescKey }, color: '#00243D',
{ id: 'cosco', name: 'COSCO', color: '#003A70', textColor: 'text-white', trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=', placeholder: 'Ex: COSU1234567', descKey: 'containerOrBl' as CarrierDescKey }, textColor: 'text-white',
{ id: 'one', name: 'ONE', color: '#FF00FF', textColor: 'text-white', trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=', placeholder: 'Ex: ONEU1234567', descKey: 'containerOrBl' as CarrierDescKey }, trackingUrl: 'https://www.maersk.com/tracking/',
{ id: 'evergreen', name: 'Evergreen', color: '#006633', textColor: 'text-white', trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=', placeholder: 'Ex: EGHU1234567', descKey: 'containerOrBl' as CarrierDescKey }, placeholder: 'Ex: MSKU1234567',
{ id: 'yangming', name: 'Yang Ming', color: '#FFD700', textColor: 'text-gray-900', trackingUrl: 'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=', placeholder: 'Ex: YMLU1234567', descKey: 'containerOnly' as CarrierDescKey }, descKey: 'containerOrBl' as CarrierDescKey,
{ id: 'zim', name: 'ZIM', color: '#1E3A8A', textColor: 'text-white', trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=', placeholder: 'Ex: ZIMU1234567', descKey: 'containerOrBl' as CarrierDescKey }, },
{ id: 'hmm', name: 'HMM', color: '#E65100', textColor: 'text-white', trackingUrl: 'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=', placeholder: 'Ex: HDMU1234567', descKey: 'containerOrBl' as CarrierDescKey }, {
id: 'msc',
name: 'MSC',
color: '#002B5C',
textColor: 'text-white',
trackingUrl: 'https://www.msc.com/track-a-shipment?query=',
placeholder: 'Ex: MSCU1234567',
descKey: 'containerBlOrBooking' as CarrierDescKey,
},
{
id: 'cma-cgm',
name: 'CMA CGM',
color: '#E30613',
textColor: 'text-white',
trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=',
placeholder: 'Ex: CMAU1234567',
descKey: 'containerOrBl' as CarrierDescKey,
},
{
id: 'hapag-lloyd',
name: 'Hapag-Lloyd',
color: '#FF6600',
textColor: 'text-white',
trackingUrl:
'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=',
placeholder: 'Ex: HLCU1234567',
descKey: 'containerOnly' as CarrierDescKey,
},
{
id: 'cosco',
name: 'COSCO',
color: '#003A70',
textColor: 'text-white',
trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=',
placeholder: 'Ex: COSU1234567',
descKey: 'containerOrBl' as CarrierDescKey,
},
{
id: 'one',
name: 'ONE',
color: '#FF00FF',
textColor: 'text-white',
trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=',
placeholder: 'Ex: ONEU1234567',
descKey: 'containerOrBl' as CarrierDescKey,
},
{
id: 'evergreen',
name: 'Evergreen',
color: '#006633',
textColor: 'text-white',
trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=',
placeholder: 'Ex: EGHU1234567',
descKey: 'containerOrBl' as CarrierDescKey,
},
{
id: 'yangming',
name: 'Yang Ming',
color: '#FFD700',
textColor: 'text-gray-900',
trackingUrl:
'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=',
placeholder: 'Ex: YMLU1234567',
descKey: 'containerOnly' as CarrierDescKey,
},
{
id: 'zim',
name: 'ZIM',
color: '#1E3A8A',
textColor: 'text-white',
trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=',
placeholder: 'Ex: ZIMU1234567',
descKey: 'containerOrBl' as CarrierDescKey,
},
{
id: 'hmm',
name: 'HMM',
color: '#E65100',
textColor: 'text-white',
trackingUrl:
'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=',
placeholder: 'Ex: HDMU1234567',
descKey: 'containerOrBl' as CarrierDescKey,
},
]; ];
const HISTORY_KEY = 'xpeditis_track_history'; const HISTORY_KEY = 'xpeditis_track_history';
@ -63,10 +146,12 @@ export default function TrackTracePage() {
if (savedHistory) { if (savedHistory) {
try { try {
const parsed = JSON.parse(savedHistory); const parsed = JSON.parse(savedHistory);
setSearchHistory(parsed.map((item: any) => ({ setSearchHistory(
...item, parsed.map((item: any) => ({
timestamp: new Date(item.timestamp) ...item,
}))); timestamp: new Date(item.timestamp),
}))
);
} catch (e) { } catch (e) {
console.error('Failed to parse search history:', e); console.error('Failed to parse search history:', e);
} }
@ -100,9 +185,16 @@ export default function TrackTracePage() {
timestamp: new Date(), timestamp: new Date(),
}; };
const updatedHistory = [newHistoryItem, ...searchHistory.filter( const updatedHistory = [
h => !(h.trackingNumber === newHistoryItem.trackingNumber && h.carrierId === newHistoryItem.carrierId) newHistoryItem,
)].slice(0, 10); ...searchHistory.filter(
h =>
!(
h.trackingNumber === newHistoryItem.trackingNumber &&
h.carrierId === newHistoryItem.carrierId
)
),
].slice(0, 10);
saveHistory(updatedHistory); saveHistory(updatedHistory);
@ -189,9 +281,13 @@ export default function TrackTracePage() {
className={`w-12 h-12 rounded-lg flex items-center justify-center text-sm font-bold mb-2 shadow-sm ${carrier.textColor}`} className={`w-12 h-12 rounded-lg flex items-center justify-center text-sm font-bold mb-2 shadow-sm ${carrier.textColor}`}
style={{ backgroundColor: carrier.color }} style={{ backgroundColor: carrier.color }}
> >
{carrier.name.length <= 3 ? carrier.name : carrier.name.substring(0, 2).toUpperCase()} {carrier.name.length <= 3
? carrier.name
: carrier.name.substring(0, 2).toUpperCase()}
</div> </div>
<span className="text-xs font-semibold text-gray-800 text-center leading-tight">{carrier.name}</span> <span className="text-xs font-semibold text-gray-800 text-center leading-tight">
{carrier.name}
</span>
</button> </button>
))} ))}
</div> </div>
@ -199,7 +295,10 @@ export default function TrackTracePage() {
{/* Tracking Number Input */} {/* Tracking Number Input */}
<div> <div>
<label htmlFor="tracking-number" className="block text-sm font-medium text-gray-700 mb-2"> <label
htmlFor="tracking-number"
className="block text-sm font-medium text-gray-700 mb-2"
>
{t('searchCard.trackingNumber')} {t('searchCard.trackingNumber')}
</label> </label>
<div className="flex gap-3"> <div className="flex gap-3">
@ -236,14 +335,15 @@ export default function TrackTracePage() {
{/* Map Toggle */} {/* Map Toggle */}
<div className="flex flex-wrap gap-3 pt-2"> <div className="flex flex-wrap gap-3 pt-2">
<Button <Button
variant={showMap ? "default" : "outline"} variant={showMap ? 'default' : 'outline'}
onClick={() => { onClick={() => {
setShowMap(!showMap); setShowMap(!showMap);
if (!showMap) setIsMapLoading(true); if (!showMap) setIsMapLoading(true);
}} }}
className={showMap className={
? "bg-blue-600 hover:bg-blue-700 text-white" showMap
: "text-gray-700 border-gray-300 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700" ? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'text-gray-700 border-gray-300 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700'
} }
> >
<Globe className="mr-2 h-4 w-4" /> <Globe className="mr-2 h-4 w-4" />
@ -263,9 +363,13 @@ export default function TrackTracePage() {
{/* Vessel Position Map */} {/* Vessel Position Map */}
{showMap && ( {showMap && (
<div className={`${isMapFullscreen ? 'fixed inset-0 z-50 bg-gray-900' : ''}`}> <div className={`${isMapFullscreen ? 'fixed inset-0 z-50 bg-gray-900' : ''}`}>
<Card className={`bg-white shadow-xl overflow-hidden ${isMapFullscreen ? 'h-full rounded-none' : ''}`}> <Card
className={`bg-white shadow-xl overflow-hidden ${isMapFullscreen ? 'h-full rounded-none' : ''}`}
>
{/* Map Header */} {/* Map Header */}
<div className={`flex items-center justify-between px-6 py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white ${isMapFullscreen ? '' : 'rounded-t-lg'}`}> <div
className={`flex items-center justify-between px-6 py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white ${isMapFullscreen ? '' : 'rounded-t-lg'}`}
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-white/20 rounded-lg"> <div className="p-2 bg-white/20 rounded-lg">
<Globe className="h-6 w-6" /> <Globe className="h-6 w-6" />
@ -309,7 +413,9 @@ export default function TrackTracePage() {
</div> </div>
{/* Map Container */} {/* Map Container */}
<div className={`relative w-full ${isMapFullscreen ? 'h-[calc(100vh-80px)]' : 'h-[70vh] min-h-[500px] max-h-[800px]'}`}> <div
className={`relative w-full ${isMapFullscreen ? 'h-[calc(100vh-80px)]' : 'h-[70vh] min-h-[500px] max-h-[800px]'}`}
>
{isMapLoading && ( {isMapLoading && (
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center z-10"> <div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center z-10">
<div className="text-center"> <div className="text-center">
@ -317,9 +423,18 @@ export default function TrackTracePage() {
<Ship className="h-16 w-16 text-blue-600 animate-pulse" /> <Ship className="h-16 w-16 text-blue-600 animate-pulse" />
<div className="absolute -bottom-1 left-1/2 transform -translate-x-1/2"> <div className="absolute -bottom-1 left-1/2 transform -translate-x-1/2">
<div className="flex gap-1"> <div className="flex gap-1">
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} /> <div
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} /> className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} /> style={{ animationDelay: '0ms' }}
/>
<div
className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"
style={{ animationDelay: '150ms' }}
/>
<div
className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"
style={{ animationDelay: '300ms' }}
/>
</div> </div>
</div> </div>
</div> </div>
@ -338,7 +453,9 @@ export default function TrackTracePage() {
/> />
{/* Map Legend Overlay */} {/* Map Legend Overlay */}
<div className={`absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? 'max-w-xs' : 'max-w-[280px]'}`}> <div
className={`absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? 'max-w-xs' : 'max-w-[280px]'}`}
>
<h4 className="font-semibold text-gray-800 text-sm mb-3 flex items-center gap-2"> <h4 className="font-semibold text-gray-800 text-sm mb-3 flex items-center gap-2">
<Anchor className="h-4 w-4 text-blue-600" /> <Anchor className="h-4 w-4 text-blue-600" />
{t('map.legend')} {t('map.legend')}
@ -364,7 +481,9 @@ export default function TrackTracePage() {
</div> </div>
{/* Quick Stats Overlay */} {/* Quick Stats Overlay */}
<div className={`absolute top-4 right-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? '' : 'hidden lg:block'}`}> <div
className={`absolute top-4 right-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? '' : 'hidden lg:block'}`}
>
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
<div className="text-center"> <div className="text-center">
<p className="text-2xl font-bold text-blue-600">90K+</p> <p className="text-2xl font-bold text-blue-600">90K+</p>
@ -444,12 +563,16 @@ export default function TrackTracePage() {
{item.carrierName.substring(0, 2).toUpperCase()} {item.carrierName.substring(0, 2).toUpperCase()}
</div> </div>
<div> <div>
<p className="font-mono text-sm font-medium text-gray-900">{item.trackingNumber}</p> <p className="font-mono text-sm font-medium text-gray-900">
<p className="text-xs text-gray-500">{item.carrierName} {formatTimeAgo(item.timestamp)}</p> {item.trackingNumber}
</p>
<p className="text-xs text-gray-500">
{item.carrierName} {formatTimeAgo(item.timestamp)}
</p>
</div> </div>
</div> </div>
<button <button
onClick={(e) => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
handleDeleteHistory(item.id); handleDeleteHistory(item.id);
}} }}

View File

@ -7,20 +7,35 @@ export default async function AssurancePage() {
const t = await getTranslations('dashboard.wikiPages'); const t = await getTranslations('dashboard.wikiPages');
const clauses = t.raw('assurance.clauses') as Array<{ const clauses = t.raw('assurance.clauses') as Array<{
name: string; level: string; includes: string[]; excludes: string[]; name: string;
level: string;
includes: string[];
excludes: string[];
}>; }>;
const extensions = t.raw('assurance.extensions') as Array<{ name: string; description: string }>; const extensions = t.raw('assurance.extensions') as Array<{ name: string; description: string }>;
const processSteps = t.raw('assurance.processSteps') as string[]; const processSteps = t.raw('assurance.processSteps') as string[];
const clauseColors = ['border-green-500 bg-green-50', 'border-yellow-500 bg-yellow-50', 'border-red-500 bg-red-50']; const clauseColors = [
'border-green-500 bg-green-50',
'border-yellow-500 bg-yellow-50',
'border-red-500 bg-red-50',
];
const clauseBadges = ['bg-green-600', 'bg-yellow-600', 'bg-red-600']; const clauseBadges = ['bg-green-600', 'bg-yellow-600', 'bg-red-600'];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"> <Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
{t('backToWiki')} {t('backToWiki')}
</Link> </Link>
@ -42,10 +57,14 @@ export default async function AssurancePage() {
<CardContent className="pt-4"> <CardContent className="pt-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-xl font-bold">{clause.name}</h3> <h3 className="text-xl font-bold">{clause.name}</h3>
<span className={`px-2 py-1 rounded text-white text-sm ${clauseBadges[i]}`}>{clause.level}</span> <span className={`px-2 py-1 rounded text-white text-sm ${clauseBadges[i]}`}>
{clause.level}
</span>
</div> </div>
<div className="mb-3"> <div className="mb-3">
<h4 className="text-sm font-semibold text-green-700 mb-1">{t('includesLabel')}</h4> <h4 className="text-sm font-semibold text-green-700 mb-1">
{t('includesLabel')}
</h4>
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
{clause.includes.map((item, j) => ( {clause.includes.map((item, j) => (
<li key={j} className="flex items-start gap-1 text-gray-700"> <li key={j} className="flex items-start gap-1 text-gray-700">
@ -73,7 +92,7 @@ export default async function AssurancePage() {
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('assurance.extensionsTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">{t('assurance.extensionsTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{extensions.map((ext) => ( {extensions.map(ext => (
<Card key={ext.name} className="bg-white"> <Card key={ext.name} className="bg-white">
<CardContent className="pt-4"> <CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{ext.name}</h4> <h4 className="font-semibold text-gray-900">{ext.name}</h4>
@ -98,7 +117,9 @@ export default async function AssurancePage() {
<Card className="mt-4 bg-blue-50 border-blue-200"> <Card className="mt-4 bg-blue-50 border-blue-200">
<CardContent className="pt-6"> <CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-2">{t('assurance.valueTitle')}</h3> <h3 className="font-semibold text-blue-900 mb-2">{t('assurance.valueTitle')}</h3>
<p className="font-mono text-blue-800 bg-white p-3 rounded border text-sm">{t('assurance.valueFormula')}</p> <p className="font-mono text-blue-800 bg-white p-3 rounded border text-sm">
{t('assurance.valueFormula')}
</p>
<p className="text-sm text-blue-700 mt-2">{t('assurance.valueNote')}</p> <p className="text-sm text-blue-700 mt-2">{t('assurance.valueNote')}</p>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -7,19 +7,32 @@ export default async function CalculFretPage() {
const t = await getTranslations('dashboard.wikiPages'); const t = await getTranslations('dashboard.wikiPages');
const surcharges = t.raw('calculFret.surcharges') as Array<{ const surcharges = t.raw('calculFret.surcharges') as Array<{
code: string; name: string; description: string; variation: string; code: string;
name: string;
description: string;
variation: string;
}>; }>;
const additionalCosts = t.raw('calculFret.additionalCosts') as Array<{ const additionalCosts = t.raw('calculFret.additionalCosts') as Array<{
name: string; description: string; typical: string; name: string;
description: string;
typical: string;
}>; }>;
const exampleItems = t.raw('calculFret.exampleItems') as Array<{ item: string; amount: string }>; const exampleItems = t.raw('calculFret.exampleItems') as Array<{ item: string; amount: string }>;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"> <Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
{t('backToWiki')} {t('backToWiki')}
</Link> </Link>
@ -46,7 +59,7 @@ export default async function CalculFretPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{surcharges.map((s) => ( {surcharges.map(s => (
<tr key={s.code} className="border-t hover:bg-gray-50"> <tr key={s.code} className="border-t hover:bg-gray-50">
<td className="p-3 font-mono font-bold text-blue-600">{s.code}</td> <td className="p-3 font-mono font-bold text-blue-600">{s.code}</td>
<td className="p-3 font-medium">{s.name}</td> <td className="p-3 font-medium">{s.name}</td>
@ -60,14 +73,18 @@ export default async function CalculFretPage() {
</div> </div>
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('calculFret.additionalCostsTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">
{t('calculFret.additionalCostsTitle')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{additionalCosts.map((cost) => ( {additionalCosts.map(cost => (
<Card key={cost.name} className="bg-white"> <Card key={cost.name} className="bg-white">
<CardContent className="pt-4"> <CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{cost.name}</h4> <h4 className="font-semibold text-gray-900">{cost.name}</h4>
<p className="text-sm text-gray-600 mt-1">{cost.description}</p> <p className="text-sm text-gray-600 mt-1">{cost.description}</p>
<p className="text-xs text-gray-400 mt-2">{t('calculFret.colCost')}: {cost.typical}</p> <p className="text-xs text-gray-400 mt-2">
{t('calculFret.colCost')}: {cost.typical}
</p>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@ -86,7 +103,10 @@ export default async function CalculFretPage() {
</thead> </thead>
<tbody> <tbody>
{exampleItems.map((item, i) => ( {exampleItems.map((item, i) => (
<tr key={i} className={`border-b last:border-0 ${i === exampleItems.length - 1 ? 'font-bold text-blue-700' : ''}`}> <tr
key={i}
className={`border-b last:border-0 ${i === exampleItems.length - 1 ? 'font-bold text-blue-700' : ''}`}
>
<td className="py-2">{item.item}</td> <td className="py-2">{item.item}</td>
<td className="py-2 text-right font-mono">{item.amount}</td> <td className="py-2 text-right font-mono">{item.amount}</td>
</tr> </tr>

View File

@ -7,17 +7,36 @@ export default async function ConteneursPage() {
const t = await getTranslations('dashboard.wikiPages'); const t = await getTranslations('dashboard.wikiPages');
const containers = t.raw('conteneurs.containers') as Array<{ const containers = t.raw('conteneurs.containers') as Array<{
type: string; description: string; internal: string; door: string; volume: string; payload: string; type: string;
description: string;
internal: string;
door: string;
volume: string;
payload: string;
}>;
const specialEquipment = t.raw('conteneurs.specialEquipment') as Array<{
name: string;
description: string;
}>;
const selectionGuide = t.raw('conteneurs.selectionGuide') as Array<{
condition: string;
recommendation: string;
}>; }>;
const specialEquipment = t.raw('conteneurs.specialEquipment') as Array<{ name: string; description: string }>;
const selectionGuide = t.raw('conteneurs.selectionGuide') as Array<{ condition: string; recommendation: string }>;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"> <Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
{t('backToWiki')} {t('backToWiki')}
</Link> </Link>
@ -45,7 +64,7 @@ export default async function ConteneursPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{containers.map((c) => ( {containers.map(c => (
<tr key={c.type} className="border-t hover:bg-gray-50"> <tr key={c.type} className="border-t hover:bg-gray-50">
<td className="p-3"> <td className="p-3">
<div className="font-semibold text-gray-900">{c.type}</div> <div className="font-semibold text-gray-900">{c.type}</div>
@ -63,9 +82,11 @@ export default async function ConteneursPage() {
</div> </div>
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('conteneurs.specialEquipmentTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">
{t('conteneurs.specialEquipmentTitle')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{specialEquipment.map((eq) => ( {specialEquipment.map(eq => (
<Card key={eq.name} className="bg-white"> <Card key={eq.name} className="bg-white">
<CardContent className="pt-4"> <CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{eq.name}</h4> <h4 className="font-semibold text-gray-900">{eq.name}</h4>
@ -82,8 +103,12 @@ export default async function ConteneursPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-blue-200"> <tr className="border-b border-blue-200">
<th className="text-left py-2 font-medium text-blue-800">{t('conteneurs.colCondition')}</th> <th className="text-left py-2 font-medium text-blue-800">
<th className="text-left py-2 font-medium text-blue-800">{t('conteneurs.colRecommendation')}</th> {t('conteneurs.colCondition')}
</th>
<th className="text-left py-2 font-medium text-blue-800">
{t('conteneurs.colRecommendation')}
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@ -7,17 +7,34 @@ export default async function DocumentsTransportPage() {
const t = await getTranslations('dashboard.wikiPages'); const t = await getTranslations('dashboard.wikiPages');
const documents = t.raw('documentsTransport.documents') as Array<{ const documents = t.raw('documentsTransport.documents') as Array<{
name: string; type: string; description: string; types: string[]; name: string;
type: string;
description: string;
types: string[];
}>;
const additionalDocs = t.raw('documentsTransport.additionalDocs') as Array<{
name: string;
description: string;
}>;
const blFunctions = t.raw('documentsTransport.blFunctions') as Array<{
title: string;
description: string;
}>; }>;
const additionalDocs = t.raw('documentsTransport.additionalDocs') as Array<{ name: string; description: string }>;
const blFunctions = t.raw('documentsTransport.blFunctions') as Array<{ title: string; description: string }>;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"> <Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
{t('backToWiki')} {t('backToWiki')}
</Link> </Link>
@ -32,21 +49,30 @@ export default async function DocumentsTransportPage() {
</div> </div>
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('documentsTransport.mainDocumentsTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">
{t('documentsTransport.mainDocumentsTitle')}
</h2>
<div className="space-y-4"> <div className="space-y-4">
{documents.map((doc) => ( {documents.map(doc => (
<Card key={doc.name} className="bg-white"> <Card key={doc.name} className="bg-white">
<CardContent className="pt-4"> <CardContent className="pt-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-gray-900">{doc.name}</h4> <h4 className="font-semibold text-gray-900">{doc.name}</h4>
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded">{doc.type}</span> <span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded">
{doc.type}
</span>
</div> </div>
<p className="text-sm text-gray-600 mb-2">{doc.description}</p> <p className="text-sm text-gray-600 mb-2">{doc.description}</p>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{doc.types.map((type, j) => ( {doc.types.map((type, j) => (
<span key={j} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{type}</span> <span
key={j}
className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded"
>
{type}
</span>
))} ))}
</div> </div>
</div> </div>
@ -58,9 +84,11 @@ export default async function DocumentsTransportPage() {
</div> </div>
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('documentsTransport.additionalDocsTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">
{t('documentsTransport.additionalDocsTitle')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{additionalDocs.map((doc) => ( {additionalDocs.map(doc => (
<Card key={doc.name} className="bg-white"> <Card key={doc.name} className="bg-white">
<CardContent className="pt-4"> <CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{doc.name}</h4> <h4 className="font-semibold text-gray-900">{doc.name}</h4>
@ -73,9 +101,11 @@ export default async function DocumentsTransportPage() {
<Card className="mt-8 bg-blue-50 border-blue-200"> <Card className="mt-8 bg-blue-50 border-blue-200">
<CardContent className="pt-6"> <CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-4">{t('documentsTransport.blFocusTitle')}</h3> <h3 className="font-semibold text-blue-900 mb-4">
{t('documentsTransport.blFocusTitle')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{blFunctions.map((fn) => ( {blFunctions.map(fn => (
<div key={fn.title} className="bg-white p-4 rounded-lg border border-blue-200"> <div key={fn.title} className="bg-white p-4 rounded-lg border border-blue-200">
<h4 className="font-medium text-blue-800 mb-1">{fn.title}</h4> <h4 className="font-medium text-blue-800 mb-1">{fn.title}</h4>
<p className="text-sm text-gray-600">{fn.description}</p> <p className="text-sm text-gray-600">{fn.description}</p>

View File

@ -6,18 +6,32 @@ import { ShieldCheck } from 'lucide-react';
export default async function DouanesPage() { export default async function DouanesPage() {
const t = await getTranslations('dashboard.wikiPages'); const t = await getTranslations('dashboard.wikiPages');
const regimes = t.raw('douanes.regimes') as Array<{ code: string; name: string; description: string }>; const regimes = t.raw('douanes.regimes') as Array<{
code: string;
name: string;
description: string;
}>;
const documents = t.raw('douanes.documents') as Array<{ const documents = t.raw('douanes.documents') as Array<{
name: string; mandatory: boolean; description: string; name: string;
mandatory: boolean;
description: string;
}>; }>;
const duties = t.raw('douanes.duties') as Array<{ type: string; description: string }>; const duties = t.raw('douanes.duties') as Array<{ type: string; description: string }>;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"> <Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
{t('backToWiki')} {t('backToWiki')}
</Link> </Link>
@ -43,7 +57,7 @@ export default async function DouanesPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{regimes.map((r) => ( {regimes.map(r => (
<tr key={r.code} className="border-t hover:bg-gray-50"> <tr key={r.code} className="border-t hover:bg-gray-50">
<td className="p-3 font-mono font-bold text-blue-600">{r.code}</td> <td className="p-3 font-mono font-bold text-blue-600">{r.code}</td>
<td className="p-3 font-medium">{r.name}</td> <td className="p-3 font-medium">{r.name}</td>
@ -58,11 +72,13 @@ export default async function DouanesPage() {
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.documentsTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.documentsTitle')}</h2>
<div className="space-y-3"> <div className="space-y-3">
{documents.map((doc) => ( {documents.map(doc => (
<Card key={doc.name} className="bg-white"> <Card key={doc.name} className="bg-white">
<CardContent className="py-3"> <CardContent className="py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={`px-2 py-0.5 text-xs rounded font-medium ${doc.mandatory ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'}`}> <span
className={`px-2 py-0.5 text-xs rounded font-medium ${doc.mandatory ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'}`}
>
{doc.mandatory ? t('mandatoryLabel') : t('optionalLabel')} {doc.mandatory ? t('mandatoryLabel') : t('optionalLabel')}
</span> </span>
<div> <div>
@ -79,7 +95,7 @@ export default async function DouanesPage() {
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.dutiesTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.dutiesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{duties.map((d) => ( {duties.map(d => (
<Card key={d.type} className="bg-white"> <Card key={d.type} className="bg-white">
<CardContent className="pt-4"> <CardContent className="pt-4">
<h4 className="font-semibold text-gray-900 mb-2">{d.type}</h4> <h4 className="font-semibold text-gray-900 mb-2">{d.type}</h4>

View File

@ -7,17 +7,31 @@ export default async function ImdgPage() {
const t = await getTranslations('dashboard.wikiPages'); const t = await getTranslations('dashboard.wikiPages');
const classes = t.raw('imdg.classes') as Array<{ const classes = t.raw('imdg.classes') as Array<{
class: string; name: string; description: string; subdivisions?: string[]; class: string;
name: string;
description: string;
subdivisions?: string[];
}>; }>;
const documents = t.raw('imdg.documents') as Array<{ name: string; description: string }>; const documents = t.raw('imdg.documents') as Array<{ name: string; description: string }>;
const packagingGroups = t.raw('imdg.packagingGroups') as Array<{ group: string; description: string }>; const packagingGroups = t.raw('imdg.packagingGroups') as Array<{
group: string;
description: string;
}>;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"> <Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
{t('backToWiki')} {t('backToWiki')}
</Link> </Link>
@ -34,17 +48,21 @@ export default async function ImdgPage() {
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.classesTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.classesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{classes.map((cls) => ( {classes.map(cls => (
<Card key={cls.class} className="bg-white border-orange-200"> <Card key={cls.class} className="bg-white border-orange-200">
<CardContent className="pt-4"> <CardContent className="pt-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 bg-orange-100 text-orange-800 text-xs font-bold rounded">{cls.class}</span> <span className="px-2 py-0.5 bg-orange-100 text-orange-800 text-xs font-bold rounded">
{cls.class}
</span>
<h4 className="font-semibold text-gray-900">{cls.name}</h4> <h4 className="font-semibold text-gray-900">{cls.name}</h4>
</div> </div>
<p className="text-sm text-gray-600">{cls.description}</p> <p className="text-sm text-gray-600">{cls.description}</p>
{cls.subdivisions && ( {cls.subdivisions && (
<div className="mt-2"> <div className="mt-2">
<p className="text-xs font-medium text-gray-500 mb-1">{t('imdg.subdivisionsLabel')}</p> <p className="text-xs font-medium text-gray-500 mb-1">
{t('imdg.subdivisionsLabel')}
</p>
<ul className="text-xs text-gray-600 space-y-0.5"> <ul className="text-xs text-gray-600 space-y-0.5">
{cls.subdivisions.map((sub, j) => ( {cls.subdivisions.map((sub, j) => (
<li key={j}> {sub}</li> <li key={j}> {sub}</li>
@ -61,7 +79,7 @@ export default async function ImdgPage() {
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.documentsTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.documentsTitle')}</h2>
<div className="space-y-3"> <div className="space-y-3">
{documents.map((doc) => ( {documents.map(doc => (
<Card key={doc.name} className="bg-white"> <Card key={doc.name} className="bg-white">
<CardContent className="py-3"> <CardContent className="py-3">
<h4 className="font-semibold text-gray-900">{doc.name}</h4> <h4 className="font-semibold text-gray-900">{doc.name}</h4>
@ -76,7 +94,11 @@ export default async function ImdgPage() {
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.packagingGroupsTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.packagingGroupsTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{packagingGroups.map((pg, i) => { {packagingGroups.map((pg, i) => {
const colors = ['bg-red-50 border-red-200', 'bg-yellow-50 border-yellow-200', 'bg-green-50 border-green-200']; const colors = [
'bg-red-50 border-red-200',
'bg-yellow-50 border-yellow-200',
'bg-green-50 border-green-200',
];
return ( return (
<Card key={pg.group} className={`border ${colors[i]}`}> <Card key={pg.group} className={`border ${colors[i]}`}>
<CardContent className="pt-4"> <CardContent className="pt-4">

View File

@ -7,22 +7,40 @@ export default async function IncotermsPage() {
const t = await getTranslations('dashboard.wikiPages'); const t = await getTranslations('dashboard.wikiPages');
const list = t.raw('incoterms.list') as Array<{ const list = t.raw('incoterms.list') as Array<{
code: string; name: string; description: string; risk: string; transport: string; code: string;
name: string;
description: string;
risk: string;
transport: string;
}>; }>;
const categorySections = t.raw('incoterms.categorySections') as Array<{ const categorySections = t.raw('incoterms.categorySections') as Array<{
name: string; description: string; terms: string[]; name: string;
description: string;
terms: string[];
}>; }>;
const keyPoints = t.raw('incoterms.keyPoints') as string[]; const keyPoints = t.raw('incoterms.keyPoints') as string[];
const tips = t.raw('incoterms.tips') as string[]; const tips = t.raw('incoterms.tips') as string[];
const categoryColors = ['bg-green-100 text-green-800', 'bg-red-100 text-red-800', 'bg-blue-100 text-blue-800']; const categoryColors = [
'bg-green-100 text-green-800',
'bg-red-100 text-red-800',
'bg-blue-100 text-blue-800',
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"> <Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
{t('backToWiki')} {t('backToWiki')}
</Link> </Link>
@ -59,8 +77,13 @@ export default async function IncotermsPage() {
<h3 className="font-semibold text-gray-900">{cat.name}</h3> <h3 className="font-semibold text-gray-900">{cat.name}</h3>
<p className="text-sm text-gray-600 mt-1">{cat.description}</p> <p className="text-sm text-gray-600 mt-1">{cat.description}</p>
<div className="flex flex-wrap gap-1 mt-3"> <div className="flex flex-wrap gap-1 mt-3">
{cat.terms.map((term) => ( {cat.terms.map(term => (
<span key={term} className={`px-2 py-0.5 rounded text-xs font-mono font-bold ${categoryColors[i]}`}>{term}</span> <span
key={term}
className={`px-2 py-0.5 rounded text-xs font-mono font-bold ${categoryColors[i]}`}
>
{term}
</span>
))} ))}
</div> </div>
</CardContent> </CardContent>
@ -83,7 +106,7 @@ export default async function IncotermsPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{list.map((item) => ( {list.map(item => (
<tr key={item.code} className="border-t hover:bg-gray-50"> <tr key={item.code} className="border-t hover:bg-gray-50">
<td className="p-3 font-mono font-bold text-blue-600">{item.code}</td> <td className="p-3 font-mono font-bold text-blue-600">{item.code}</td>
<td className="p-3 font-medium">{item.name}</td> <td className="p-3 font-medium">{item.name}</td>

View File

@ -6,17 +6,33 @@ import { Scale } from 'lucide-react';
export default async function LclVsFclPage() { export default async function LclVsFclPage() {
const t = await getTranslations('dashboard.wikiPages'); const t = await getTranslations('dashboard.wikiPages');
const criteria = t.raw('lclVsFcl.criteria') as Array<{ criterion: string; lcl: string; fcl: string }>; const criteria = t.raw('lclVsFcl.criteria') as Array<{
const lclProcess = t.raw('lclVsFcl.lclProcess') as Array<{ step: string; title: string; description: string }>; criterion: string;
lcl: string;
fcl: string;
}>;
const lclProcess = t.raw('lclVsFcl.lclProcess') as Array<{
step: string;
title: string;
description: string;
}>;
const chooseLcl = t.raw('lclVsFcl.chooseLcl') as string[]; const chooseLcl = t.raw('lclVsFcl.chooseLcl') as string[];
const chooseFcl = t.raw('lclVsFcl.chooseFcl') as string[]; const chooseFcl = t.raw('lclVsFcl.chooseFcl') as string[];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"> <Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
{t('backToWiki')} {t('backToWiki')}
</Link> </Link>
@ -57,7 +73,7 @@ export default async function LclVsFclPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{criteria.map((row) => ( {criteria.map(row => (
<tr key={row.criterion} className="border-t hover:bg-gray-50"> <tr key={row.criterion} className="border-t hover:bg-gray-50">
<td className="p-3 font-medium text-gray-900">{row.criterion}</td> <td className="p-3 font-medium text-gray-900">{row.criterion}</td>
<td className="p-3 text-gray-600">{row.lcl}</td> <td className="p-3 text-gray-600">{row.lcl}</td>
@ -72,7 +88,7 @@ export default async function LclVsFclPage() {
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lclVsFcl.lclProcessTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">{t('lclVsFcl.lclProcessTitle')}</h2>
<div className="space-y-3"> <div className="space-y-3">
{lclProcess.map((step) => ( {lclProcess.map(step => (
<div key={step.step} className="flex items-start gap-4 bg-white p-4 rounded-lg border"> <div key={step.step} className="flex items-start gap-4 bg-white p-4 rounded-lg border">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold text-sm"> <div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold text-sm">
{step.step} {step.step}

View File

@ -10,15 +10,29 @@ export default async function LettreCreditPage() {
const parties = t.raw('lettreCredit.parties') as Array<{ role: string; description: string }>; const parties = t.raw('lettreCredit.parties') as Array<{ role: string; description: string }>;
const documents = t.raw('lettreCredit.documents') as Array<{ name: string; description: string }>; const documents = t.raw('lettreCredit.documents') as Array<{ name: string; description: string }>;
const errors = t.raw('lettreCredit.errors') as string[]; const errors = t.raw('lettreCredit.errors') as string[];
const datesItems = t.raw('lettreCredit.datesItems') as Array<{ label: string; description: string }>; const datesItems = t.raw('lettreCredit.datesItems') as Array<{
const costsItems = t.raw('lettreCredit.costsItems') as Array<{ label: string; description: string }>; label: string;
description: string;
}>;
const costsItems = t.raw('lettreCredit.costsItems') as Array<{
label: string;
description: string;
}>;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"> <Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
{t('backToWiki')} {t('backToWiki')}
</Link> </Link>
@ -35,7 +49,7 @@ export default async function LettreCreditPage() {
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.typesTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.typesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{types.map((type) => ( {types.map(type => (
<Card key={type.name} className="bg-white"> <Card key={type.name} className="bg-white">
<CardContent className="pt-4"> <CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{type.name}</h4> <h4 className="font-semibold text-gray-900">{type.name}</h4>
@ -57,7 +71,7 @@ export default async function LettreCreditPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{parties.map((p) => ( {parties.map(p => (
<tr key={p.role} className="border-t hover:bg-gray-50"> <tr key={p.role} className="border-t hover:bg-gray-50">
<td className="p-3 font-medium text-blue-700">{p.role}</td> <td className="p-3 font-medium text-blue-700">{p.role}</td>
<td className="p-3 text-gray-600">{p.description}</td> <td className="p-3 text-gray-600">{p.description}</td>
@ -71,7 +85,7 @@ export default async function LettreCreditPage() {
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.documentsTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.documentsTitle')}</h2>
<div className="space-y-3"> <div className="space-y-3">
{documents.map((doc) => ( {documents.map(doc => (
<div key={doc.name} className="flex items-start gap-3 bg-white p-4 rounded-lg border"> <div key={doc.name} className="flex items-start gap-3 bg-white p-4 rounded-lg border">
<span className="text-green-500 mt-0.5 flex-shrink-0"></span> <span className="text-green-500 mt-0.5 flex-shrink-0"></span>
<div> <div>
@ -108,7 +122,7 @@ export default async function LettreCreditPage() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('lettreCredit.datesTitle')}</h3> <h3 className="font-semibold text-gray-900 mb-3">{t('lettreCredit.datesTitle')}</h3>
<div className="space-y-3"> <div className="space-y-3">
{datesItems.map((item) => ( {datesItems.map(item => (
<div key={item.label}> <div key={item.label}>
<span className="font-medium text-gray-800 text-sm">{item.label}</span> <span className="font-medium text-gray-800 text-sm">{item.label}</span>
<p className="text-xs text-gray-600 mt-0.5">{item.description}</p> <p className="text-xs text-gray-600 mt-0.5">{item.description}</p>
@ -121,7 +135,7 @@ export default async function LettreCreditPage() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('lettreCredit.costsTitle')}</h3> <h3 className="font-semibold text-gray-900 mb-3">{t('lettreCredit.costsTitle')}</h3>
<div className="space-y-3"> <div className="space-y-3">
{costsItems.map((item) => ( {costsItems.map(item => (
<div key={item.label}> <div key={item.label}>
<span className="font-medium text-gray-800 text-sm">{item.label}</span> <span className="font-medium text-gray-800 text-sm">{item.label}</span>
<p className="text-xs text-gray-600 mt-0.5">{item.description}</p> <p className="text-xs text-gray-600 mt-0.5">{item.description}</p>

View File

@ -17,7 +17,19 @@ import {
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
type TopicKey = 'incoterms' | 'documents' | 'containers' | 'lclFcl' | 'customs' | 'insurance' | 'freight' | 'ports' | 'vgm' | 'imdg' | 'letterOfCredit' | 'transitTime'; type TopicKey =
| 'incoterms'
| 'documents'
| 'containers'
| 'lclFcl'
| 'customs'
| 'insurance'
| 'freight'
| 'ports'
| 'vgm'
| 'imdg'
| 'letterOfCredit'
| 'transitTime';
interface WikiTopic { interface WikiTopic {
key: TopicKey; key: TopicKey;
@ -27,18 +39,63 @@ interface WikiTopic {
} }
const wikiTopics: WikiTopic[] = [ const wikiTopics: WikiTopic[] = [
{ key: 'incoterms', icon: ScrollText, href: '/dashboard/wiki/incoterms', tags: ['FOB', 'CIF', 'EXW', 'DDP'] }, {
{ key: 'documents', icon: ClipboardList, href: '/dashboard/wiki/documents-transport', tags: ['B/L', 'Sea Waybill', 'Manifest'] }, key: 'incoterms',
{ key: 'containers', icon: Package, href: '/dashboard/wiki/conteneurs', tags: ["20'", "40'", 'Reefer', 'Open Top'] }, icon: ScrollText,
href: '/dashboard/wiki/incoterms',
tags: ['FOB', 'CIF', 'EXW', 'DDP'],
},
{
key: 'documents',
icon: ClipboardList,
href: '/dashboard/wiki/documents-transport',
tags: ['B/L', 'Sea Waybill', 'Manifest'],
},
{
key: 'containers',
icon: Package,
href: '/dashboard/wiki/conteneurs',
tags: ["20'", "40'", 'Reefer', 'Open Top'],
},
{ key: 'lclFcl', icon: Scale, href: '/dashboard/wiki/lcl-vs-fcl', tags: ['LCL', 'FCL'] }, { key: 'lclFcl', icon: Scale, href: '/dashboard/wiki/lcl-vs-fcl', tags: ['LCL', 'FCL'] },
{ key: 'customs', icon: ShieldCheck, href: '/dashboard/wiki/douanes', tags: ['HS Code', 'EUR.1', 'AEO'] }, {
{ key: 'insurance', icon: Shield, href: '/dashboard/wiki/assurance', tags: ['ICC A', 'ICC B', 'ICC C'] }, key: 'customs',
{ key: 'freight', icon: Calculator, href: '/dashboard/wiki/calcul-fret', tags: ['CBM', 'THC', 'BAF', 'CAF'] }, icon: ShieldCheck,
{ key: 'ports', icon: Globe, href: '/dashboard/wiki/ports-routes', tags: ['Hub', 'Suez', 'Panama'] }, href: '/dashboard/wiki/douanes',
tags: ['HS Code', 'EUR.1', 'AEO'],
},
{
key: 'insurance',
icon: Shield,
href: '/dashboard/wiki/assurance',
tags: ['ICC A', 'ICC B', 'ICC C'],
},
{
key: 'freight',
icon: Calculator,
href: '/dashboard/wiki/calcul-fret',
tags: ['CBM', 'THC', 'BAF', 'CAF'],
},
{
key: 'ports',
icon: Globe,
href: '/dashboard/wiki/ports-routes',
tags: ['Hub', 'Suez', 'Panama'],
},
{ key: 'vgm', icon: Anchor, href: '/dashboard/wiki/vgm', tags: ['SOLAS', 'VGM'] }, { key: 'vgm', icon: Anchor, href: '/dashboard/wiki/vgm', tags: ['SOLAS', 'VGM'] },
{ key: 'imdg', icon: AlertTriangle, href: '/dashboard/wiki/imdg', tags: ['IMDG', 'MSDS', 'DG'] }, { key: 'imdg', icon: AlertTriangle, href: '/dashboard/wiki/imdg', tags: ['IMDG', 'MSDS', 'DG'] },
{ key: 'letterOfCredit', icon: CreditCard, href: '/dashboard/wiki/lettre-credit', tags: ['L/C', 'SWIFT', 'UCP 600'] }, {
{ key: 'transitTime', icon: Timer, href: '/dashboard/wiki/transit-time', tags: ['Cut-off', 'Free time', 'Demurrage'] }, key: 'letterOfCredit',
icon: CreditCard,
href: '/dashboard/wiki/lettre-credit',
tags: ['L/C', 'SWIFT', 'UCP 600'],
},
{
key: 'transitTime',
icon: Timer,
href: '/dashboard/wiki/transit-time',
tags: ['Cut-off', 'Free time', 'Demurrage'],
},
]; ];
export default async function WikiPage() { export default async function WikiPage() {
@ -54,7 +111,7 @@ export default async function WikiPage() {
{/* Cards Grid */} {/* Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{wikiTopics.map((topic) => { {wikiTopics.map(topic => {
const IconComponent = topic.icon; const IconComponent = topic.icon;
return ( return (
<Link key={topic.href} href={topic.href} className="block group"> <Link key={topic.href} href={topic.href} className="block group">
@ -74,7 +131,7 @@ export default async function WikiPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{topic.tags.map((tag) => ( {topic.tags.map(tag => (
<span <span
key={tag} key={tag}
className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded-full" className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded-full"

View File

@ -7,21 +7,40 @@ export default async function PortsRoutesPage() {
const t = await getTranslations('dashboard.wikiPages'); const t = await getTranslations('dashboard.wikiPages');
const routes = t.raw('portsRoutes.routes') as Array<{ const routes = t.raw('portsRoutes.routes') as Array<{
name: string; description: string; via: string; transitTime: string; majorPorts: string[]; name: string;
description: string;
via: string;
transitTime: string;
majorPorts: string[];
}>; }>;
const passages = t.raw('portsRoutes.passages') as Array<{ const passages = t.raw('portsRoutes.passages') as Array<{
name: string; location: string; length: string; description: string; keyStat: string; name: string;
location: string;
length: string;
description: string;
keyStat: string;
}>; }>;
const ports = t.raw('portsRoutes.ports') as Array<{ const ports = t.raw('portsRoutes.ports') as Array<{
rank: number; port: string; country: string; teu: string; rank: number;
port: string;
country: string;
teu: string;
}>; }>;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"> <Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
{t('backToWiki')} {t('backToWiki')}
</Link> </Link>
@ -36,20 +55,33 @@ export default async function PortsRoutesPage() {
</div> </div>
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('portsRoutes.majorRoutesTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">
{t('portsRoutes.majorRoutesTitle')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{routes.map((route) => ( {routes.map(route => (
<Card key={route.name} className="bg-white"> <Card key={route.name} className="bg-white">
<CardContent className="pt-4"> <CardContent className="pt-4">
<h4 className="font-semibold text-gray-900 mb-1">{route.name}</h4> <h4 className="font-semibold text-gray-900 mb-1">{route.name}</h4>
<p className="text-sm text-gray-600 mb-3">{route.description}</p> <p className="text-sm text-gray-600 mb-3">{route.description}</p>
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
<span className="text-gray-500">{t('portsRoutes.colVia')}: <strong className="text-gray-800">{route.via}</strong></span> <span className="text-gray-500">
<span className="text-gray-500">{t('portsRoutes.colTransit')}: <strong className="text-blue-600">{route.transitTime}</strong></span> {t('portsRoutes.colVia')}:{' '}
<strong className="text-gray-800">{route.via}</strong>
</span>
<span className="text-gray-500">
{t('portsRoutes.colTransit')}:{' '}
<strong className="text-blue-600">{route.transitTime}</strong>
</span>
</div> </div>
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 mt-2">
{route.majorPorts.map((port) => ( {route.majorPorts.map(port => (
<span key={port} className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded">{port}</span> <span
key={port}
className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded"
>
{port}
</span>
))} ))}
</div> </div>
</CardContent> </CardContent>
@ -61,15 +93,19 @@ export default async function PortsRoutesPage() {
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('portsRoutes.passagesTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">{t('portsRoutes.passagesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{passages.map((p) => ( {passages.map(p => (
<Card key={p.name} className="bg-white border-blue-200"> <Card key={p.name} className="bg-white border-blue-200">
<CardContent className="pt-4"> <CardContent className="pt-4">
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div> <div>
<h4 className="font-semibold text-gray-900">{p.name}</h4> <h4 className="font-semibold text-gray-900">{p.name}</h4>
<p className="text-sm text-gray-500">{p.location} {t('portsRoutes.colLength')}: {p.length}</p> <p className="text-sm text-gray-500">
{p.location} {t('portsRoutes.colLength')}: {p.length}
</p>
</div> </div>
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded font-medium whitespace-nowrap">{p.keyStat}</span> <span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded font-medium whitespace-nowrap">
{p.keyStat}
</span>
</div> </div>
<p className="text-sm text-gray-600">{p.description}</p> <p className="text-sm text-gray-600">{p.description}</p>
</CardContent> </CardContent>
@ -91,7 +127,7 @@ export default async function PortsRoutesPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ports.map((port) => ( {ports.map(port => (
<tr key={port.rank} className="border-t hover:bg-gray-50"> <tr key={port.rank} className="border-t hover:bg-gray-50">
<td className="p-3 font-bold text-gray-400">#{port.rank}</td> <td className="p-3 font-bold text-gray-400">#{port.rank}</td>
<td className="p-3 font-medium text-gray-900">{port.port}</td> <td className="p-3 font-medium text-gray-900">{port.port}</td>

View File

@ -7,11 +7,21 @@ export default async function TransitTimePage() {
const t = await getTranslations('dashboard.wikiPages'); const t = await getTranslations('dashboard.wikiPages');
const timeline = t.raw('transitTime.timeline') as Array<{ const timeline = t.raw('transitTime.timeline') as Array<{
step: string; description: string; delay: string; responsible: string; step: string;
description: string;
delay: string;
responsible: string;
}>;
const transitTimes = t.raw('transitTime.transitTimes') as Array<{
route: string;
time: string;
via: string;
}>; }>;
const transitTimes = t.raw('transitTime.transitTimes') as Array<{ route: string; time: string; via: string }>;
const lateFees = t.raw('transitTime.lateFees') as Array<{ const lateFees = t.raw('transitTime.lateFees') as Array<{
name: string; definition: string; rate: string; location: string; name: string;
definition: string;
rate: string;
location: string;
}>; }>;
const potentialDelays = t.raw('transitTime.potentialDelays') as string[]; const potentialDelays = t.raw('transitTime.potentialDelays') as string[];
const seasonalVariations = t.raw('transitTime.seasonalVariations') as string[]; const seasonalVariations = t.raw('transitTime.seasonalVariations') as string[];
@ -28,9 +38,17 @@ export default async function TransitTimePage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"> <Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
{t('backToWiki')} {t('backToWiki')}
</Link> </Link>
@ -48,7 +66,7 @@ export default async function TransitTimePage() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3">{t('transitTime.keyTermsTitle')}</h3> <h3 className="font-semibold text-blue-900 mb-3">{t('transitTime.keyTermsTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{keyTerms.map((term) => ( {keyTerms.map(term => (
<div key={term.key}> <div key={term.key}>
<h4 className="font-medium text-blue-800">{term.key}</h4> <h4 className="font-medium text-blue-800">{term.key}</h4>
<p className="text-sm text-blue-700">{term.def}</p> <p className="text-sm text-blue-700">{term.def}</p>
@ -62,7 +80,10 @@ export default async function TransitTimePage() {
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.timelineTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.timelineTitle')}</h2>
<div className="space-y-3"> <div className="space-y-3">
{timeline.map((item, index) => ( {timeline.map((item, index) => (
<Card key={index} className={`bg-white ${index === 5 || index === 7 ? 'border-blue-300 border-2' : ''}`}> <Card
key={index}
className={`bg-white ${index === 5 || index === 7 ? 'border-blue-300 border-2' : ''}`}
>
<CardContent className="py-4"> <CardContent className="py-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold text-sm"> <div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold text-sm">
@ -71,10 +92,14 @@ export default async function TransitTimePage() {
<div className="flex-1"> <div className="flex-1">
<div className="flex flex-wrap items-center gap-2 mb-1"> <div className="flex flex-wrap items-center gap-2 mb-1">
<h4 className="font-medium text-gray-900">{item.step}</h4> <h4 className="font-medium text-gray-900">{item.step}</h4>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">{item.delay}</span> <span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">
{item.delay}
</span>
</div> </div>
<p className="text-sm text-gray-600">{item.description}</p> <p className="text-sm text-gray-600">{item.description}</p>
<p className="text-xs text-gray-400 mt-1">{t('transitTime.responsibleLabel')} : {item.responsible}</p> <p className="text-xs text-gray-400 mt-1">
{t('transitTime.responsibleLabel')} : {item.responsible}
</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -84,7 +109,9 @@ export default async function TransitTimePage() {
</div> </div>
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.transitTimesTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">
{t('transitTime.transitTimesTitle')}
</h2>
<Card className="bg-white"> <Card className="bg-white">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@ -97,7 +124,7 @@ export default async function TransitTimePage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{transitTimes.map((tt) => ( {transitTimes.map(tt => (
<tr key={tt.route} className="border-b last:border-0 hover:bg-gray-50"> <tr key={tt.route} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-3 text-gray-900">{tt.route}</td> <td className="py-3 text-gray-900">{tt.route}</td>
<td className="py-3 text-center font-mono text-blue-600">{tt.time}</td> <td className="py-3 text-center font-mono text-blue-600">{tt.time}</td>
@ -137,7 +164,7 @@ export default async function TransitTimePage() {
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.lateFeesTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.lateFeesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{lateFees.map((fee) => ( {lateFees.map(fee => (
<Card key={fee.name} className="bg-white border-red-200"> <Card key={fee.name} className="bg-white border-red-200">
<CardContent className="pt-4"> <CardContent className="pt-4">
<h4 className="text-lg font-semibold text-red-700 mb-1">{fee.name}</h4> <h4 className="text-lg font-semibold text-red-700 mb-1">{fee.name}</h4>
@ -158,18 +185,28 @@ export default async function TransitTimePage() {
<Card className="mt-8 bg-orange-50 border-orange-200"> <Card className="mt-8 bg-orange-50 border-orange-200">
<CardContent className="pt-6"> <CardContent className="pt-6">
<h3 className="font-semibold text-orange-900 mb-3">{t('transitTime.delayFactorsTitle')}</h3> <h3 className="font-semibold text-orange-900 mb-3">
{t('transitTime.delayFactorsTitle')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<h4 className="font-medium text-orange-800">{t('transitTime.potentialDelaysTitle')}</h4> <h4 className="font-medium text-orange-800">
{t('transitTime.potentialDelaysTitle')}
</h4>
<ul className="text-sm text-orange-700 mt-2 space-y-1"> <ul className="text-sm text-orange-700 mt-2 space-y-1">
{potentialDelays.map((d, i) => <li key={i}> {d}</li>)} {potentialDelays.map((d, i) => (
<li key={i}> {d}</li>
))}
</ul> </ul>
</div> </div>
<div> <div>
<h4 className="font-medium text-orange-800">{t('transitTime.seasonalVariationsTitle')}</h4> <h4 className="font-medium text-orange-800">
{t('transitTime.seasonalVariationsTitle')}
</h4>
<ul className="text-sm text-orange-700 mt-2 space-y-1"> <ul className="text-sm text-orange-700 mt-2 space-y-1">
{seasonalVariations.map((v, i) => <li key={i}> {v}</li>)} {seasonalVariations.map((v, i) => (
<li key={i}> {v}</li>
))}
</ul> </ul>
</div> </div>
</div> </div>
@ -183,7 +220,9 @@ export default async function TransitTimePage() {
<div className="bg-white p-4 rounded-lg border"> <div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">{t('transitTime.rolloverCausesTitle')}</h4> <h4 className="font-medium text-gray-900">{t('transitTime.rolloverCausesTitle')}</h4>
<ul className="text-sm text-gray-600 mt-2 space-y-1"> <ul className="text-sm text-gray-600 mt-2 space-y-1">
{rolloverCauses.map((cause, i) => <li key={i}> {cause}</li>)} {rolloverCauses.map((cause, i) => (
<li key={i}> {cause}</li>
))}
</ul> </ul>
<p className="text-xs text-gray-500 mt-3">{t('transitTime.rolloverImpact')}</p> <p className="text-xs text-gray-500 mt-3">{t('transitTime.rolloverImpact')}</p>
</div> </div>
@ -194,7 +233,9 @@ export default async function TransitTimePage() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3">{t('transitTime.tipsTitle')}</h3> <h3 className="font-semibold text-amber-900 mb-3">{t('transitTime.tipsTitle')}</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800"> <ul className="list-disc list-inside space-y-2 text-amber-800">
{tips.map((tip, i) => <li key={i}>{tip}</li>)} {tips.map((tip, i) => (
<li key={i}>{tip}</li>
))}
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -7,21 +7,40 @@ export default async function VGMPage() {
const t = await getTranslations('dashboard.wikiPages'); const t = await getTranslations('dashboard.wikiPages');
const why = t.raw('vgm.why') as Array<{ title: string; description: string }>; const why = t.raw('vgm.why') as Array<{ title: string; description: string }>;
const elements = t.raw('vgm.elements') as Array<{ element: string; description: string; example: string }>; const elements = t.raw('vgm.elements') as Array<{
const methods = t.raw('vgm.methods') as Array<{ element: string;
method: string; name: string; description: string; description: string;
process: string[]; advantages: string[]; disadvantages: string[]; example: string;
}>;
const methods = t.raw('vgm.methods') as Array<{
method: string;
name: string;
description: string;
process: string[];
advantages: string[];
disadvantages: string[];
}>;
const responsibilities = t.raw('vgm.responsibilities') as Array<{
role: string;
description: string;
}>; }>;
const responsibilities = t.raw('vgm.responsibilities') as Array<{ role: string; description: string }>;
const sanctions = t.raw('vgm.sanctions') as Array<{ region: string; sanction: string }>; const sanctions = t.raw('vgm.sanctions') as Array<{ region: string; sanction: string }>;
const tips = t.raw('vgm.tips') as string[]; const tips = t.raw('vgm.tips') as string[];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"> <Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
{t('backToWiki')} {t('backToWiki')}
</Link> </Link>
@ -39,7 +58,7 @@ export default async function VGMPage() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3">{t('vgm.whyTitle')}</h3> <h3 className="font-semibold text-blue-900 mb-3">{t('vgm.whyTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800">
{why.map((item) => ( {why.map(item => (
<div key={item.title}> <div key={item.title}>
<h4 className="font-medium">{item.title}</h4> <h4 className="font-medium">{item.title}</h4>
<p className="text-sm mt-0.5">{item.description}</p> <p className="text-sm mt-0.5">{item.description}</p>
@ -54,16 +73,23 @@ export default async function VGMPage() {
<Card className="bg-white"> <Card className="bg-white">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="bg-gray-50 p-4 rounded-lg border mb-4 text-center font-mono text-lg"> <div className="bg-gray-50 p-4 rounded-lg border mb-4 text-center font-mono text-lg">
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded">{t('vgm.formula')}</span> <span className="bg-blue-100 text-blue-800 px-2 py-1 rounded">
{t('vgm.formula')}
</span>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{elements.map((item) => ( {elements.map(item => (
<div key={item.element} className="flex items-center justify-between py-3 border-b last:border-0"> <div
key={item.element}
className="flex items-center justify-between py-3 border-b last:border-0"
>
<div> <div>
<h4 className="font-medium text-gray-900">{item.element}</h4> <h4 className="font-medium text-gray-900">{item.element}</h4>
<p className="text-sm text-gray-600">{item.description}</p> <p className="text-sm text-gray-600">{item.description}</p>
</div> </div>
<span className="text-sm font-mono bg-gray-100 px-3 py-1 rounded ml-4 flex-shrink-0">{item.example}</span> <span className="text-sm font-mono bg-gray-100 px-3 py-1 rounded ml-4 flex-shrink-0">
{item.example}
</span>
</div> </div>
))} ))}
</div> </div>
@ -74,11 +100,13 @@ export default async function VGMPage() {
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('vgm.methodsTitle')}</h2> <h2 className="text-xl font-bold text-gray-900 mb-4">{t('vgm.methodsTitle')}</h2>
<div className="space-y-4"> <div className="space-y-4">
{methods.map((method) => ( {methods.map(method => (
<Card key={method.method} className="bg-white"> <Card key={method.method} className="bg-white">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-3"> <CardTitle className="flex items-center gap-3">
<span className="px-3 py-1 bg-green-600 text-white rounded-md text-sm">{method.method}</span> <span className="px-3 py-1 bg-green-600 text-white rounded-md text-sm">
{method.method}
</span>
<span className="text-lg">{method.name}</span> <span className="text-lg">{method.name}</span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -88,25 +116,33 @@ export default async function VGMPage() {
<div> <div>
<h4 className="font-medium text-gray-700 mb-2">{t('vgm.processLabel')}</h4> <h4 className="font-medium text-gray-700 mb-2">{t('vgm.processLabel')}</h4>
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1"> <ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
{method.process.map((step, i) => <li key={i}>{step}</li>)} {method.process.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol> </ol>
</div> </div>
<div> <div>
<h4 className="font-medium text-green-700 mb-2"> {t('vgm.advantagesLabel')}</h4> <h4 className="font-medium text-green-700 mb-2">
{t('vgm.advantagesLabel')}
</h4>
<ul className="text-sm text-gray-600 space-y-1"> <ul className="text-sm text-gray-600 space-y-1">
{method.advantages.map((adv) => ( {method.advantages.map(adv => (
<li key={adv} className="flex items-center gap-2"> <li key={adv} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0" />{adv} <span className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0" />
{adv}
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
<div> <div>
<h4 className="font-medium text-red-700 mb-2"> {t('vgm.disadvantagesLabel')}</h4> <h4 className="font-medium text-red-700 mb-2">
{t('vgm.disadvantagesLabel')}
</h4>
<ul className="text-sm text-gray-600 space-y-1"> <ul className="text-sm text-gray-600 space-y-1">
{method.disadvantages.map((dis) => ( {method.disadvantages.map(dis => (
<li key={dis} className="flex items-center gap-2"> <li key={dis} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />{dis} <span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
{dis}
</li> </li>
))} ))}
</ul> </ul>
@ -122,7 +158,7 @@ export default async function VGMPage() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('vgm.responsibilityTitle')}</h3> <h3 className="font-semibold text-gray-900 mb-3">{t('vgm.responsibilityTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{responsibilities.map((r) => ( {responsibilities.map(r => (
<div key={r.role} className="bg-white p-4 rounded-lg border"> <div key={r.role} className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">{r.role}</h4> <h4 className="font-medium text-gray-900">{r.role}</h4>
<p className="text-sm text-gray-600 mt-1">{r.description}</p> <p className="text-sm text-gray-600 mt-1">{r.description}</p>
@ -155,8 +191,11 @@ export default async function VGMPage() {
<Card className="bg-white"> <Card className="bg-white">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="space-y-3"> <div className="space-y-3">
{sanctions.map((s) => ( {sanctions.map(s => (
<div key={s.region} className="flex items-center justify-between py-3 border-b last:border-0"> <div
key={s.region}
className="flex items-center justify-between py-3 border-b last:border-0"
>
<span className="font-medium text-gray-900">{s.region}</span> <span className="font-medium text-gray-900">{s.region}</span>
<span className="text-sm text-red-600">{s.sanction}</span> <span className="text-sm text-red-600">{s.sanction}</span>
</div> </div>
@ -170,7 +209,9 @@ export default async function VGMPage() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3">{t('vgm.tipsTitle')}</h3> <h3 className="font-semibold text-amber-900 mb-3">{t('vgm.tipsTitle')}</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800"> <ul className="list-disc list-inside space-y-2 text-amber-800">
{tips.map((tip, i) => <li key={i}>{tip}</li>)} {tips.map((tip, i) => (
<li key={i}>{tip}</li>
))}
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,4 +1,4 @@
"use client"; 'use client';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
@ -24,9 +24,7 @@ export default function DemoPage() {
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Démo Carte Maritime</h1> <h1 className="text-3xl font-bold text-gray-900 mb-2">Démo Carte Maritime</h1>
<p className="text-gray-600"> <p className="text-gray-600">Visualisation de la route entre Marseille et Barcelone</p>
Visualisation de la route entre Marseille et Barcelone
</p>
</div> </div>
<div className="bg-white rounded-lg shadow-lg overflow-hidden"> <div className="bg-white rounded-lg shadow-lg overflow-hidden">
@ -34,9 +32,7 @@ export default function DemoPage() {
<h2 className="text-white text-lg font-semibold"> <h2 className="text-white text-lg font-semibold">
Route: Port de Marseille Port de Barcelone Route: Port de Marseille Port de Barcelone
</h2> </h2>
<p className="text-blue-100 text-sm mt-1"> <p className="text-blue-100 text-sm mt-1">Distance approximative: ~350 km par la mer</p>
Distance approximative: ~350 km par la mer
</p>
</div> </div>
<PortRouteMap portA={portA} portB={portB} height="600px" /> <PortRouteMap portA={portA} portB={portB} height="600px" />
@ -46,12 +42,16 @@ export default function DemoPage() {
<div> <div>
<h3 className="font-semibold text-gray-900 mb-2">📍 Port d'origine</h3> <h3 className="font-semibold text-gray-900 mb-2">📍 Port d'origine</h3>
<p className="text-gray-600">Marseille, France</p> <p className="text-gray-600">Marseille, France</p>
<p className="text-gray-500 text-xs">Lat: {portA.lat}, Lng: {portA.lng}</p> <p className="text-gray-500 text-xs">
Lat: {portA.lat}, Lng: {portA.lng}
</p>
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-900 mb-2">📍 Port de destination</h3> <h3 className="font-semibold text-gray-900 mb-2">📍 Port de destination</h3>
<p className="text-gray-600">Barcelone, Espagne</p> <p className="text-gray-600">Barcelone, Espagne</p>
<p className="text-gray-500 text-xs">Lat: {portB.lat}, Lng: {portB.lng}</p> <p className="text-gray-500 text-xs">
Lat: {portB.lat}, Lng: {portB.lng}
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -50,8 +50,18 @@ export default function ForgotPasswordPage() {
<> <>
<div className="mb-8"> <div className="mb-8">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-6"> <div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-6">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> className="w-8 h-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg> </svg>
</div> </div>
<h1 className="text-h1 text-brand-navy mb-2">{t('successTitle')}</h1> <h1 className="text-h1 text-brand-navy mb-2">{t('successTitle')}</h1>
@ -61,9 +71,7 @@ export default function ForgotPasswordPage() {
__html: t('successMessage', { email }), __html: t('successMessage', { email }),
}} }}
/> />
<p className="text-body-sm text-neutral-500 mt-3"> <p className="text-body-sm text-neutral-500 mt-3">{t('successHint')}</p>
{t('successHint')}
</p>
</div> </div>
<Link href="/login" className="btn-primary w-full text-center block text-lg"> <Link href="/login" className="btn-primary w-full text-center block text-lg">
{t('backToLogin')} {t('backToLogin')}
@ -73,15 +81,23 @@ export default function ForgotPasswordPage() {
<> <>
<div className="mb-8"> <div className="mb-8">
<h1 className="text-h1 text-brand-navy mb-2">{t('title')}</h1> <h1 className="text-h1 text-brand-navy mb-2">{t('title')}</h1>
<p className="text-body text-neutral-600"> <p className="text-body text-neutral-600">{t('subtitle')}</p>
{t('subtitle')}
</p>
</div> </div>
{error && ( {error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3"> <div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<p className="text-body-sm text-red-800">{error}</p> <p className="text-body-sm text-red-800">{error}</p>
</div> </div>
@ -115,9 +131,17 @@ export default function ForgotPasswordPage() {
</form> </form>
<div className="mt-8 text-center"> <div className="mt-8 text-center">
<Link href="/login" className="text-body-sm link flex items-center justify-center gap-2"> <Link
href="/login"
className="text-body-sm link flex items-center justify-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg> </svg>
{t('backToLogin')} {t('backToLogin')}
</Link> </Link>
@ -146,30 +170,54 @@ export default function ForgotPasswordPage() {
<div className="absolute inset-0 flex flex-col justify-center px-16 xl:px-24 text-white"> <div className="absolute inset-0 flex flex-col justify-center px-16 xl:px-24 text-white">
<div className="max-w-xl"> <div className="max-w-xl">
<h2 className="text-display-sm mb-6 text-white">{t('sidePanel.title')}</h2> <h2 className="text-display-sm mb-6 text-white">{t('sidePanel.title')}</h2>
<p className="text-body-lg text-neutral-200 mb-12"> <p className="text-body-lg text-neutral-200 mb-12">{t('sidePanel.description')}</p>
{t('sidePanel.description')}
</p>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-start"> <div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center"> <div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> className="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg> </svg>
</div> </div>
<div className="ml-4"> <div className="ml-4">
<h3 className="text-h5 mb-1 text-white">{t('sidePanel.features.secure.title')}</h3> <h3 className="text-h5 mb-1 text-white">
<p className="text-body-sm text-neutral-300">{t('sidePanel.features.secure.description')}</p> {t('sidePanel.features.secure.title')}
</h3>
<p className="text-body-sm text-neutral-300">
{t('sidePanel.features.secure.description')}
</p>
</div> </div>
</div> </div>
<div className="flex items-start"> <div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center"> <div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> className="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg> </svg>
</div> </div>
<div className="ml-4"> <div className="ml-4">
<h3 className="text-h5 mb-1 text-white">{t('sidePanel.features.email.title')}</h3> <h3 className="text-h5 mb-1 text-white">{t('sidePanel.features.email.title')}</h3>
<p className="text-body-sm text-neutral-300">{t('sidePanel.features.email.description')}</p> <p className="text-body-sm text-neutral-300">
{t('sidePanel.features.email.description')}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -177,9 +225,30 @@ export default function ForgotPasswordPage() {
</div> </div>
<div className="absolute bottom-0 right-0 opacity-10"> <div className="absolute bottom-0 right-0 opacity-10">
<svg width="400" height="400" viewBox="0 0 400 400" fill="none"> <svg width="400" height="400" viewBox="0 0 400 400" fill="none">
<circle cx="200" cy="200" r="150" stroke="currentColor" strokeWidth="2" className="text-white" /> <circle
<circle cx="200" cy="200" r="100" stroke="currentColor" strokeWidth="2" className="text-white" /> cx="200"
<circle cx="200" cy="200" r="50" stroke="currentColor" strokeWidth="2" className="text-white" /> cy="200"
r="150"
stroke="currentColor"
strokeWidth="2"
className="text-white"
/>
<circle
cx="200"
cy="200"
r="100"
stroke="currentColor"
strokeWidth="2"
className="text-white"
/>
<circle
cx="200"
cy="200"
r="50"
stroke="currentColor"
strokeWidth="2"
className="text-white"
/>
</svg> </svg>
</div> </div>
</div> </div>

View File

@ -13,11 +13,7 @@ export function generateStaticParams() {
return routing.locales.map(locale => ({ locale })); return routing.locales.map(locale => ({ locale }));
} }
export async function generateMetadata({ export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
params,
}: {
params: Promise<Params>;
}): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'metadata.home' }); const t = await getTranslations({ locale, namespace: 'metadata.home' });

View File

@ -135,9 +135,7 @@ function LoginPageContent() {
<div className="mb-8"> <div className="mb-8">
<h1 className="text-h1 text-brand-navy mb-2">{tLogin('title')}</h1> <h1 className="text-h1 text-brand-navy mb-2">{tLogin('title')}</h1>
<p className="text-body text-neutral-600"> <p className="text-body text-neutral-600">{tLogin('subtitle')}</p>
{tLogin('subtitle')}
</p>
</div> </div>
{error && ( {error && (
@ -181,7 +179,10 @@ function LoginPageContent() {
aria-describedby={fieldErrors.email ? 'email-error' : undefined} aria-describedby={fieldErrors.email ? 'email-error' : undefined}
/> />
{fieldErrors.email && ( {fieldErrors.email && (
<p id="email-error" className="mt-1.5 text-body-sm text-red-600 flex items-center gap-1"> <p
id="email-error"
className="mt-1.5 text-body-sm text-red-600 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
@ -196,7 +197,10 @@ function LoginPageContent() {
</div> </div>
<div> <div>
<label htmlFor="password" className={`label ${fieldErrors.password ? 'text-red-600' : ''}`}> <label
htmlFor="password"
className={`label ${fieldErrors.password ? 'text-red-600' : ''}`}
>
{tLogin('passwordLabel')} {tLogin('passwordLabel')}
</label> </label>
<input <input
@ -216,7 +220,10 @@ function LoginPageContent() {
aria-describedby={fieldErrors.password ? 'password-error' : undefined} aria-describedby={fieldErrors.password ? 'password-error' : undefined}
/> />
{fieldErrors.password && ( {fieldErrors.password && (
<p id="password-error" className="mt-1.5 text-body-sm text-red-600 flex items-center gap-1"> <p
id="password-error"
className="mt-1.5 text-body-sm text-red-600 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
@ -287,9 +294,7 @@ function LoginPageContent() {
<div className="absolute inset-0 flex flex-col justify-center px-16 xl:px-24 text-white"> <div className="absolute inset-0 flex flex-col justify-center px-16 xl:px-24 text-white">
<div className="max-w-xl"> <div className="max-w-xl">
<h2 className="text-display-sm mb-6 text-white">{tPanel('title')}</h2> <h2 className="text-display-sm mb-6 text-white">{tPanel('title')}</h2>
<p className="text-body-lg text-neutral-200 mb-12"> <p className="text-body-lg text-neutral-200 mb-12">{tPanel('description')}</p>
{tPanel('description')}
</p>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-start"> <div className="flex items-start">
@ -309,7 +314,9 @@ function LoginPageContent() {
</svg> </svg>
</div> </div>
<div className="ml-4"> <div className="ml-4">
<h3 className="text-h5 mb-1 text-white">{tPanel('features.instantRates.title')}</h3> <h3 className="text-h5 mb-1 text-white">
{tPanel('features.instantRates.title')}
</h3>
<p className="text-body-sm text-neutral-300"> <p className="text-body-sm text-neutral-300">
{tPanel('features.instantRates.description')} {tPanel('features.instantRates.description')}
</p> </p>
@ -372,11 +379,15 @@ function LoginPageContent() {
</div> </div>
<div> <div>
<div className="text-display-sm text-brand-turquoise">10k+</div> <div className="text-display-sm text-brand-turquoise">10k+</div>
<div className="text-body-sm text-neutral-300 mt-1">{tPanel('stats.shipments')}</div> <div className="text-body-sm text-neutral-300 mt-1">
{tPanel('stats.shipments')}
</div>
</div> </div>
<div> <div>
<div className="text-display-sm text-brand-turquoise">99.5%</div> <div className="text-display-sm text-brand-turquoise">99.5%</div>
<div className="text-body-sm text-neutral-300 mt-1">{tPanel('stats.satisfaction')}</div> <div className="text-body-sm text-neutral-300 mt-1">
{tPanel('stats.satisfaction')}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,221 +1,238 @@
'use client'; 'use client';
import { useRef } from 'react'; import { useRef } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
import { Ship, Home, ArrowRight, LayoutDashboard, Anchor } from 'lucide-react'; import { Ship, Home, ArrowRight, LayoutDashboard, Anchor } from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout'; import { LandingHeader, LandingFooter } from '@/components/layout';
export default function NotFound() { export default function NotFound() {
const heroRef = useRef(null); const heroRef = useRef(null);
const isHeroInView = useInView(heroRef, { once: true }); const isHeroInView = useInView(heroRef, { once: true });
return ( return (
<div className="min-h-screen bg-white flex flex-col"> <div className="min-h-screen bg-white flex flex-col">
<LandingHeader transparentOnTop={true} /> <LandingHeader transparentOnTop={true} />
{/* Hero Section */} {/* Hero Section */}
<section <section
ref={heroRef} ref={heroRef}
className="relative flex-1 flex items-center justify-center overflow-hidden" className="relative flex-1 flex items-center justify-center overflow-hidden"
> >
{/* Background */} {/* Background */}
<div className="absolute inset-0 bg-gradient-to-br from-brand-navy via-brand-navy/95 to-brand-navy/90"> <div className="absolute inset-0 bg-gradient-to-br from-brand-navy via-brand-navy/95 to-brand-navy/90">
<div className="absolute inset-0 opacity-10"> <div className="absolute inset-0 opacity-10">
<div className="absolute top-10 left-10 w-72 h-72 lg:w-96 lg:h-96 bg-brand-turquoise rounded-full blur-3xl" /> <div className="absolute top-10 left-10 w-72 h-72 lg:w-96 lg:h-96 bg-brand-turquoise rounded-full blur-3xl" />
<div className="absolute bottom-32 right-10 w-72 h-72 lg:w-96 lg:h-96 bg-brand-green rounded-full blur-3xl" /> <div className="absolute bottom-32 right-10 w-72 h-72 lg:w-96 lg:h-96 bg-brand-green rounded-full blur-3xl" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-brand-turquoise/50 rounded-full blur-3xl" /> <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-brand-turquoise/50 rounded-full blur-3xl" />
</div> </div>
</div> </div>
{/* Content */} {/* Content */}
<div className="relative z-10 max-w-4xl mx-auto px-6 text-center pt-32 pb-40"> <div className="relative z-10 max-w-4xl mx-auto px-6 text-center pt-32 pb-40">
{/* Badge */} {/* Badge */}
<motion.div <motion.div
initial={{ scale: 0.8, opacity: 0 }} initial={{ scale: 0.8, opacity: 0 }}
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}} animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
transition={{ duration: 0.6, delay: 0.2 }} transition={{ duration: 0.6, delay: 0.2 }}
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-10 border border-white/20" className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-10 border border-white/20"
> >
<Anchor className="w-5 h-5 text-brand-turquoise" /> <Anchor className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium font-heading"> <span className="text-white/90 text-sm font-medium font-heading">Erreur 404</span>
Erreur 404 </motion.div>
</span>
</motion.div> {/* Animated Ship + Waves illustration */}
<motion.div
{/* Animated Ship + Waves illustration */} initial={{ opacity: 0, scale: 0.8 }}
<motion.div animate={isHeroInView ? { opacity: 1, scale: 1 } : {}}
initial={{ opacity: 0, scale: 0.8 }} transition={{ duration: 0.8, delay: 0.3 }}
animate={isHeroInView ? { opacity: 1, scale: 1 } : {}} className="relative mb-8 flex justify-center"
transition={{ duration: 0.8, delay: 0.3 }} >
className="relative mb-8 flex justify-center" <div className="relative w-64 h-40 lg:w-80 lg:h-48">
> {/* Ship */}
<div className="relative w-64 h-40 lg:w-80 lg:h-48"> <svg
{/* Ship */} viewBox="0 0 200 120"
<svg className="absolute inset-0 w-full h-full animate-float"
viewBox="0 0 200 120" fill="none"
className="absolute inset-0 w-full h-full animate-float" xmlns="http://www.w3.org/2000/svg"
fill="none" >
xmlns="http://www.w3.org/2000/svg" {/* Hull */}
> <path d="M40 75 L55 95 L145 95 L160 75 Z" fill="#34CCCD" opacity="0.9" />
{/* Hull */} {/* Deck */}
<path <rect x="60" y="58" width="80" height="17" rx="2" fill="white" opacity="0.9" />
d="M40 75 L55 95 L145 95 L160 75 Z" {/* Bridge */}
fill="#34CCCD" <rect x="85" y="35" width="35" height="23" rx="2" fill="white" opacity="0.85" />
opacity="0.9" {/* Window */}
/> <rect x="92" y="40" width="8" height="6" rx="1" fill="#34CCCD" opacity="0.7" />
{/* Deck */} <rect x="105" y="40" width="8" height="6" rx="1" fill="#34CCCD" opacity="0.7" />
<rect x="60" y="58" width="80" height="17" rx="2" fill="white" opacity="0.9" /> {/* Smokestack */}
{/* Bridge */} <rect x="95" y="22" width="12" height="13" rx="1" fill="#10183A" opacity="0.8" />
<rect x="85" y="35" width="35" height="23" rx="2" fill="white" opacity="0.85" /> <rect x="95" y="22" width="12" height="4" rx="1" fill="#34CCCD" opacity="0.6" />
{/* Window */} {/* Mast */}
<rect x="92" y="40" width="8" height="6" rx="1" fill="#34CCCD" opacity="0.7" /> <line
<rect x="105" y="40" width="8" height="6" rx="1" fill="#34CCCD" opacity="0.7" /> x1="102"
{/* Smokestack */} y1="10"
<rect x="95" y="22" width="12" height="13" rx="1" fill="#10183A" opacity="0.8" /> x2="102"
<rect x="95" y="22" width="12" height="4" rx="1" fill="#34CCCD" opacity="0.6" /> y2="22"
{/* Mast */} stroke="white"
<line x1="102" y1="10" x2="102" y2="22" stroke="white" strokeWidth="1.5" opacity="0.7" /> strokeWidth="1.5"
{/* Flag */} opacity="0.7"
<path d="M102 10 L115 15 L102 20" fill="#34CCCD" opacity="0.8" /> />
{/* Containers on deck */} {/* Flag */}
<rect x="65" y="60" width="12" height="10" rx="1" fill="#067224" opacity="0.7" /> <path d="M102 10 L115 15 L102 20" fill="#34CCCD" opacity="0.8" />
<rect x="79" y="60" width="12" height="10" rx="1" fill="#34CCCD" opacity="0.5" /> {/* Containers on deck */}
<rect x="130" y="60" width="12" height="10" rx="1" fill="#067224" opacity="0.5" /> <rect x="65" y="60" width="12" height="10" rx="1" fill="#067224" opacity="0.7" />
<rect x="144" y="60" width="10" height="10" rx="1" fill="white" opacity="0.3" /> <rect x="79" y="60" width="12" height="10" rx="1" fill="#34CCCD" opacity="0.5" />
</svg> <rect x="130" y="60" width="12" height="10" rx="1" fill="#067224" opacity="0.5" />
<rect x="144" y="60" width="10" height="10" rx="1" fill="white" opacity="0.3" />
{/* Waves layer 1 */} </svg>
<svg
viewBox="0 0 400 30" {/* Waves layer 1 */}
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[150%] animate-wave" <svg
preserveAspectRatio="none" viewBox="0 0 400 30"
xmlns="http://www.w3.org/2000/svg" className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[150%] animate-wave"
> preserveAspectRatio="none"
<path xmlns="http://www.w3.org/2000/svg"
d="M0,15 C50,5 100,25 150,15 C200,5 250,25 300,15 C350,5 400,25 400,15 L400,30 L0,30 Z" >
fill="#34CCCD" <path
opacity="0.3" d="M0,15 C50,5 100,25 150,15 C200,5 250,25 300,15 C350,5 400,25 400,15 L400,30 L0,30 Z"
/> fill="#34CCCD"
</svg> opacity="0.3"
/>
{/* Waves layer 2 */} </svg>
<svg
viewBox="0 0 400 30" {/* Waves layer 2 */}
className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-[160%] animate-wave-slow" <svg
preserveAspectRatio="none" viewBox="0 0 400 30"
xmlns="http://www.w3.org/2000/svg" className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-[160%] animate-wave-slow"
> preserveAspectRatio="none"
<path xmlns="http://www.w3.org/2000/svg"
d="M0,18 C60,8 120,28 180,18 C240,8 300,28 360,18 L400,18 L400,30 L0,30 Z" >
fill="#34CCCD" <path
opacity="0.15" d="M0,18 C60,8 120,28 180,18 C240,8 300,28 360,18 L400,18 L400,30 L0,30 Z"
/> fill="#34CCCD"
</svg> opacity="0.15"
</div> />
</motion.div> </svg>
</div>
{/* 404 */} </motion.div>
<motion.h1
initial={{ opacity: 0, y: 30 }} {/* 404 */}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}} <motion.h1
transition={{ duration: 0.8, delay: 0.4 }} initial={{ opacity: 0, y: 30 }}
className="text-8xl lg:text-[12rem] font-bold text-white mb-2 leading-none font-heading tracking-tight" animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
> transition={{ duration: 0.8, delay: 0.4 }}
404 className="text-8xl lg:text-[12rem] font-bold text-white mb-2 leading-none font-heading tracking-tight"
</motion.h1> >
404
{/* Subtitle */} </motion.h1>
<motion.h2
initial={{ opacity: 0, y: 20 }} {/* Subtitle */}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}} <motion.h2
transition={{ duration: 0.8, delay: 0.5 }} initial={{ opacity: 0, y: 20 }}
className="text-3xl lg:text-5xl font-bold mb-6 font-heading" animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
> transition={{ duration: 0.8, delay: 0.5 }}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green"> className="text-3xl lg:text-5xl font-bold mb-6 font-heading"
Page introuvable >
</span> <span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
</motion.h2> Page introuvable
</span>
{/* Description */} </motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }} {/* Description */}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}} <motion.p
transition={{ duration: 0.8, delay: 0.6 }} initial={{ opacity: 0, y: 20 }}
className="text-lg lg:text-xl text-white/70 mb-12 max-w-2xl mx-auto leading-relaxed font-body" animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
> transition={{ duration: 0.8, delay: 0.6 }}
Ce navire a pris le large... La page que vous cherchez className="text-lg lg:text-xl text-white/70 mb-12 max-w-2xl mx-auto leading-relaxed font-body"
n&apos;existe pas ou a é déplacée. >
</motion.p> Ce navire a pris le large... La page que vous cherchez n&apos;existe pas ou a é
déplacée.
{/* CTA Buttons */} </motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }} {/* CTA Buttons */}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}} <motion.div
transition={{ duration: 0.8, delay: 0.7 }} initial={{ opacity: 0, y: 20 }}
className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6" animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
> transition={{ duration: 0.8, delay: 0.7 }}
<Link className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6"
href="/" >
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2 font-heading" <Link
> href="/"
<Home className="w-5 h-5" /> className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2 font-heading"
<span>Retour à l&apos;accueil</span> >
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" /> <Home className="w-5 h-5" />
</Link> <span>Retour à l&apos;accueil</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
<Link </Link>
href="/dashboard"
className="group px-8 py-4 bg-white/10 backdrop-blur-sm text-white border border-white/20 rounded-lg hover:bg-white/20 transition-all hover:shadow-xl font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2 font-heading" <Link
> href="/dashboard"
<LayoutDashboard className="w-5 h-5" /> className="group px-8 py-4 bg-white/10 backdrop-blur-sm text-white border border-white/20 rounded-lg hover:bg-white/20 transition-all hover:shadow-xl font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2 font-heading"
<span>Tableau de bord</span> >
</Link> <LayoutDashboard className="w-5 h-5" />
</motion.div> <span>Tableau de bord</span>
</div> </Link>
</motion.div>
{/* Bottom wave */} </div>
<div className="absolute bottom-0 left-0 right-0">
<svg {/* Bottom wave */}
className="w-full h-16 lg:h-24" <div className="absolute bottom-0 left-0 right-0">
viewBox="0 0 1440 120" <svg
preserveAspectRatio="none" className="w-full h-16 lg:h-24"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 120"
> preserveAspectRatio="none"
<path xmlns="http://www.w3.org/2000/svg"
d="M0,60 C240,90 480,30 720,60 C960,90 1200,30 1440,60 L1440,120 L0,120 Z" >
fill="white" <path
/> d="M0,60 C240,90 480,30 720,60 C960,90 1200,30 1440,60 L1440,120 L0,120 Z"
</svg> fill="white"
</div> />
</section> </svg>
</div>
<LandingFooter /> </section>
<style jsx global>{` <LandingFooter />
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); } <style jsx global>{`
25% { transform: translateY(-6px) rotate(1deg); } @keyframes float {
75% { transform: translateY(4px) rotate(-1deg); } 0%,
} 100% {
@keyframes wave { transform: translateY(0px) rotate(0deg);
0% { transform: translateX(-50%) translateX(0); } }
100% { transform: translateX(-50%) translateX(-50px); } 25% {
} transform: translateY(-6px) rotate(1deg);
@keyframes wave-slow { }
0% { transform: translateX(-50%) translateX(0); } 75% {
100% { transform: translateX(-50%) translateX(50px); } transform: translateY(4px) rotate(-1deg);
} }
.animate-float { }
animation: float 4s ease-in-out infinite; @keyframes wave {
} 0% {
.animate-wave { transform: translateX(-50%) translateX(0);
animation: wave 3s ease-in-out infinite alternate; }
} 100% {
.animate-wave-slow { transform: translateX(-50%) translateX(-50px);
animation: wave-slow 4s ease-in-out infinite alternate; }
} }
`}</style> @keyframes wave-slow {
</div> 0% {
); transform: translateX(-50%) translateX(0);
} }
100% {
transform: translateX(-50%) translateX(50px);
}
}
.animate-float {
animation: float 4s ease-in-out infinite;
}
.animate-wave {
animation: wave 3s ease-in-out infinite alternate;
}
.animate-wave-slow {
animation: wave-slow 4s ease-in-out infinite alternate;
}
`}</style>
</div>
);
}

View File

@ -62,7 +62,13 @@ function AnimatedCounter({
}, [end, duration, isActive]); }, [end, duration, isActive]);
const display = decimals > 0 ? count.toFixed(decimals) : Math.floor(count).toString(); const display = decimals > 0 ? count.toFixed(decimals) : Math.floor(count).toString();
return <>{prefix}{display}{suffix}</>; return (
<>
{prefix}
{display}
{suffix}
</>
);
} }
export default function LandingPage() { export default function LandingPage() {
@ -139,12 +145,18 @@ export default function LandingPage() {
}, },
]; ];
const stats = [ const stats = [
{ end: 50, prefix: '', suffix: '+', decimals: 0, label: t('stats.carriers'), icon: Ship }, { end: 50, prefix: '', suffix: '+', decimals: 0, label: t('stats.carriers'), icon: Ship },
{ end: 10, prefix: '', suffix: 'K+', decimals: 0, label: t('stats.ports'), icon: Anchor }, { end: 10, prefix: '', suffix: 'K+', decimals: 0, label: t('stats.ports'), icon: Anchor },
{ end: 2, prefix: '<', suffix: 's', decimals: 0, label: t('stats.responseTime'), icon: Zap }, { end: 2, prefix: '<', suffix: 's', decimals: 0, label: t('stats.responseTime'), icon: Zap },
{ end: 99.5, prefix: '', suffix: '%', decimals: 1, label: t('stats.availability'), icon: CheckCircle2 }, {
end: 99.5,
prefix: '',
suffix: '%',
decimals: 1,
label: t('stats.availability'),
icon: CheckCircle2,
},
]; ];
const planFeaturesByKey: Record<string, Array<{ key: string; included: boolean }>> = { const planFeaturesByKey: Record<string, Array<{ key: string; included: boolean }>> = {
@ -245,12 +257,13 @@ export default function LandingPage() {
}, },
] as const; ] as const;
const testimonials = (t.raw('testimonials.items') as Array<{ const testimonials =
quote: string; (t.raw('testimonials.items') as Array<{
author: string; quote: string;
role: string; author: string;
company: string; role: string;
}>) ?? []; company: string;
}>) ?? [];
const containerVariants = { const containerVariants = {
hidden: { opacity: 0, y: 50 }, hidden: { opacity: 0, y: 50 },
@ -289,10 +302,7 @@ export default function LandingPage() {
playsInline playsInline
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full object-cover"
> >
<source <source src="https://assets.mixkit.co/videos/36264/36264-720.mp4" type="video/mp4" />
src="https://assets.mixkit.co/videos/36264/36264-720.mp4"
type="video/mp4"
/>
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
@ -520,7 +530,6 @@ export default function LandingPage() {
</div> </div>
</section> </section>
{/* Partner Logos Section */} {/* Partner Logos Section */}
<section className="py-16 bg-white"> <section className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
@ -531,9 +540,7 @@ export default function LandingPage() {
transition={{ duration: 0.6 }} transition={{ duration: 0.6 }}
className="text-center mb-12" className="text-center mb-12"
> >
<h3 className="text-2xl font-bold text-brand-navy mb-2"> <h3 className="text-2xl font-bold text-brand-navy mb-2">{t('partners.title')}</h3>
{t('partners.title')}
</h3>
<p className="text-gray-600">{t('partners.subtitle')}</p> <p className="text-gray-600">{t('partners.subtitle')}</p>
</motion.div> </motion.div>
@ -604,7 +611,9 @@ export default function LandingPage() {
transition={{ duration: 0.6, delay: 0.2 }} transition={{ duration: 0.6, delay: 0.2 }}
className="flex items-center justify-center gap-4 mb-12" className="flex items-center justify-center gap-4 mb-12"
> >
<span className={`text-sm font-medium ${!billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}> <span
className={`text-sm font-medium ${!billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}
>
{t('pricing.monthly')} {t('pricing.monthly')}
</span> </span>
<button <button
@ -619,7 +628,9 @@ export default function LandingPage() {
}`} }`}
/> />
</button> </button>
<span className={`text-sm font-medium ${billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}> <span
className={`text-sm font-medium ${billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}
>
{t('pricing.yearly')} {t('pricing.yearly')}
</span> </span>
{billingYearly && ( {billingYearly && (
@ -635,7 +646,7 @@ export default function LandingPage() {
animate={isPricingInView ? 'visible' : 'hidden'} animate={isPricingInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch"
> >
{pricingPlans.map((plan) => { {pricingPlans.map(plan => {
const planName = t(`pricing.plans.${plan.key}.name` as any); const planName = t(`pricing.plans.${plan.key}.name` as any);
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);
@ -673,11 +684,17 @@ export default function LandingPage() {
<div className="flex flex-col flex-1 p-6"> <div className="flex flex-col flex-1 p-6">
<div className="mb-4"> <div className="mb-4">
<div className={`inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider px-2.5 py-1 rounded-full mb-3 ${plan.highlighted ? 'bg-white/10 text-white/70' : plan.badgeBg}`}> <div
<div className={`w-1.5 h-1.5 rounded-full bg-gradient-to-r ${plan.accentColor}`} /> className={`inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider px-2.5 py-1 rounded-full mb-3 ${plan.highlighted ? 'bg-white/10 text-white/70' : plan.badgeBg}`}
>
<div
className={`w-1.5 h-1.5 rounded-full bg-gradient-to-r ${plan.accentColor}`}
/>
{planName} {planName}
</div> </div>
<h3 className={`text-xl font-bold mb-1 ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}> <h3
className={`text-xl font-bold mb-1 ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}
>
{planDescription} {planDescription}
</h3> </h3>
</div> </div>
@ -685,34 +702,48 @@ export default function LandingPage() {
<div className="mb-6"> <div className="mb-6">
{plan.monthlyPrice === null ? ( {plan.monthlyPrice === null ? (
<div> <div>
<span className={`text-3xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}> <span
className={`text-3xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}
>
{t('pricing.custom')} {t('pricing.custom')}
</span> </span>
<p className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}> <p
className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}
>
{t('pricing.customSubtitle')} {t('pricing.customSubtitle')}
</p> </p>
</div> </div>
) : plan.monthlyPrice === 0 ? ( ) : plan.monthlyPrice === 0 ? (
<div> <div>
<span className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}> <span
className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}
>
{t('pricing.free')} {t('pricing.free')}
</span> </span>
<p className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}> <p
className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}
>
{t('pricing.freeSubtitle')} {t('pricing.freeSubtitle')}
</p> </p>
</div> </div>
) : ( ) : (
<div> <div>
<div className="flex items-end gap-1"> <div className="flex items-end gap-1">
<span className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}> <span
className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}
>
{billingYearly ? plan.yearlyMonthly : plan.monthlyPrice} {billingYearly ? plan.yearlyMonthly : plan.monthlyPrice}
</span> </span>
<span className={`text-sm pb-1.5 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}> <span
className={`text-sm pb-1.5 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}
>
{t('pricing.perMonth')} {t('pricing.perMonth')}
</span> </span>
</div> </div>
{billingYearly ? ( {billingYearly ? (
<p className={`text-xs mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}> <p
className={`text-xs mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}
>
{t('pricing.billedYearly', { {t('pricing.billedYearly', {
price: numberFormat.format(plan.yearlyPrice ?? 0), price: numberFormat.format(plan.yearlyPrice ?? 0),
})} })}
@ -726,18 +757,30 @@ export default function LandingPage() {
)} )}
</div> </div>
<div className={`rounded-xl p-3 mb-5 space-y-2 ${plan.highlighted ? 'bg-white/10' : 'bg-gray-50'}`}> <div
className={`rounded-xl p-3 mb-5 space-y-2 ${plan.highlighted ? 'bg-white/10' : 'bg-gray-50'}`}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" /> <Users className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}>{planUsers}</span> <span
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}
>
{planUsers}
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Ship className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" /> <Ship className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}>{planShipments}</span> <span
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}
>
{planShipments}
</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" /> <BarChart3 className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}> <span
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}
>
{t('pricing.commission', { rate: plan.commission })} {t('pricing.commission', { rate: plan.commission })}
</span> </span>
</div> </div>
@ -747,15 +790,25 @@ export default function LandingPage() {
{planFeatures.map((feature, featureIndex) => ( {planFeatures.map((feature, featureIndex) => (
<li key={featureIndex} className="flex items-start gap-2.5"> <li key={featureIndex} className="flex items-start gap-2.5">
{feature.included ? ( {feature.included ? (
<Check className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-green'}`} /> <Check
className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-green'}`}
/>
) : ( ) : (
<X className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-white/20' : 'text-gray-300'}`} /> <X
className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-white/20' : 'text-gray-300'}`}
/>
)} )}
<span className={`text-sm ${ <span
feature.included className={`text-sm ${
? plan.highlighted ? 'text-white/90' : 'text-gray-700' feature.included
: plan.highlighted ? 'text-white/30' : 'text-gray-400' ? plan.highlighted
}`}> ? 'text-white/90'
: 'text-gray-700'
: plan.highlighted
? 'text-white/30'
: 'text-gray-400'
}`}
>
{t(`pricing.features.${feature.key}` as any)} {t(`pricing.features.${feature.key}` as any)}
</span> </span>
</li> </li>
@ -768,8 +821,8 @@ export default function LandingPage() {
plan.highlighted plan.highlighted
? 'bg-brand-turquoise text-white hover:bg-brand-turquoise/90 shadow-lg shadow-brand-turquoise/30 hover:shadow-xl' ? 'bg-brand-turquoise text-white hover:bg-brand-turquoise/90 shadow-lg shadow-brand-turquoise/30 hover:shadow-xl'
: plan.key === 'bronze' : plan.key === 'bronze'
? 'bg-gray-100 text-brand-navy hover:bg-gray-200' ? 'bg-gray-100 text-brand-navy hover:bg-gray-200'
: 'bg-brand-navy text-white hover:bg-brand-navy/90 shadow-md hover:shadow-lg' : 'bg-brand-navy text-white hover:bg-brand-navy/90 shadow-md hover:shadow-lg'
}`} }`}
> >
{planCta} {planCta}
@ -786,9 +839,7 @@ export default function LandingPage() {
transition={{ duration: 0.8, delay: 0.5 }} transition={{ duration: 0.8, delay: 0.5 }}
className="mt-12 text-center space-y-2" className="mt-12 text-center space-y-2"
> >
<p className="text-gray-600 text-sm"> <p className="text-gray-600 text-sm">{t('pricing.noCommitment')}</p>
{t('pricing.noCommitment')}
</p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{t('pricing.questions')}{' '} {t('pricing.questions')}{' '}
<Link href="/contact" className="text-brand-turquoise font-medium hover:underline"> <Link href="/contact" className="text-brand-turquoise font-medium hover:underline">
@ -816,10 +867,10 @@ export default function LandingPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-4">{t('howItWorks.title')}</h2> <h2 className="text-4xl lg:text-5xl font-bold text-white mb-4">
<p className="text-xl text-white/80 max-w-2xl mx-auto"> {t('howItWorks.title')}
{t('howItWorks.subtitle')} </h2>
</p> <p className="text-xl text-white/80 max-w-2xl mx-auto">{t('howItWorks.subtitle')}</p>
</motion.div> </motion.div>
<div className="relative"> <div className="relative">
@ -886,9 +937,7 @@ export default function LandingPage() {
<span>{tCommon('tryNow')}</span> <span>{tCommon('tryNow')}</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link> </Link>
<p className="mt-3 text-white/50 text-sm"> <p className="mt-3 text-white/50 text-sm">{t('howItWorks.ctaHint')}</p>
{t('howItWorks.ctaHint')}
</p>
</motion.div> </motion.div>
</div> </div>
</section> </section>
@ -962,9 +1011,7 @@ export default function LandingPage() {
<h2 className="text-2xl sm:text-4xl lg:text-5xl font-bold text-brand-navy mb-4 sm:mb-6"> <h2 className="text-2xl sm:text-4xl lg:text-5xl font-bold text-brand-navy mb-4 sm:mb-6">
{t('cta.title')} {t('cta.title')}
</h2> </h2>
<p className="text-base sm:text-xl text-gray-600 mb-8 sm:mb-10"> <p className="text-base sm:text-xl text-gray-600 mb-8 sm:mb-10">{t('cta.subtitle')}</p>
{t('cta.subtitle')}
</p>
</motion.div> </motion.div>
<motion.div <motion.div

View File

@ -106,7 +106,10 @@ export default function PressPage() {
<LandingHeader activePage="press" /> <LandingHeader activePage="press" />
{/* Hero Section */} {/* Hero Section */}
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"> <section
ref={heroRef}
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
>
<div className="absolute inset-0 opacity-10"> <div className="absolute inset-0 opacity-10">
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" /> <div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" /> <div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
@ -203,9 +206,7 @@ export default function PressPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4"> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('releasesTitle')} {t('releasesTitle')}
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('releasesSubtitle')}</p>
{t('releasesSubtitle')}
</p>
</motion.div> </motion.div>
<motion.div <motion.div
@ -214,7 +215,7 @@ export default function PressPage() {
animate={isNewsInView ? 'visible' : 'hidden'} animate={isNewsInView ? 'visible' : 'hidden'}
className="space-y-6" className="space-y-6"
> >
{RELEASES.map((release) => ( {RELEASES.map(release => (
<motion.div <motion.div
key={release.id} key={release.id}
variants={itemVariants} variants={itemVariants}
@ -272,9 +273,7 @@ export default function PressPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4"> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('coverageTitle')} {t('coverageTitle')}
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('coverageSubtitle')}</p>
{t('coverageSubtitle')}
</p>
</motion.div> </motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -303,7 +302,9 @@ export default function PressPage() {
<h3 className="text-lg font-bold text-brand-navy mb-2 group-hover:text-brand-turquoise transition-colors"> <h3 className="text-lg font-bold text-brand-navy mb-2 group-hover:text-brand-turquoise transition-colors">
{t(`coverage.${article.key}.title`)} {t(`coverage.${article.key}.title`)}
</h3> </h3>
<span className="text-gray-500 text-sm">{t(`coverage.${article.key}.date`)}</span> <span className="text-gray-500 text-sm">
{t(`coverage.${article.key}.date`)}
</span>
</div> </div>
</div> </div>
</motion.a> </motion.a>
@ -321,12 +322,8 @@ export default function PressPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4"> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('kitTitle')}</h2>
{t('kitTitle')} <p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('kitSubtitle')}</p>
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('kitSubtitle')}
</p>
</motion.div> </motion.div>
<motion.div <motion.div
@ -335,7 +332,7 @@ export default function PressPage() {
animate={isResourcesInView ? 'visible' : 'hidden'} animate={isResourcesInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-3 gap-8" className="grid grid-cols-1 md:grid-cols-3 gap-8"
> >
{KIT_ITEMS.map((item) => { {KIT_ITEMS.map(item => {
const IconComponent = item.icon; const IconComponent = item.icon;
return ( return (
<motion.div <motion.div
@ -347,7 +344,9 @@ export default function PressPage() {
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-2xl flex items-center justify-center mb-6"> <div className="w-16 h-16 bg-brand-turquoise/10 rounded-2xl flex items-center justify-center mb-6">
<IconComponent className="w-8 h-8 text-brand-turquoise" /> <IconComponent className="w-8 h-8 text-brand-turquoise" />
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`kit.${item.key}.title`)}</h3> <h3 className="text-xl font-bold text-brand-navy mb-2">
{t(`kit.${item.key}.title`)}
</h3>
<p className="text-gray-600 mb-4">{t(`kit.${item.key}.description`)}</p> <p className="text-gray-600 mb-4">{t(`kit.${item.key}.description`)}</p>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-gray-500">{t(`kit.${item.key}.format`)}</span> <span className="text-sm text-gray-500">{t(`kit.${item.key}.format`)}</span>
@ -367,7 +366,10 @@ export default function PressPage() {
</section> </section>
{/* Milestones */} {/* Milestones */}
<section ref={milestonesRef} className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95"> <section
ref={milestonesRef}
className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95"
>
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
@ -378,9 +380,7 @@ export default function PressPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-4"> <h2 className="text-4xl lg:text-5xl font-bold text-white mb-4">
{t('milestonesTitle')} {t('milestonesTitle')}
</h2> </h2>
<p className="text-xl text-white/80 max-w-2xl mx-auto"> <p className="text-xl text-white/80 max-w-2xl mx-auto">{t('milestonesSubtitle')}</p>
{t('milestonesSubtitle')}
</p>
</motion.div> </motion.div>
<div className="flex flex-wrap justify-center gap-8"> <div className="flex flex-wrap justify-center gap-8">
@ -397,8 +397,12 @@ export default function PressPage() {
<div className="w-20 h-20 bg-brand-turquoise rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-20 h-20 bg-brand-turquoise rounded-full flex items-center justify-center mx-auto mb-4">
<IconComponent className="w-10 h-10 text-white" /> <IconComponent className="w-10 h-10 text-white" />
</div> </div>
<div className="text-2xl font-bold text-brand-turquoise mb-1">{milestone.key}</div> <div className="text-2xl font-bold text-brand-turquoise mb-1">
<div className="text-white/80 max-w-[150px]">{t(`milestones.${milestone.key}` as any)}</div> {milestone.key}
</div>
<div className="text-white/80 max-w-[150px]">
{t(`milestones.${milestone.key}` as any)}
</div>
</motion.div> </motion.div>
); );
})} })}
@ -418,9 +422,7 @@ export default function PressPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4"> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('contact.title')} {t('contact.title')}
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('contact.body')}</p>
{t('contact.body')}
</p>
</motion.div> </motion.div>
<motion.div <motion.div
@ -431,7 +433,9 @@ export default function PressPage() {
> >
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div> <div>
<h3 className="text-2xl font-bold text-brand-navy mb-6">{t('contact.relationsTitle')}</h3> <h3 className="text-2xl font-bold text-brand-navy mb-6">
{t('contact.relationsTitle')}
</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center"> <div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center">
@ -465,17 +469,19 @@ export default function PressPage() {
</div> </div>
<div> <div>
<h3 className="text-2xl font-bold text-brand-navy mb-6">{t('contact.responsibleTitle')}</h3> <h3 className="text-2xl font-bold text-brand-navy mb-6">
{t('contact.responsibleTitle')}
</h3>
<div className="flex items-start space-x-4"> <div className="flex items-start space-x-4">
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center flex-shrink-0"> <div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center flex-shrink-0">
<Users className="w-8 h-8 text-brand-turquoise" /> <Users className="w-8 h-8 text-brand-turquoise" />
</div> </div>
<div> <div>
<div className="text-lg font-bold text-brand-navy">Camille Dumont</div> <div className="text-lg font-bold text-brand-navy">Camille Dumont</div>
<div className="text-brand-turquoise font-medium mb-2">{t('contact.responsibleRole')}</div> <div className="text-brand-turquoise font-medium mb-2">
<p className="text-gray-600 text-sm"> {t('contact.responsibleRole')}
{t('contact.responsibleBio')} </div>
</p> <p className="text-gray-600 text-sm">{t('contact.responsibleBio')}</p>
</div> </div>
</div> </div>
</div> </div>
@ -485,9 +491,7 @@ export default function PressPage() {
<div className="flex items-start space-x-4 bg-gray-50 p-6 rounded-xl"> <div className="flex items-start space-x-4 bg-gray-50 p-6 rounded-xl">
<Quote className="w-8 h-8 text-brand-turquoise flex-shrink-0" /> <Quote className="w-8 h-8 text-brand-turquoise flex-shrink-0" />
<div> <div>
<p className="text-gray-600 italic mb-4"> <p className="text-gray-600 italic mb-4">&ldquo;{t('contact.quote')}&rdquo;</p>
&ldquo;{t('contact.quote')}&rdquo;
</p>
<div className="text-sm"> <div className="text-sm">
<span className="font-bold text-brand-navy">Jean-Pierre Durand</span> <span className="font-bold text-brand-navy">Jean-Pierre Durand</span>
<span className="text-gray-500">{t('contact.quoteRole')}</span> <span className="text-gray-500">{t('contact.quoteRole')}</span>

View File

@ -177,7 +177,9 @@ export default function PricingPage() {
{/* Billing toggle */} {/* Billing toggle */}
<div className="flex items-center justify-center gap-4 mb-12"> <div className="flex items-center justify-center gap-4 mb-12">
<span className={`text-sm font-medium ${billing === 'monthly' ? 'text-gray-900' : 'text-gray-500'}`}> <span
className={`text-sm font-medium ${billing === 'monthly' ? 'text-gray-900' : 'text-gray-500'}`}
>
{t('hero.monthly')} {t('hero.monthly')}
</span> </span>
<button <button
@ -192,7 +194,9 @@ export default function PricingPage() {
}`} }`}
/> />
</button> </button>
<span className={`text-sm font-medium ${billing === 'yearly' ? 'text-gray-900' : 'text-gray-500'}`}> <span
className={`text-sm font-medium ${billing === 'yearly' ? 'text-gray-900' : 'text-gray-500'}`}
>
{t('hero.yearly')} {t('hero.yearly')}
</span> </span>
{billing === 'yearly' && ( {billing === 'yearly' && (
@ -206,7 +210,7 @@ export default function PricingPage() {
{/* Plans grid */} {/* Plans grid */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20"> <section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{PLANS.map((plan) => ( {PLANS.map(plan => (
<div <div
key={plan.key} key={plan.key}
className={`relative rounded-2xl border-2 p-6 flex flex-col ${ className={`relative rounded-2xl border-2 p-6 flex flex-col ${
@ -227,11 +231,15 @@ export default function PricingPage() {
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<h3 className="text-xl font-bold text-gray-900">{t(`plans.${plan.key}.name`)}</h3> <h3 className="text-xl font-bold text-gray-900">{t(`plans.${plan.key}.name`)}</h3>
{plan.badge && ( {plan.badge && (
<Shield className={`w-5 h-5 ${ <Shield
plan.badge === 'silver' ? 'text-slate-500' : className={`w-5 h-5 ${
plan.badge === 'gold' ? 'text-yellow-500' : plan.badge === 'silver'
'text-purple-500' ? 'text-slate-500'
}`} /> : plan.badge === 'gold'
? 'text-yellow-500'
: 'text-purple-500'
}`}
/>
)} )}
</div> </div>
@ -249,7 +257,9 @@ export default function PricingPage() {
{billing === 'monthly' {billing === 'monthly'
? formatPrice(plan.monthlyPrice) ? formatPrice(plan.monthlyPrice)
: formatPrice(Math.round(plan.yearlyPrice / 12))} : formatPrice(Math.round(plan.yearlyPrice / 12))}
<span className="text-base font-normal text-gray-500">{t('currency.perMonth')}</span> <span className="text-base font-normal text-gray-500">
{t('currency.perMonth')}
</span>
</p> </p>
{billing === 'yearly' && ( {billing === 'yearly' && (
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
@ -284,7 +294,7 @@ export default function PricingPage() {
{/* Features */} {/* Features */}
<div className="flex-1 space-y-2 mb-6"> <div className="flex-1 space-y-2 mb-6">
{plan.features.map((feature) => ( {plan.features.map(feature => (
<div key={feature.key} className="flex items-center gap-2 text-sm"> <div key={feature.key} className="flex items-center gap-2 text-sm">
{feature.included ? ( {feature.included ? (
<Check className="w-4 h-4 text-green-500 flex-shrink-0" /> <Check className="w-4 h-4 text-green-500 flex-shrink-0" />

Some files were not shown because too many files have changed in this diff Show More