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

@ -75,14 +75,14 @@ async function createTestBooking() {
'test@carrier.com',
'NLRTM', // Rotterdam
'USNYC', // New York
25.5, // volume_cbm
3500, // weight_kg
10, // pallet_count
1850.50, // price_usd
25.5, // volume_cbm
3500, // weight_kg
10, // pallet_count
1850.5, // price_usd
1665.45, // price_eur
'USD', // primary_currency
28, // transit_days
'LCL', // container_type
'USD', // primary_currency
28, // transit_days
'LCL', // container_type
'PENDING', // status - IMPORTANT!
confirmationToken,
'Test booking created by script',
@ -102,7 +102,6 @@ async function createTestBooking() {
console.log('\n📧 URL API (pour curl):');
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');
} catch (error) {
console.error('❌ Erreur:', error.message);
console.error(error);

View File

@ -10,7 +10,7 @@
require('dotenv').config();
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));
// 1. Afficher la configuration
@ -20,7 +20,10 @@ console.log('SMTP_HOST:', process.env.SMTP_HOST);
console.log('SMTP_PORT:', process.env.SMTP_PORT);
console.log('SMTP_SECURE:', process.env.SMTP_SECURE);
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(
'SMTP_PASS:',
process.env.SMTP_PASS ? '***' + process.env.SMTP_PASS.slice(-4) : 'NON DÉFINI'
);
console.log('SMTP_FROM:', process.env.SMTP_FROM);
console.log('APP_URL:', process.env.APP_URL);
@ -120,7 +123,7 @@ async function sendSimpleEmail() {
console.log(' Rejected:', info.rejected);
return true;
} catch (error) {
console.error('❌ Échec d\'envoi email simple:');
console.error("❌ Échec d'envoi email simple:");
console.error(' Message:', error.message);
console.error(' Code:', error.code);
return false;
@ -254,7 +257,7 @@ async function sendCarrierEmail() {
console.log(' Sujet: Nouvelle demande de réservation - FRPAR → USNYC');
return true;
} catch (error) {
console.error('\n❌ Échec d\'envoi email transporteur:');
console.error("\n❌ Échec d'envoi email transporteur:");
console.error(' Message:', error.message);
console.error(' Code:', error.code);
console.error(' ResponseCode:', error.responseCode);
@ -282,7 +285,7 @@ async function runAllTests() {
// Test 2: Email simple
const simpleEmailOk = await sendSimpleEmail();
if (!simpleEmailOk) {
console.log('\n⚠ L\'email simple a échoué, mais on continue...');
console.log("\n⚠ L'email simple a échoué, mais on continue...");
}
// Test 3: Email transporteur
@ -298,9 +301,9 @@ async function runAllTests() {
if (connectionOk && simpleEmailOk && carrierEmailOk) {
console.log('\n✅ TOUS LES TESTS ONT RÉUSSI!');
console.log(' Le système d\'envoi d\'email fonctionne correctement.');
console.log(" Le système d'envoi d'email fonctionne correctement.");
console.log(' Si vous ne recevez pas les emails dans le backend,');
console.log(' le problème vient de l\'intégration NestJS.');
console.log(" le problème vient de l'intégration NestJS.");
} else {
console.log('\n❌ CERTAINS TESTS ONT ÉCHOUÉ');
console.log(' Vérifiez les erreurs ci-dessus pour comprendre le problème.');

View File

@ -100,7 +100,7 @@ deleteTestDocuments()
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
.catch(error => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -14,7 +14,7 @@ function fixImportsInFile(filePath) {
// Replace relative imports to ../ports/ with @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) {
fs.writeFileSync(filePath, modified, 'utf8');

View File

@ -37,7 +37,7 @@ async function fixDummyUrls() {
const documents = row.documents;
// Update each document URL
const updatedDocuments = documents.map((doc) => {
const updatedDocuments = documents.map(doc => {
if (doc.filePath && doc.filePath.includes('dummy-storage')) {
// Extract filename from dummy URL
const fileName = doc.fileName || doc.filePath.split('/').pop();
@ -58,10 +58,10 @@ async function fixDummyUrls() {
});
// Update the database
await client.query(
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
[JSON.stringify(updatedDocuments), bookingId]
);
await client.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
JSON.stringify(updatedDocuments),
bookingId,
]);
updatedCount++;
console.log(`✅ Updated booking ${bookingId}\n`);
@ -84,7 +84,7 @@ fixDummyUrls()
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
.catch(error => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -24,10 +24,13 @@ function fixImportsInFile(filePath) {
modified = modified.replace(/from ['"]\.\.\/domain\//g, "from '@domain/");
// Also fix import statements (not just from)
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, "import $1@domain/");
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/domain\//g, "import $1@domain/");
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/domain\//g, "import $1@domain/");
modified = modified.replace(/import\s+(['"])\.\.\/domain\//g, "import $1@domain/");
modified = modified.replace(
/import\s+(['"])\.\.\/\.\.\/\.\.\/\.\.\/domain\//g,
'import $1@domain/'
);
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/domain\//g, '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) {
fs.writeFileSync(filePath, modified, 'utf8');

View File

@ -34,7 +34,7 @@ async function fixMinioHostname() {
const documents = row.documents;
// Update each document URL
const updatedDocuments = documents.map((doc) => {
const updatedDocuments = documents.map(doc => {
if (doc.filePath && doc.filePath.includes('http://minio:9000')) {
const newUrl = doc.filePath.replace('http://minio:9000', 'http://localhost:9000');
@ -51,10 +51,10 @@ async function fixMinioHostname() {
});
// Update the database
await client.query(
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
[JSON.stringify(updatedDocuments), bookingId]
);
await client.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
JSON.stringify(updatedDocuments),
bookingId,
]);
updatedCount++;
console.log(`✅ Updated booking ${bookingId}\n`);
@ -75,7 +75,7 @@ fixMinioHostname()
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
.catch(error => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -86,7 +86,7 @@ listFiles()
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
.catch(error => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -9,18 +9,21 @@ async function loginAndTestEmail() {
console.log('🔐 Connexion...');
const loginResponse = await axios.post(`${API_URL}/auth/login`, {
email: 'admin@xpeditis.com',
password: 'Admin123!@#'
password: 'Admin123!@#',
});
const token = loginResponse.data.accessToken;
console.log('✅ Connecté avec succès\n');
// 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 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('carrierEmail', 'testcarrier@example.com');
@ -39,8 +42,8 @@ async function loginAndTestEmail() {
const bookingResponse = await axios.post(`${API_URL}/csv-bookings`, form, {
headers: {
...form.getHeaders(),
'Authorization': `Bearer ${token}`
}
Authorization: `Bearer ${token}`,
},
});
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('3. Email devrait être envoyé à: testcarrier@example.com');
console.log('\n⏳ Attendez quelques secondes puis vérifiez les logs du backend...');
} catch (error) {
console.error('❌ ERREUR:');
if (error.response) {

View File

@ -120,11 +120,17 @@ async function restoreDocumentReferences() {
// Determine document type
let docType = 'OTHER';
if (file.fileName.toLowerCase().includes('bill-of-lading') || file.fileName.toLowerCase().includes('bol')) {
if (
file.fileName.toLowerCase().includes('bill-of-lading') ||
file.fileName.toLowerCase().includes('bol')
) {
docType = 'BILL_OF_LADING';
} else if (file.fileName.toLowerCase().includes('packing-list')) {
docType = 'PACKING_LIST';
} else if (file.fileName.toLowerCase().includes('commercial-invoice') || file.fileName.toLowerCase().includes('invoice')) {
} else if (
file.fileName.toLowerCase().includes('commercial-invoice') ||
file.fileName.toLowerCase().includes('invoice')
) {
docType = 'COMMERCIAL_INVOICE';
}
@ -143,10 +149,10 @@ async function restoreDocumentReferences() {
});
// Update the booking with new document references
await pgClient.query(
'UPDATE csv_bookings SET documents = $1 WHERE id = $2',
[JSON.stringify(newDocuments), bookingId]
);
await pgClient.query('UPDATE csv_bookings SET documents = $1 WHERE id = $2', [
JSON.stringify(newDocuments),
bookingId,
]);
updatedCount++;
createdDocsCount += newDocuments.length;
@ -170,7 +176,7 @@ restoreDocumentReferences()
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
.catch(error => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -28,7 +28,7 @@ AppDataSource.initialize()
console.log('✅ No pending migrations');
} else {
console.log(`✅ Successfully ran ${migrations.length} migration(s):`);
migrations.forEach((migration) => {
migrations.forEach(migration => {
console.log(` - ${migration.name}`);
});
}
@ -37,7 +37,7 @@ AppDataSource.initialize()
console.log('✅ Database migrations completed successfully');
process.exit(0);
})
.catch((error) => {
.catch(error => {
console.error('❌ Error during migration:');
console.error(error);
process.exit(1);

View File

@ -210,10 +210,7 @@ function parseSeaPorts(filePath: string): ParsedPort[] {
// Validate coordinates
const [longitude, latitude] = port.coordinates;
if (
latitude < -90 || latitude > 90 ||
longitude < -180 || longitude > 180
) {
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
skipped++;
continue;
}
@ -244,13 +241,14 @@ function generateSQLInserts(ports: ParsedPort[]): string {
for (let i = 0; i < ports.length; i += batchSize) {
const batch = ports.slice(i, i + batchSize);
const values = batch.map(port => {
const name = port.name.replace(/'/g, "''");
const city = port.city.replace(/'/g, "''");
const countryName = port.countryName.replace(/'/g, "''");
const timezone = port.timezone ? `'${port.timezone}'` : 'NULL';
const values = batch
.map(port => {
const name = port.name.replace(/'/g, "''");
const city = port.city.replace(/'/g, "''");
const countryName = port.countryName.replace(/'/g, "''");
const timezone = port.timezone ? `'${port.timezone}'` : 'NULL';
return `(
return `(
'${port.code}',
'${name}',
'${city}',
@ -261,7 +259,8 @@ function generateSQLInserts(ports: ParsedPort[]): string {
${timezone},
${port.isActive}
)`;
}).join(',\n ');
})
.join(',\n ');
batches.push(`
// 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)) {
console.error('❌ Error: /tmp/sea-ports.json not found!');
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);
}
@ -342,7 +343,10 @@ async function main() {
const migrationContent = generateMigration(ports);
// 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 fileName = `${timestamp}-SeedPorts.ts`;
const filePath = path.join(migrationsDir, fileName);

View File

@ -5,7 +5,10 @@
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() {
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_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx');
console.log('STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx');
} catch (error) {
console.error('Error fetching prices:', error.message);
}

View File

@ -73,7 +73,7 @@ setBucketPolicy()
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
.catch(error => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

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

View File

@ -29,18 +29,20 @@ import { CsvBookingsModule } from '../csv-bookings.module';
// Email
import { EmailModule } from '@infrastructure/email/email.module';
/**
* Admin Module
*
* Provides admin-only endpoints for managing all data in the system.
* All endpoints require ADMIN role.
*/
// Blog
import { BlogModule } from '../blog/blog.module';
// Storage
import { StorageModule } from '@infrastructure/storage/storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]),
ConfigModule,
CsvBookingsModule,
EmailModule,
BlogModule,
StorageModule,
],
controllers: [AdminController],
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,
Param,
Body,
Query,
HttpCode,
HttpStatus,
Logger,
@ -15,14 +16,22 @@ import {
BadRequestException,
ParseUUIDPipe,
UseGuards,
UseInterceptors,
UploadedFile,
Inject,
} 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 {
ApiTags,
ApiOperation,
ApiResponse,
ApiNotFoundResponse,
ApiParam,
ApiQuery,
ApiConsumes,
ApiBearerAuth,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
@ -56,6 +65,25 @@ import {
// Email imports
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
*
@ -80,7 +108,9 @@ export class AdminController {
private readonly csvBookingService: CsvBookingService,
@Inject(SIRET_VERIFICATION_PORT)
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 ====================
@ -912,4 +942,134 @@ export class AdminController {
this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`);
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(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,
DeleteObjectCommand,
HeadObjectCommand,
HeadBucketCommand,
CreateBucketCommand,
ListObjectsV2Command,
} from '@aws-sdk/client-s3';
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> {
if (!this.s3Client) {
throw new Error(
@ -77,6 +96,8 @@ export class S3StorageAdapter implements StoragePort {
);
}
await this.ensureBucket(options.bucket);
try {
const command = new PutObjectCommand({
Bucket: options.bucket,
@ -108,6 +129,12 @@ export class S3StorageAdapter implements StoragePort {
}
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 {
const command = new GetObjectCommand({
Bucket: options.bucket,

View File

@ -58,7 +58,7 @@ async function runMigrations() {
console.log('✅ No pending migrations');
} else {
console.log(`✅ Successfully ran ${migrations.length} migration(s):`);
migrations.forEach((migration) => {
migrations.forEach(migration => {
console.log(` - ${migration.name}`);
});
}
@ -77,10 +77,10 @@ function startApplication() {
const app = spawn('node', ['dist/main'], {
stdio: 'inherit',
env: process.env
env: process.env,
});
app.on('exit', (code) => {
app.on('exit', code => {
process.exit(code);
});
@ -96,7 +96,7 @@ async function main() {
startApplication();
}
main().catch((error) => {
main().catch(error => {
console.error('❌ Startup failed:', error);
process.exit(1);
});

View File

@ -114,10 +114,10 @@ async function syncDatabase() {
});
// Update the database
await pgClient.query(
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
[JSON.stringify(validDocuments), bookingId]
);
await pgClient.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
JSON.stringify(validDocuments),
bookingId,
]);
updatedCount++;
removedDocsCount += missingDocuments.length;
@ -148,7 +148,7 @@ syncDatabase()
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
.catch(error => {
console.error('\n❌ Script failed:', error);
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
const TEST_USER = {
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() {
@ -56,16 +56,12 @@ async function testWorkflow() {
contentType: 'application/pdf',
});
const bookingResponse = await axios.post(
`${API_BASE}/csv-bookings`,
form,
{
headers: {
...form.getHeaders(),
Authorization: `Bearer ${token}`,
},
}
);
const bookingResponse = await axios.post(`${API_BASE}/csv-bookings`, form, {
headers: {
...form.getHeaders(),
Authorization: `Bearer ${token}`,
},
});
console.log('✅ Booking created successfully!');
console.log('📦 Booking ID:', bookingResponse.data.id);
@ -80,7 +76,9 @@ async function testWorkflow() {
console.error('❌ Error:', error.response?.data || error.message);
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) {

View File

@ -213,7 +213,9 @@ async function testEmailConfig() {
console.log('📊 Résumé des tests:');
console.log(' ✓ Vérifiez Mailtrap inbox: https://mailtrap.io/inboxes');
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
@ -222,7 +224,7 @@ testEmailConfig()
console.log('✅ Tests terminés avec succès');
process.exit(0);
})
.catch((error) => {
.catch(error => {
console.error('❌ Erreur lors des tests:', error);
process.exit(1);
});

View File

@ -5,25 +5,28 @@ const transporter = nodemailer.createTransport({
port: 2525,
auth: {
user: '2597bd31d265eb',
pass: 'cd126234193c89'
}
pass: 'cd126234193c89',
},
});
console.log('🔄 Tentative d\'envoi d\'email...');
console.log("🔄 Tentative d'envoi d'email...");
transporter.sendMail({
from: 'noreply@xpeditis.com',
to: 'test@example.com',
subject: 'Test Email depuis Portail Transporteur',
text: 'Email de test pour vérifier la configuration'
}).then(info => {
console.log('✅ Email envoyé:', info.messageId);
console.log('📧 Response:', info.response);
process.exit(0);
}).catch(err => {
console.error('❌ Erreur:', err.message);
console.error('Code:', err.code);
console.error('Command:', err.command);
console.error('Stack:', err.stack);
process.exit(1);
});
transporter
.sendMail({
from: 'noreply@xpeditis.com',
to: 'test@example.com',
subject: 'Test Email depuis Portail Transporteur',
text: 'Email de test pour vérifier la configuration',
})
.then(info => {
console.log('✅ Email envoyé:', info.messageId);
console.log('📧 Response:', info.response);
process.exit(0);
})
.catch(err => {
console.error('❌ Erreur:', err.message);
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...');
transporter.verify()
transporter
.verify()
.then(() => {
console.log('✅ SMTP connection verified!');
console.log('\n2⃣ Sending test email...');
@ -40,17 +41,17 @@ transporter.verify()
from: 'noreply@xpeditis.com',
to: 'test@example.com',
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('📧 Message ID:', info.messageId);
console.log('📬 Response:', info.response);
console.log('\n🎉 SUCCESS! Email sending works with IP directly.');
process.exit(0);
})
.catch((error) => {
.catch(error => {
console.error('\n❌ ERROR:', error.message);
console.error('Code:', error.code);
console.error('Command:', error.command);

View File

@ -6,7 +6,8 @@ const axios = require('axios');
const API_URL = 'http://localhost:4000/api/v1';
// 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() {
console.log('🧪 Test envoi email via CSV booking...\n');
@ -19,7 +20,10 @@ async function testCsvBookingEmail() {
// Créer un fichier de test temporaire
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
form.append('carrierName', 'Test Carrier Email');
@ -41,8 +45,8 @@ async function testCsvBookingEmail() {
const response = await axios.post(`${API_URL}/csv-bookings`, form, {
headers: {
...form.getHeaders(),
'Authorization': `Bearer ${AUTH_TOKEN}`
}
Authorization: `Bearer ${AUTH_TOKEN}`,
},
});
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('2. Votre inbox Mailtrap: https://mailtrap.io/inboxes');
console.log('3. Email destinataire: test-carrier@example.com');
} catch (error) {
console.error('❌ Erreur:', error.response?.data || error.message);
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('{ "email": "admin@xpeditis.com", "password": "..." }');
}

View File

@ -31,7 +31,8 @@ const transporter = nodemailer.createTransport(config);
console.log('\nVerifying SMTP connection...');
transporter.verify()
transporter
.verify()
.then(() => {
console.log('✅ SMTP connection verified successfully!');
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>',
});
})
.then((info) => {
.then(info => {
console.log('✅ Email sent successfully!');
console.log('Message ID:', info.messageId);
console.log('Response:', info.response);
process.exit(0);
})
.catch((error) => {
.catch(error => {
console.error('❌ Error:', error.message);
console.error('Full error:', error);
process.exit(1);

View File

@ -49,12 +49,12 @@ async function test() {
await transporter.verify();
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({
from: 'noreply@xpeditis.com',
to: 'test@example.com',
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!');

View File

@ -179,7 +179,7 @@ uploadTestDocuments()
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
.catch(error => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -90,7 +90,10 @@ export default function AboutPage() {
<LandingHeader activePage="about" />
{/* 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 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" />
@ -155,9 +158,7 @@ export default function AboutPage() {
<Target className="w-8 h-8 text-white" />
</div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('mission.title')}</h2>
<p className="text-gray-600 text-lg leading-relaxed">
{t('mission.body')}
</p>
<p className="text-gray-600 text-lg leading-relaxed">{t('mission.body')}</p>
</motion.div>
<motion.div
@ -168,9 +169,7 @@ export default function AboutPage() {
<Eye className="w-8 h-8 text-white" />
</div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('vision.title')}</h2>
<p className="text-gray-600 text-lg leading-relaxed">
{t('vision.body')}
</p>
<p className="text-gray-600 text-lg leading-relaxed">{t('vision.body')}</p>
</motion.div>
</motion.div>
</div>
@ -186,11 +185,7 @@ export default function AboutPage() {
>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{STATS.map((stat, index) => (
<motion.div
key={stat.key}
variants={itemVariants}
className="text-center"
>
<motion.div key={stat.key} variants={itemVariants} className="text-center">
<motion.div
initial={{ scale: 0 }}
animate={isStatsInView ? { scale: 1 } : {}}
@ -215,10 +210,10 @@ export default function AboutPage() {
transition={{ duration: 0.8 }}
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('valuesTitle')}</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('valuesSubtitle')}
</p>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('valuesTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('valuesSubtitle')}</p>
</motion.div>
<motion.div
@ -227,7 +222,7 @@ export default function AboutPage() {
animate={isValuesInView ? 'visible' : 'hidden'}
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;
return (
<motion.div
@ -241,7 +236,9 @@ export default function AboutPage() {
>
<IconComponent className="w-7 h-7 text-white" />
</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>
</motion.div>
);
@ -259,10 +256,10 @@ export default function AboutPage() {
transition={{ duration: 0.8 }}
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('timelineTitle')}</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('timelineSubtitle')}
</p>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('timelineTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('timelineSubtitle')}</p>
</motion.div>
<div className="relative">
@ -286,13 +283,19 @@ export default function AboutPage() {
transition={{ duration: 0.7, ease: 'easeOut' }}
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={`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" />
<span className="text-2xl font-bold text-brand-turquoise">{year}</span>
</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>
</div>
</div>
@ -302,7 +305,13 @@ export default function AboutPage() {
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
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"
/>
</div>
@ -324,10 +333,10 @@ export default function AboutPage() {
transition={{ duration: 0.8 }}
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('teamTitle')}</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('teamSubtitle')}
</p>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('teamTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('teamSubtitle')}</p>
</motion.div>
<motion.div
@ -336,7 +345,7 @@ export default function AboutPage() {
animate={isTeamInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
{TEAM.map((member) => (
{TEAM.map(member => (
<motion.div
key={member.key}
variants={itemVariants}
@ -358,7 +367,9 @@ export default function AboutPage() {
</div>
<div className="p-6">
<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>
</div>
</motion.div>
@ -376,12 +387,8 @@ export default function AboutPage() {
viewport={{ once: true }}
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
{t('cta.title')}
</h2>
<p className="text-xl text-white/80 mb-10">
{t('cta.body')}
</p>
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">{t('cta.title')}</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">
<Link
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';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { motion, useInView } from 'framer-motion';
@ -19,9 +19,16 @@ import {
type LucideIcon,
} from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout';
import { getBlogPosts, type BlogPost, type BlogPostCategory } from '@/lib/api/blog';
type CategoryKey = 'all' | 'industry' | 'technology' | 'guides' | 'news';
type ArticleKey = 'incoterms' | 'costs' | 'ports' | 'funding' | 'green' | 'api' | 'documents';
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}`;
}
type CategoryKey = 'all' | BlogPostCategory;
const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [
{ key: 'all', icon: BookOpen },
@ -31,20 +38,36 @@ const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [
{ key: 'news', icon: Globe },
];
const ARTICLES: { id: number; key: ArticleKey; category: Exclude<CategoryKey, 'all'>; tags: string[] }[] = [
{ id: 2, key: 'incoterms', category: 'guides', tags: ['Incoterms', 'Guide', 'Commerce'] },
{ id: 3, key: 'costs', category: 'guides', tags: ['Optimisation', 'Costs', 'Strategy'] },
{ id: 4, key: 'ports', category: 'industry', tags: ['Ports', 'Europe', 'Stats'] },
{ id: 5, key: 'funding', category: 'news', tags: ['Funding', 'Growth', 'Xpeditis'] },
{ id: 6, key: 'green', category: 'industry', tags: ['Environment', 'Decarbonization', 'Sustainability'] },
{ id: 7, key: 'api', category: 'technology', tags: ['API', 'Integration', 'Technical'] },
{ id: 8, key: 'documents', category: 'guides', tags: ['Documents', 'Export', 'Customs'] },
];
const containerVariants = {
hidden: { opacity: 0, y: 50 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, staggerChildren: 0.1 },
},
};
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() {
const t = useTranslations('marketing.blog');
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all');
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 articlesRef = useRef(null);
@ -54,44 +77,50 @@ export default function BlogPage() {
const isArticlesInView = useInView(articlesRef, { once: true });
const isCategoriesInView = useInView(categoriesRef, { once: true });
const filteredArticles = ARTICLES.filter((article) => {
const categoryMatch = selectedCategory === 'all' || article.category === selectedCategory;
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;
});
useEffect(() => {
let cancelled = false;
const containerVariants = {
hidden: { opacity: 0, y: 50 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
staggerChildren: 0.1,
},
},
};
const load = async () => {
setLoading(true);
try {
const res = await getBlogPosts({
category: selectedCategory !== 'all' ? selectedCategory : undefined,
search: searchQuery || undefined,
limit: 50,
});
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 = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5 },
},
};
load();
return () => {
cancelled = true;
};
}, [selectedCategory, searchQuery]);
return (
<div className="min-h-screen bg-white">
<LandingHeader activePage="blog" />
{/* 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 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" />
@ -126,7 +155,6 @@ export default function BlogPage() {
{t('intro')}
</p>
{/* Search Bar */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
@ -139,7 +167,7 @@ export default function BlogPage() {
type="text"
placeholder={t('searchPlaceholder')}
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"
/>
</div>
@ -147,7 +175,6 @@ export default function BlogPage() {
</motion.div>
</div>
{/* Wave */}
<div className="absolute bottom-0 left-0 right-0">
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
<path
@ -167,7 +194,7 @@ export default function BlogPage() {
className="max-w-7xl mx-auto px-6 lg:px-8"
>
<div className="flex flex-wrap items-center justify-center gap-4">
{CATEGORIES.map((category) => {
{CATEGORIES.map(category => {
const IconComponent = category.icon;
const isActive = selectedCategory === category.key;
return (
@ -190,64 +217,71 @@ export default function BlogPage() {
</section>
{/* Featured Article */}
<section className="py-16">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
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">
<div className="absolute inset-0 bg-gradient-to-r from-brand-navy via-brand-navy/80 to-transparent z-10" />
<div className="absolute right-0 top-0 bottom-0 w-1/2 bg-brand-turquoise/20 flex items-center justify-center">
<Anchor className="w-48 h-48 text-white/10" />
</div>
<div className="relative z-20 p-8 lg:p-12">
<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">
{t('featuredBadge')}
</span>
<span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full">
{t('categories.technology')}
</span>
{!loading && featuredPost && (
<section className="py-16">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
>
<Link href={`/blog/${featuredPost.slug}`}>
<div className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden group cursor-pointer">
<div className="absolute inset-0 bg-gradient-to-r from-brand-navy via-brand-navy/80 to-transparent z-10" />
{featuredPost.coverImageUrl ? (
<div
className="absolute right-0 top-0 bottom-0 w-1/2 bg-cover bg-center"
style={{ backgroundImage: `url(${resolveUrl(featuredPost.coverImageUrl)})` }}
/>
) : (
<div className="absolute right-0 top-0 bottom-0 w-1/2 bg-brand-turquoise/20 flex items-center justify-center">
<Anchor className="w-48 h-48 text-white/10" />
</div>
)}
<h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors">
{t('featured.title')}
</h2>
<p className="text-lg text-white/80 mb-6">{t('featured.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>{t('featured.author')}</span>
<div className="relative z-20 p-8 lg:p-12">
<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">
{t('featuredBadge')}
</span>
<span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full">
{t(`categories.${featuredPost.category}`)}
</span>
</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">
<span>{t('readArticle')}</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
<h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors">
{featuredPost.title}
</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>
</Link>
</motion.div>
</div>
</section>
</Link>
</motion.div>
</div>
</section>
)}
{/* Articles Grid */}
<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"
>
<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>
{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">
<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>
@ -275,30 +325,36 @@ export default function BlogPage() {
animate={isArticlesInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
{filteredArticles.map((article) => (
<motion.div key={article.id} variants={itemVariants}>
<Link href={`/blog/${article.id}`}>
{posts.map(post => (
<motion.div key={post.id} variants={itemVariants}>
<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="aspect-video bg-gradient-to-br from-brand-navy/10 to-brand-turquoise/10 flex items-center justify-center relative">
<Ship className="w-16 h-16 text-brand-navy/20" />
<div className="aspect-video bg-gradient-to-br from-brand-navy/10 to-brand-turquoise/10 flex items-center justify-center relative overflow-hidden">
{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">
<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>
</div>
</div>
<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">
{t(`articles.${article.key}.title` as any)}
{post.title}
</h3>
<p className="text-gray-600 mb-4 line-clamp-2 flex-1">
{t(`articles.${article.key}.excerpt` as any)}
</p>
<p className="text-gray-600 mb-4 line-clamp-2 flex-1">{post.excerpt}</p>
<div className="flex flex-wrap gap-2 mb-4">
{article.tags.map((tag) => (
{post.tags.map(tag => (
<span
key={tag}
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">
<User className="w-4 h-4 text-brand-turquoise" />
</div>
<span>{t(`articles.${article.key}.author` as any)}</span>
<span>{post.authorName}</span>
</div>
<div className="flex items-center space-x-4">
<span>{t(`articles.${article.key}.date` as any)}</span>
<span className="flex items-center space-x-1">
{post.publishedAt && (
<div className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
<span>{t(`articles.${article.key}.readTime` as any)}</span>
</span>
</div>
<span>{formatDate(post.publishedAt)}</span>
</div>
)}
</div>
</div>
</div>
@ -330,21 +385,6 @@ export default function BlogPage() {
))}
</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>
</section>
@ -357,12 +397,8 @@ export default function BlogPage() {
viewport={{ once: true }}
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl font-bold text-white mb-6">
{t('newsletter.title')}
</h2>
<p className="text-xl text-white/80 mb-10">
{t('newsletter.body')}
</p>
<h2 className="text-4xl font-bold text-white mb-6">{t('newsletter.title')}</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">
<input
type="email"
@ -377,9 +413,7 @@ export default function BlogPage() {
<ArrowRight className="w-5 h-5" />
</button>
</form>
<p className="text-white/50 text-sm mt-4">
{t('newsletter.disclaimer')}
</p>
<p className="text-white/50 text-sm mt-4">{t('newsletter.disclaimer')}</p>
</motion.div>
</div>
</section>

View File

@ -81,9 +81,7 @@ export default function BookingConfirmPage() {
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
{t('errorTitle')}
</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('errorTitle')}</h1>
<p className="text-gray-600">{error}</p>
</div>
@ -98,9 +96,7 @@ export default function BookingConfirmPage() {
</ul>
</div>
<p className="text-sm text-gray-500 text-center">
{t('errorContact')}
</p>
<p className="text-sm text-gray-500 text-center">{t('errorContact')}</p>
</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>
<h1 className="text-3xl font-bold text-gray-900 mb-3">
{t('successTitle')}
</h1>
<p className="text-lg text-gray-600 mb-2">
{t('successHeadline')}
</p>
<p className="text-gray-500">
{t('successBody')}
</p>
<h1 className="text-3xl font-bold text-gray-900 mb-3">{t('successTitle')}</h1>
<p className="text-lg text-gray-600 mb-2">{t('successHeadline')}</p>
<p className="text-gray-500">{t('successBody')}</p>
</div>
{/* Booking Summary */}
<div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
{t('summaryTitle')}
</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('summaryTitle')}</h2>
<div className="space-y-3">
<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">
{booking.primaryCurrency === 'USD'
? `$${booking.priceUSD.toLocaleString()}`
: `${booking.priceEUR.toLocaleString()}`
}
: `${booking.priceEUR.toLocaleString()}`}
</div>
<div className="text-sm text-gray-500">
{booking.primaryCurrency === 'USD'
? `(€${booking.priceEUR.toLocaleString()})`
: `($${booking.priceUSD.toLocaleString()})`
}
: `($${booking.priceUSD.toLocaleString()})`}
</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">
<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">
<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>
{t('nextStepsTitle')}
</h3>
@ -239,10 +230,23 @@ export default function BookingConfirmPage() {
<h3 className="font-semibold text-gray-900 mb-3">{t('labels.documents')}</h3>
<div className="space-y-2">
{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">
<svg 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
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>
<div>
<p className="text-sm font-medium text-gray-900">{doc.fileName}</p>

View File

@ -89,9 +89,7 @@ export default function BookingRejectPage() {
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
{t('errorTitle')}
</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('errorTitle')}</h1>
<p className="text-gray-600">{error}</p>
</div>
@ -106,9 +104,7 @@ export default function BookingRejectPage() {
</ul>
</div>
<p className="text-sm text-gray-500 text-center">
{t('errorContact')}
</p>
<p className="text-sm text-gray-500 text-center">{t('errorContact')}</p>
</div>
</div>
);
@ -137,21 +133,13 @@ export default function BookingRejectPage() {
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-3">
{t('rejectedTitle')}
</h1>
<p className="text-lg text-gray-600 mb-2">
{t('rejectedHeadline')}
</p>
<p className="text-gray-500">
{t('rejectedBody')}
</p>
<h1 className="text-3xl font-bold text-gray-900 mb-3">{t('rejectedTitle')}</h1>
<p className="text-lg text-gray-600 mb-2">{t('rejectedHeadline')}</p>
<p className="text-gray-500">{t('rejectedBody')}</p>
</div>
<div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
{t('summaryTitle')}
</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('summaryTitle')}</h2>
<div className="space-y-3">
<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">
{booking.primaryCurrency === 'USD'
? `$${booking.priceUSD.toLocaleString()}`
: `${booking.priceEUR.toLocaleString()}`
}
: `${booking.priceEUR.toLocaleString()}`}
</span>
</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">
<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">
<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>
{t('infoTitle')}
</h3>
<p className="text-sm text-blue-800">
{t('infoBody')}
</p>
<p className="text-sm text-blue-800">{t('infoBody')}</p>
</div>
<div className="text-center text-sm text-gray-500">
@ -259,12 +249,8 @@ export default function BookingRejectPage() {
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
{t('formTitle')}
</h1>
<p className="text-gray-600">
{t('formIntro')}
</p>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('formTitle')}</h1>
<p className="text-gray-600">{t('formIntro')}</p>
</div>
<div className="mb-6">
@ -275,8 +261,18 @@ export default function BookingRejectPage() {
>
<div className="flex items-center justify-between">
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
<svg
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>
</div>
</button>
@ -289,18 +285,14 @@ export default function BookingRejectPage() {
id="reason"
rows={4}
value={reason}
onChange={(e) => setReason(e.target.value)}
onChange={e => setReason(e.target.value)}
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"
maxLength={500}
/>
<div className="mt-1 flex items-center justify-between">
<p className="text-xs text-gray-500">
{t('reasonHint')}
</p>
<span className="text-xs text-gray-400">
{reason.length}/500
</span>
<p className="text-xs text-gray-500">{t('reasonHint')}</p>
<span className="text-xs text-gray-400">{reason.length}/500</span>
</div>
</div>
)}
@ -320,16 +312,36 @@ export default function BookingRejectPage() {
>
{isRejecting ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" 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
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
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>
{t('submitting')}
</>
) : (
<>
<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>
{t('submit')}
</>
@ -344,9 +356,7 @@ export default function BookingRejectPage() {
</a>
</div>
<p className="mt-6 text-xs text-center text-gray-500">
{t('helpText')}
</p>
<p className="mt-6 text-xs text-center text-gray-500">{t('helpText')}</p>
</div>
</div>
);

View File

@ -64,15 +64,76 @@ type 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: 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 },
{
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: 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 JOB_REQ_KEYS = ['req1', 'req2', 'req3', 'req4'] as const;
@ -92,7 +153,7 @@ export default function CareersPage() {
const isJobsInView = useInView(jobsRef, { 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 locationMatch = selectedLocation === 'all' || job.location === selectedLocation;
return departmentMatch && locationMatch;
@ -124,7 +185,10 @@ export default function CareersPage() {
<LandingHeader activePage="careers" />
{/* 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 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" />
@ -221,9 +285,7 @@ export default function CareersPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('benefitsTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('benefitsSubtitle')}
</p>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('benefitsSubtitle')}</p>
</motion.div>
<motion.div
@ -232,7 +294,7 @@ export default function CareersPage() {
animate={isBenefitsInView ? 'visible' : 'hidden'}
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;
return (
<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">
<IconComponent className="w-7 h-7 text-brand-turquoise" />
</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>
</motion.div>
);
@ -254,7 +318,10 @@ export default function CareersPage() {
</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="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<motion.div
@ -265,9 +332,7 @@ export default function CareersPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
{t('cultureTitle')}
</h2>
<p className="text-xl text-white/80 mb-8">
{t('cultureBody')}
</p>
<p className="text-xl text-white/80 mb-8">{t('cultureBody')}</p>
<ul className="space-y-4">
{CULTURE_ITEMS.map((itemKey, index) => (
<motion.li
@ -292,7 +357,7 @@ export default function CareersPage() {
transition={{ duration: 0.8, delay: 0.2 }}
className="grid grid-cols-2 gap-4"
>
{[1, 2, 3, 4].map((i) => (
{[1, 2, 3, 4].map(i => (
<div
key={i}
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">
{t('jobsTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('jobsSubtitle')}
</p>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('jobsSubtitle')}</p>
</motion.div>
{/* Filters */}
@ -332,12 +395,14 @@ export default function CareersPage() {
<div className="relative">
<select
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"
>
{DEPARTMENT_VALUES.map((value) => (
{DEPARTMENT_VALUES.map(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>
))}
</select>
@ -346,10 +411,10 @@ export default function CareersPage() {
<div className="relative">
<select
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"
>
{LOCATION_VALUES.map((value) => (
{LOCATION_VALUES.map(value => (
<option key={value} value={value}>
{value === 'all' ? t('filters.allLocations') : t(`locations.${value}` as any)}
</option>
@ -373,7 +438,7 @@ export default function CareersPage() {
<p className="text-gray-500">{t('noJobs.body')}</p>
</div>
) : (
filteredJobs.map((job) => {
filteredJobs.map(job => {
const IconComponent = job.icon;
const isExpanded = expandedJob === job.id;
@ -393,7 +458,9 @@ export default function CareersPage() {
<IconComponent className="w-6 h-6 text-brand-turquoise" />
</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">
<span className="flex items-center space-x-1">
<Building2 className="w-4 h-4" />
@ -441,10 +508,15 @@ export default function CareersPage() {
>
<div className="p-6 bg-gray-50">
<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">
{JOB_REQ_KEYS.map((reqKey) => (
<li key={reqKey} className="flex items-start space-x-2 text-gray-600">
{JOB_REQ_KEYS.map(reqKey => (
<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" />
<span>{t(`jobs.${job.key}.${reqKey}` as any)}</span>
</li>
@ -483,12 +555,8 @@ export default function CareersPage() {
viewport={{ once: true }}
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl font-bold text-brand-navy mb-6">
{t('cta.title')}
</h2>
<p className="text-xl text-gray-600 mb-10">
{t('cta.body')}
</p>
<h2 className="text-4xl font-bold text-brand-navy mb-6">{t('cta.title')}</h2>
<p className="text-xl text-gray-600 mb-10">{t('cta.body')}</p>
<Link
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"

View File

@ -55,7 +55,10 @@ export default function CarrierAcceptPage() {
errorMessage = t('common.bookingAlreadyAccepted');
} else if (errorMessage.includes('status REJECTED')) {
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');
}
@ -65,7 +68,7 @@ export default function CarrierAcceptPage() {
setLoading(false);
const timer = setInterval(() => {
setCountdown((prev) => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(timer);
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="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" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">
{t('accept.loadingTitle')}
</h1>
<p className="text-gray-600">
{t('accept.loadingMessage')}
</p>
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('accept.loadingTitle')}</h1>
<p className="text-gray-600">{t('accept.loadingMessage')}</p>
</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="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" />
<h1 className="text-3xl font-bold text-gray-900 mb-4">
{t('accept.thanksTitle')}
</h1>
<h1 className="text-3xl font-bold text-gray-900 mb-4">{t('accept.thanksTitle')}</h1>
<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">
{t('accept.successHeadline')}
</p>
<p className="text-green-700 text-sm">
{t('accept.successBody')}
</p>
<p className="text-green-800 font-medium text-lg mb-2">{t('accept.successHeadline')}</p>
<p className="text-green-700 text-sm">{t('accept.successBody')}</p>
</div>
<p className="text-gray-500 text-sm mb-4">
{t('common.redirecting', { countdown })}
</p>
<p className="text-gray-500 text-sm mb-4">{t('common.redirecting', { countdown })}</p>
<button
onClick={() => router.push('/')}

View File

@ -189,7 +189,10 @@ export default function CarrierDocumentsPage() {
throw new Error(t('notAcceptedYet'));
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
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' });
setLoading(false);
return;
@ -336,12 +339,11 @@ export default function CarrierDocumentsPage() {
<Lock className="w-8 h-8 text-brand-turquoise" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('password.title')}</h1>
<p className="text-gray-600">
{t('password.intro')}
</p>
<p className="text-gray-600">{t('password.intro')}</p>
{requirements.bookingNumber && (
<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>
)}
</div>
@ -467,7 +469,9 @@ export default function CarrierDocumentsPage() {
<div className="text-center p-3 bg-gray-50 rounded-lg">
<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="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 className="text-center p-3 bg-gray-50 rounded-lg">
<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">
<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-500 text-sm mt-1">
{t('list.emptyHint')}
</p>
<p className="text-gray-500 text-sm mt-1">{t('list.emptyHint')}</p>
</div>
) : (
<div className="divide-y divide-gray-100">
@ -552,9 +554,7 @@ export default function CarrierDocumentsPage() {
</div>
{/* Info */}
<p className="mt-6 text-center text-sm text-gray-500">
{t('footerNote')}
</p>
<p className="mt-6 text-center text-sm text-gray-500">{t('footerNote')}</p>
</main>
{/* Footer */}

View File

@ -55,7 +55,10 @@ export default function CarrierRejectPage() {
errorMessage = t('common.bookingAlreadyRejected');
} else if (errorMessage.includes('status ACCEPTED')) {
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');
}
@ -65,7 +68,7 @@ export default function CarrierRejectPage() {
setLoading(false);
const timer = setInterval(() => {
setCountdown((prev) => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(timer);
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="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" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">
{t('reject.loadingTitle')}
</h1>
<p className="text-gray-600">
{t('reject.loadingMessage')}
</p>
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('reject.loadingTitle')}</h1>
<p className="text-gray-600">{t('reject.loadingMessage')}</p>
</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="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" />
<h1 className="text-3xl font-bold text-gray-900 mb-4">
{t('reject.thanksTitle')}
</h1>
<h1 className="text-3xl font-bold text-gray-900 mb-4">{t('reject.thanksTitle')}</h1>
<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">
{t('reject.successHeadline')}
</p>
<p className="text-orange-700 text-sm">
{t('reject.successBody')}
</p>
<p className="text-orange-800 font-medium text-lg mb-2">{t('reject.successHeadline')}</p>
<p className="text-orange-700 text-sm">{t('reject.successBody')}</p>
</div>
<p className="text-gray-500 text-sm mb-4">
{t('common.redirecting', { countdown })}
</p>
<p className="text-gray-500 text-sm mb-4">{t('common.redirecting', { countdown })}</p>
<button
onClick={() => router.push('/')}

View File

@ -77,7 +77,10 @@ export default function CompliancePage() {
<LandingHeader />
{/* 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 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" />
@ -148,9 +151,7 @@ export default function CompliancePage() {
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('rightsTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('rightsSubtitle')}
</p>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('rightsSubtitle')}</p>
</motion.div>
<motion.div
@ -159,7 +160,7 @@ export default function CompliancePage() {
animate={isContentInView ? 'visible' : 'hidden'}
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;
return (
<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">
<IconComponent className="w-8 h-8 text-brand-turquoise" />
</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>
</motion.div>
);
@ -185,9 +188,7 @@ export default function CompliancePage() {
transition={{ duration: 0.8, delay: 0.4 }}
className="mt-12 text-center"
>
<p className="text-gray-600 mb-4">
{t('rightsCta.text')}
</p>
<p className="text-gray-600 mb-4">{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">
<Link
href="/login"
@ -219,9 +220,7 @@ export default function CompliancePage() {
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('principlesTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('principlesSubtitle')}
</p>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('principlesSubtitle')}</p>
</motion.div>
<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">
<IconComponent className="w-6 h-6 text-brand-green" />
</div>
<h3 className="text-lg font-bold text-brand-navy mb-2">{t(`principles.${principle.key}.title`)}</h3>
<p className="text-gray-600 text-sm">{t(`principles.${principle.key}.description`)}</p>
<h3 className="text-lg font-bold text-brand-navy mb-2">
{t(`principles.${principle.key}.title`)}
</h3>
<p className="text-gray-600 text-sm">
{t(`principles.${principle.key}.description`)}
</p>
</motion.div>
);
})}
@ -261,9 +264,7 @@ export default function CompliancePage() {
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('measuresTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('measuresSubtitle')}
</p>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('measuresSubtitle')}</p>
</motion.div>
<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>
<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">
<CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" />
<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" />
</div>
<div>
<h3 className="text-2xl font-bold text-brand-navy mb-4">
{t('register.title')}
</h3>
<p className="text-gray-600 mb-6">
{t('register.body')}
</p>
<h3 className="text-2xl font-bold text-brand-navy mb-4">{t('register.title')}</h3>
<p className="text-gray-600 mb-6">{t('register.body')}</p>
<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">
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
<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"
>
<UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
<h3 className="text-2xl font-bold text-white mb-4">
{t('dpo.title')}
</h3>
<p className="text-white/80 mb-6 max-w-2xl mx-auto">
{t('dpo.body')}
</p>
<h3 className="text-2xl font-bold text-white mb-4">{t('dpo.title')}</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">
<a
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' },
];
const SUBJECTS: SubjectKey[] = ['demo', 'pricing', 'partnership', 'support', 'press', 'careers', 'other'];
const SUBJECTS: SubjectKey[] = [
'demo',
'pricing',
'partnership',
'support',
'press',
'careers',
'other',
];
export default function ContactPage() {
const t = useTranslations('marketing.contact');
@ -89,7 +97,7 @@ export default function ContactPage() {
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
setFormData((prev) => ({
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value,
}));
@ -121,7 +129,10 @@ export default function ContactPage() {
<LandingHeader activePage="contact" />
{/* 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 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" />
@ -178,7 +189,7 @@ export default function ContactPage() {
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">
{METHODS.map((method) => {
{METHODS.map(method => {
const IconComponent = method.icon;
return (
<motion.div
@ -191,9 +202,15 @@ export default function ContactPage() {
>
<IconComponent className="w-6 h-6 text-white" />
</div>
<h3 className="text-lg font-bold text-brand-navy mb-1">{t(`methods.${method.key}.title`)}</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>
<h3 className="text-lg font-bold text-brand-navy mb-1">
{t(`methods.${method.key}.title`)}
</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>
);
})}
@ -212,9 +229,7 @@ export default function ContactPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('form.title')}</h2>
<p className="text-gray-600 mb-8">
{t('form.description')}
</p>
<p className="text-gray-600 mb-8">{t('form.description')}</p>
{isSubmitted ? (
<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">
<CheckCircle2 className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-2xl font-bold text-green-800 mb-2">{t('form.successTitle')}</h3>
<p className="text-green-700 mb-6">
{t('form.successBody')}
</p>
<h3 className="text-2xl font-bold text-green-800 mb-2">
{t('form.successTitle')}
</h3>
<p className="text-green-700 mb-6">{t('form.successBody')}</p>
<button
onClick={() => {
setIsSubmitted(false);
@ -251,7 +266,10 @@ export default function ContactPage() {
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<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')} *
</label>
<input
@ -266,7 +284,10 @@ export default function ContactPage() {
/>
</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')} *
</label>
<input
@ -284,7 +305,10 @@ export default function ContactPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<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')} *
</label>
<input
@ -299,7 +323,10 @@ export default function ContactPage() {
/>
</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')}
</label>
<input
@ -315,7 +342,10 @@ export default function ContactPage() {
</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')}
</label>
<input
@ -330,7 +360,10 @@ export default function ContactPage() {
</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')} *
</label>
<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"
>
<option value="">{t('subjects.placeholder')}</option>
{SUBJECTS.map((key) => (
{SUBJECTS.map(key => (
<option key={key} value={key}>
{t(`subjects.${key}`)}
</option>
@ -351,7 +384,10 @@ export default function ContactPage() {
</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')} *
</label>
<textarea
@ -400,9 +436,7 @@ export default function ContactPage() {
transition={{ duration: 0.8, delay: 0.2 }}
>
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('office.title')}</h2>
<p className="text-gray-600 mb-8">
{t('office.subtitle')}
</p>
<p className="text-gray-600 mb-8">{t('office.subtitle')}</p>
<div className="space-y-6">
<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 className="flex items-center space-x-2">
<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')}
</a>
</div>
<div className="flex items-center space-x-2">
<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')}
</a>
</div>
@ -463,9 +503,7 @@ export default function ContactPage() {
<span className="font-medium text-gray-400">{t('hours.closed')}</span>
</div>
</div>
<p className="mt-4 text-sm text-gray-500">
{t('hours.supportNote')}
</p>
<p className="mt-4 text-sm text-gray-500">{t('hours.supportNote')}</p>
</div>
</motion.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">
<CheckCircle2 className="w-5 h-5 text-brand-turquoise" />
</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>
<p className="text-white/80 leading-relaxed">
{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">
<Shield className="w-5 h-5 text-brand-green" />
</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>
<p className="text-white/80 leading-relaxed">
{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')}
</Link>
{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">
<Zap className="w-7 h-7 text-white" />
</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">
{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')}
</p>
<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">
<BookOpen className="w-7 h-7 text-brand-turquoise" />
</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">
{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')}
</p>
<Link

View File

@ -3,7 +3,16 @@
import { useRef } from 'react';
import { motion, useInView } from 'framer-motion';
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';
type CookieTypeKey = 'essential' | 'analytics' | 'marketing' | 'functional';
@ -99,7 +108,10 @@ export default function CookiesPage() {
<LandingHeader />
{/* 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 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" />
@ -184,7 +196,7 @@ export default function CookiesPage() {
animate={isContentInView ? 'visible' : 'hidden'}
className="space-y-8"
>
{COOKIE_TYPES.map((type) => {
{COOKIE_TYPES.map(type => {
const IconComponent = type.icon;
return (
<motion.div
@ -234,9 +246,11 @@ export default function CookiesPage() {
</tr>
</thead>
<tbody>
{type.cookies.map((cookie) => (
{type.cookies.map(cookie => (
<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">
{t(`purposes.${cookie.purposeKey}` as any)}
</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 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)) {
return t(`status.${key}` as any);
}
@ -143,9 +150,7 @@ export default function AdminBookingsPage() {
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">{t('title')}</h1>
<p className="mt-1 text-sm text-gray-500">
{t('subtitle')}
</p>
<p className="mt-1 text-sm text-gray-500">{t('subtitle')}</p>
</div>
{/* 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>
<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">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
</div>
</div>
<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">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
</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="grid grid-cols-1 md:grid-cols-2 gap-4">
<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
type="text"
placeholder={t('search.placeholder')}
@ -194,7 +205,9 @@ export default function AdminBookingsPage() {
/>
</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
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
@ -261,7 +274,9 @@ export default function AdminBookingsPage() {
{/* N° Booking */}
<td className="px-4 py-4 whitespace-nowrap">
{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>
</td>
@ -278,11 +293,15 @@ export default function AdminBookingsPage() {
<div className="text-sm text-gray-900">
{booking.containerType}
{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 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>}
</div>
</td>
@ -294,20 +313,24 @@ export default function AdminBookingsPage() {
{/* Statut */}
<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)}
</span>
</td>
{/* Date */}
<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>
{/* Actions */}
<td className="px-4 py-4 whitespace-nowrap text-right text-sm">
<button
onClick={(e) => {
onClick={e => {
if (openMenuId === booking.id) {
setOpenMenuId(null);
setMenuPosition(null);
@ -319,7 +342,11 @@ export default function AdminBookingsPage() {
}}
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" />
</svg>
</button>
@ -336,7 +363,10 @@ export default function AdminBookingsPage() {
<>
<div
className="fixed inset-0 z-[998]"
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
onClick={() => {
setOpenMenuId(null);
setMenuPosition(null);
}}
/>
<div
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"
>
<svg 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="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
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="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>
<span className="text-sm font-medium text-gray-700">{t('menu.viewDetails')}</span>
</button>
@ -374,10 +419,22 @@ export default function AdminBookingsPage() {
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"
>
<svg 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
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>
<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>
) : null;
})()}
@ -391,8 +448,18 @@ export default function AdminBookingsPage() {
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"
>
<svg 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
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>
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
</button>
@ -408,11 +475,19 @@ export default function AdminBookingsPage() {
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">{t('modal.title')}</h2>
<button
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
onClick={() => {
setShowDetailsModal(false);
setSelectedBooking(null);
}}
className="text-gray-400 hover:text-gray-600"
>
<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>
</button>
</div>
@ -420,60 +495,98 @@ export default function AdminBookingsPage() {
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<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">
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">{t('modal.status')}</label>
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
<label className="block text-sm font-medium text-gray-500">
{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)}
</span>
</div>
</div>
<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>
<label className="block text-sm font-medium text-gray-500">{t('modal.origin')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || t('modal.none')}</div>
<label className="block text-sm font-medium text-gray-500">
{t('modal.origin')}
</label>
<div className="mt-1 font-semibold text-gray-900">
{selectedBooking.origin || t('modal.none')}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">{t('modal.destination')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || t('modal.none')}</div>
<label className="block text-sm font-medium text-gray-500">
{t('modal.destination')}
</label>
<div className="mt-1 font-semibold text-gray-900">
{selectedBooking.destination || t('modal.none')}
</div>
</div>
</div>
</div>
<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>
<label className="block text-sm font-medium text-gray-500">{t('modal.carrier')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || t('modal.none')}</div>
<label className="block text-sm font-medium text-gray-500">
{t('modal.carrier')}
</label>
<div className="mt-1 font-semibold text-gray-900">
{selectedBooking.carrierName || t('modal.none')}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">{t('modal.containerType')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.containerType}</div>
<label className="block text-sm font-medium text-gray-500">
{t('modal.containerType')}
</label>
<div className="mt-1 font-semibold text-gray-900">
{selectedBooking.containerType}
</div>
</div>
{selectedBooking.palletCount != null && (
<div>
<label className="block text-sm font-medium text-gray-500">{t('modal.pallets')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.palletCount}</div>
<label className="block text-sm font-medium text-gray-500">
{t('modal.pallets')}
</label>
<div className="mt-1 font-semibold text-gray-900">
{selectedBooking.palletCount}
</div>
</div>
)}
{selectedBooking.weightKG != null && (
<div>
<label className="block text-sm font-medium text-gray-500">{t('modal.weight')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString(dateLocale)} kg</div>
<label className="block text-sm font-medium text-gray-500">
{t('modal.weight')}
</label>
<div className="mt-1 font-semibold text-gray-900">
{selectedBooking.weightKG.toLocaleString(dateLocale)} kg
</div>
</div>
)}
{selectedBooking.volumeCBM != null && (
<div>
<label className="block text-sm font-medium text-gray-500">{t('modal.volume')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.volumeCBM} CBM</div>
<label className="block text-sm font-medium text-gray-500">
{t('modal.volume')}
</label>
<div className="mt-1 font-semibold text-gray-900">
{selectedBooking.volumeCBM} CBM
</div>
</div>
)}
</div>
@ -481,18 +594,24 @@ export default function AdminBookingsPage() {
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
<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">
{selectedBooking.priceEUR != null && (
<div>
<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>
)}
{selectedBooking.priceUSD != null && (
<div>
<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>
@ -500,18 +619,24 @@ export default function AdminBookingsPage() {
)}
<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>
<label className="block text-gray-500">{t('modal.createdAt')}</label>
<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>
{selectedBooking.updatedAt && (
<div>
<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>
@ -528,7 +653,9 @@ export default function AdminBookingsPage() {
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"
>
{validatingId === selectedBooking.id ? t('modal.validating') : t('modal.validateButton')}
{validatingId === selectedBooking.id
? t('modal.validating')
: t('modal.validateButton')}
</button>
</div>
)}
@ -536,7 +663,10 @@ export default function AdminBookingsPage() {
<div className="flex justify-end mt-6 pt-4 border-t">
<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"
>
{t('modal.close')}

View File

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

View File

@ -171,12 +171,16 @@ export default function AdminDocumentsPage() {
const uniqueUsers = Array.from(
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()
);
const filteredDocuments = documents.filter(doc => {
const matchesSearch = searchTerm === '' ||
const matchesSearch =
searchTerm === '' ||
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
(doc.name && doc.name.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 matchesQuote = filterQuoteNumber === '' ||
const matchesQuote =
filterQuoteNumber === '' ||
doc.quoteNumber.toLowerCase().includes(filterQuoteNumber.toLowerCase());
return matchesSearch && matchesUser && matchesQuote;
@ -201,7 +206,7 @@ export default function AdminDocumentsPage() {
const getDocumentIcon = (type: string): ReactNode => {
const typeLower = type.toLowerCase();
const cls = "h-6 w-6";
const cls = 'h-6 w-6';
const iconMap: Record<string, ReactNode> = {
'application/pdf': <FileText className={`${cls} text-red-500`} />,
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
@ -304,10 +309,7 @@ export default function AdminDocumentsPage() {
return (
<div className="space-y-6">
<PageHeader
title={t('title')}
description={t('subtitle')}
/>
<PageHeader title={t('title')} description={t('subtitle')} />
{/* Stats */}
<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>
</td>
<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}
</span>
</td>
@ -448,7 +452,7 @@ export default function AdminDocumentsPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={(e) => {
onClick={e => {
const menuKey = `${doc.bookingId}::${doc.id}`;
if (openMenuId === menuKey) {
setOpenMenuId(null);
@ -461,7 +465,11 @@ export default function AdminDocumentsPage() {
}}
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" />
</svg>
</button>
@ -494,9 +502,14 @@ export default function AdminDocumentsPage() {
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
{t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span> {t('pagination.to')}{' '}
<span className="font-medium">{Math.min(endIndex, filteredDocuments.length)}</span> {t('pagination.on')}{' '}
<span className="font-medium">{filteredDocuments.length}</span> {t('pagination.results')}
{t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span>{' '}
{t('pagination.to')}{' '}
<span className="font-medium">
{Math.min(endIndex, filteredDocuments.length)}
</span>{' '}
{t('pagination.on')}{' '}
<span className="font-medium">{filteredDocuments.length}</span>{' '}
{t('pagination.results')}
</p>
</div>
<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>
<select
value={itemsPerPage}
onChange={(e) => {
onChange={e => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
@ -517,7 +530,10 @@ export default function AdminDocumentsPage() {
<option value={100}>100</option>
</select>
</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
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
@ -525,7 +541,11 @@ export default function AdminDocumentsPage() {
>
<span className="sr-only">{t('pagination.previous')}</span>
<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>
</button>
@ -564,7 +584,11 @@ export default function AdminDocumentsPage() {
>
<span className="sr-only">{t('pagination.next')}</span>
<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>
</button>
</nav>
@ -578,7 +602,10 @@ export default function AdminDocumentsPage() {
<>
<div
className="fixed inset-0 z-[998]"
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
onClick={() => {
setOpenMenuId(null);
setMenuPosition(null);
}}
/>
<div
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={() => {
setOpenMenuId(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"
>
<svg 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
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>
<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
onClick={() => {
@ -615,8 +657,18 @@ export default function AdminDocumentsPage() {
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"
>
<svg 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
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>
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
</button>

View File

@ -72,7 +72,9 @@ const LEVEL_ROW_BG: Record<string, string> = {
function LevelBadge({ level }: { level: string }) {
const style = LEVEL_STYLES[level] || 'bg-gray-100 text-gray-600';
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}
</span>
);
@ -148,16 +150,14 @@ export default function AdminLogsPage() {
if (fmt) params.set('format', fmt);
return params.toString();
},
[filters],
[filters]
);
const fetchLogs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await get<LogsResponse>(
`${LOGS_PREFIX}/export?${buildQueryString('json')}`,
);
const data = await get<LogsResponse>(`${LOGS_PREFIX}/export?${buildQueryString('json')}`);
setLogs(data.logs || []);
setTotal(data.total || 0);
} catch (err: any) {
@ -175,10 +175,7 @@ export default function AdminLogsPage() {
setExportLoading(true);
try {
const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`;
await download(
`${LOGS_PREFIX}/export?${buildQueryString(format)}`,
filename,
);
await download(`${LOGS_PREFIX}/export?${buildQueryString(format)}`, filename);
} catch (err: any) {
setError(err.message);
} finally {
@ -187,8 +184,7 @@ export default function AdminLogsPage() {
};
// Stats
const countByLevel = (level: string) =>
logs.filter(l => l.level === level).length;
const countByLevel = (level: string) => logs.filter(l => l.level === level).length;
const setFilter = (key: keyof Filters, value: string) =>
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"
>
<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>
<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
@ -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">
{/* Service */}
<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
value={filters.service}
onChange={e => setFilter('service', e.target.value)}
@ -289,7 +289,9 @@ export default function AdminLogsPage() {
{/* Level */}
<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
value={filters.level}
onChange={e => setFilter('level', e.target.value)}
@ -306,7 +308,9 @@ export default function AdminLogsPage() {
{/* Search */}
<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
type="text"
placeholder={t('filters.searchPlaceholder')}
@ -319,7 +323,9 @@ export default function AdminLogsPage() {
{/* Start */}
<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
type="datetime-local"
value={filters.startDate}
@ -330,7 +336,9 @@ export default function AdminLogsPage() {
{/* End */}
<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
type="datetime-local"
value={filters.endDate}
@ -372,9 +380,7 @@ export default function AdminLogsPage() {
<span className="text-sm">
{t('errorBanner')} <strong>{error}</strong>
<br />
<span className="text-xs text-red-500">
{t('errorHint')}
</span>
<span className="text-xs text-red-500">{t('errorHint')}</span>
</span>
</div>
)}
@ -389,9 +395,7 @@ export default function AdminLogsPage() {
</span>
</div>
{!loading && logs.length > 0 && (
<span className="text-xs text-gray-400">
{t('clickHint')}
</span>
<span className="text-xs text-gray-400">{t('clickHint')}</span>
)}
</div>
@ -469,8 +473,7 @@ export default function AdminLogsPage() {
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
{log.req_method && (
<span>
<span className="font-semibold">{log.req_method}</span>{' '}
{log.req_url}{' '}
<span className="font-semibold">{log.req_method}</span> {log.req_url}{' '}
{log.res_status && (
<span
className={
@ -495,29 +498,37 @@ export default function AdminLogsPage() {
<td colSpan={6} className="px-4 py-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<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>
</div>
{log.reqId && (
<div>
<span className="font-semibold text-gray-600">{t('detail.requestId')}</span>
<p className="font-mono text-gray-800 mt-0.5 truncate">{log.reqId}</p>
<span className="font-semibold text-gray-600">
{t('detail.requestId')}
</span>
<p className="font-mono text-gray-800 mt-0.5 truncate">
{log.reqId}
</p>
</div>
)}
{log.response_time_ms && (
<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">
{log.response_time_ms} ms
</p>
</div>
)}
<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">
{log.error
? `[ERROR] ${log.error}\n\n${log.message}`
: log.message}
{log.error ? `[ERROR] ${log.error}\n\n${log.message}` : log.message}
</pre>
</div>
</div>

View File

@ -159,7 +159,12 @@ export default function AdminOrganizationsPage() {
setVerifyingId(orgId);
const result = await verifySiret(orgId);
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();
} else {
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-1">
<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 ${
org.type === 'FREIGHT_FORWARDER' ? 'bg-blue-100 text-blue-800' :
org.type === 'CARRIER' ? 'bg-green-100 text-green-800' :
'bg-purple-100 text-purple-800'
}`}>
<span
className={`inline-block mt-2 px-2 py-1 text-xs font-semibold rounded-full ${
org.type === 'FREIGHT_FORWARDER'
? 'bg-blue-100 text-blue-800'
: org.type === 'CARRIER'
? 'bg-green-100 text-green-800'
: 'bg-purple-100 text-purple-800'
}`}
>
{getTypeLabel(org.type)}
</span>
</div>
<span 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'
}`}>
<span
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')}
</span>
</div>
@ -315,7 +326,8 @@ export default function AdminOrganizationsPage() {
</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>
@ -373,7 +385,9 @@ export default function AdminOrganizationsPage() {
<form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<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
type="text"
required
@ -384,7 +398,9 @@ export default function AdminOrganizationsPage() {
</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
value={formData.type}
onChange={e => setFormData({ ...formData, type: e.target.value })}
@ -398,20 +414,26 @@ export default function AdminOrganizationsPage() {
{formData.type === 'CARRIER' && (
<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
type="text"
required={formData.type === 'CARRIER'}
maxLength={4}
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"
/>
</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
type="text"
maxLength={9}
@ -422,19 +444,25 @@ export default function AdminOrganizationsPage() {
</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
type="text"
maxLength={14}
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"
placeholder={t('modal.siretPlaceholder')}
/>
</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
type="text"
value={formData.eori}
@ -444,7 +472,9 @@ export default function AdminOrganizationsPage() {
</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
type="tel"
value={formData.contact_phone}
@ -454,7 +484,9 @@ export default function AdminOrganizationsPage() {
</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
type="email"
value={formData.contact_email}
@ -464,77 +496,99 @@ export default function AdminOrganizationsPage() {
</div>
<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
type="text"
required
value={formData.address.street}
onChange={e => setFormData({
...formData,
address: { ...formData.address, street: e.target.value }
})}
onChange={e =>
setFormData({
...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"
/>
</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
type="text"
required
value={formData.address.city}
onChange={e => setFormData({
...formData,
address: { ...formData.address, city: e.target.value }
})}
onChange={e =>
setFormData({
...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"
/>
</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
type="text"
required
value={formData.address.postalCode}
onChange={e => setFormData({
...formData,
address: { ...formData.address, postalCode: e.target.value }
})}
onChange={e =>
setFormData({
...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"
/>
</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
type="text"
value={formData.address.state}
onChange={e => setFormData({
...formData,
address: { ...formData.address, state: e.target.value }
})}
onChange={e =>
setFormData({
...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"
/>
</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
type="text"
required
maxLength={2}
value={formData.address.country}
onChange={e => setFormData({
...formData,
address: { ...formData.address, country: e.target.value.toUpperCase() }
})}
onChange={e =>
setFormData({
...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"
/>
</div>
<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
type="url"
value={formData.logoUrl}

View File

@ -228,11 +228,15 @@ export default function AdminUsersPage() {
<div className="text-sm text-gray-500">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
user.role === 'ADMIN' ? 'bg-purple-100 text-purple-800' :
user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
user.role === 'ADMIN'
? 'bg-purple-100 text-purple-800'
: user.role === 'MANAGER'
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{getRoleLabel(user.role)}
</span>
</td>
@ -240,9 +244,11 @@ export default function AdminUsersPage() {
{user.organizationName || user.organizationId}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span 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'
}`}>
<span
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')}
</span>
</td>
@ -273,7 +279,9 @@ export default function AdminUsersPage() {
<h2 className="text-xl font-bold mb-4">{t('modal.createTitle')}</h2>
<form onSubmit={handleCreate} className="space-y-4">
<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
type="email"
required
@ -283,7 +291,9 @@ export default function AdminUsersPage() {
/>
</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
type="text"
required
@ -293,7 +303,9 @@ export default function AdminUsersPage() {
/>
</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
type="text"
required
@ -316,7 +328,9 @@ export default function AdminUsersPage() {
</select>
</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
required
value={formData.organizationId}
@ -372,7 +386,9 @@ export default function AdminUsersPage() {
<h2 className="text-xl font-bold mb-4">{t('modal.editTitle')}</h2>
<form onSubmit={handleUpdate} className="space-y-4">
<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
type="email"
disabled
@ -381,7 +397,9 @@ export default function AdminUsersPage() {
/>
</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
type="text"
required
@ -391,7 +409,9 @@ export default function AdminUsersPage() {
/>
</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
type="text"
required
@ -443,7 +463,10 @@ export default function AdminUsersPage() {
<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>
<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>
<div className="flex justify-end space-x-2">
<button

View File

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

View File

@ -32,9 +32,7 @@ export default function PaymentSuccessPage() {
setStatus('success');
} catch (err) {
console.error('Payment confirmation error:', err);
setError(
err instanceof Error ? err.message : 'Erreur lors de la confirmation du paiement'
);
setError(err instanceof Error ? err.message : 'Erreur lors de la confirmation du paiement');
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" />
<h2 className="text-xl font-bold text-gray-900 mb-2">Confirmation du paiement...</h2>
<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>
</>
)}
@ -82,15 +81,14 @@ export default function PaymentSuccessPage() {
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">Paiement confirme !</h2>
<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>
<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">
<Mail className="h-5 w-5" />
<span className="text-sm font-medium">
Email envoye au transporteur
</span>
<span className="text-sm font-medium">Email envoye au transporteur</span>
</div>
<p className="text-xs text-blue-600 mt-1">
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>
<p className="text-gray-600 mb-2">{error}</p>
<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>
<div className="space-y-3">
<button

View File

@ -41,7 +41,7 @@ const DOCUMENT_TYPES = [
{ value: 'BILL_OF_LADING', label: 'Bill of Lading (Connaissement)' },
{ value: 'PACKING_LIST', label: 'Packing List (Liste de colisage)' },
{ 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' },
];
@ -187,7 +187,7 @@ function NewBookingPageContent() {
}
// Append documents
formData.documents.forEach((file) => {
formData.documents.forEach(file => {
formDataToSend.append('documents', file);
});
@ -228,7 +228,9 @@ function NewBookingPageContent() {
</button>
<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">
Envoyez une demande de réservation directement au transporteur
</p>
@ -243,9 +245,7 @@ function NewBookingPageContent() {
<div className="flex items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
currentStep >= step
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-600'
currentStep >= step ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'
}`}
>
{step}
@ -292,16 +292,12 @@ function NewBookingPageContent() {
{/* Step 1: Transport Details (Read-only) */}
{currentStep === 1 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Détails du transport
</h2>
<h2 className="text-2xl font-bold text-gray-900 mb-6">Détails du transport</h2>
<div className="space-y-6">
{/* Carrier Info */}
<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">
Transporteur
</h3>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Transporteur</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-600">Nom</p>
@ -316,9 +312,7 @@ function NewBookingPageContent() {
{/* Route Info */}
<div className="bg-gray-50 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Trajet
</h3>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Trajet</h3>
<div className="flex items-center justify-between">
<div className="text-center">
<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="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">
<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>
@ -350,7 +346,9 @@ function NewBookingPageContent() {
</div>
<div className="bg-gray-50 rounded-lg p-4">
<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 className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Type</p>
@ -360,9 +358,7 @@ function NewBookingPageContent() {
{/* Pricing */}
<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">
Prix estimé
</h3>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Prix estimé</h3>
<div className="flex items-center justify-between mb-4">
<div>
<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">
<p className="text-sm font-semibold text-gray-700">Prix total</p>
<p className="text-3xl font-bold text-green-600">
{formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')}
{formatPrice(
formData.totalPriceForSorting,
formData.primaryCurrency || 'EUR'
)}
</p>
</div>
</div>
@ -403,9 +402,7 @@ function NewBookingPageContent() {
{/* Step 2: Document Upload */}
{currentStep === 2 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Documents requis
</h2>
<h2 className="text-2xl font-bold text-gray-900 mb-6">Documents requis</h2>
<div className="mb-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg p-4">
<div className="flex items-start">
@ -428,7 +425,7 @@ function NewBookingPageContent() {
id="file-upload"
multiple
accept={ACCEPTED_FILE_TYPES.join(',')}
onChange={(e) => handleFileChange(e.target.files)}
onChange={e => handleFileChange(e.target.files)}
className="hidden"
/>
<label
@ -451,9 +448,7 @@ function NewBookingPageContent() {
<p className="text-lg font-semibold text-gray-700 mb-2">
Cliquez pour sélectionner des fichiers
</p>
<p className="text-sm text-gray-500">
ou glissez-déposez vos documents ici
</p>
<p className="text-sm text-gray-500">ou glissez-déposez vos documents ici</p>
</label>
</div>
@ -522,7 +517,7 @@ function NewBookingPageContent() {
</label>
<textarea
value={formData.notes || ''}
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
onChange={e => setFormData(prev => ({ ...prev, notes: e.target.value }))}
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"
placeholder="Ajoutez des instructions spéciales pour le transporteur..."
@ -550,16 +545,12 @@ function NewBookingPageContent() {
{/* Step 3: Review & Submit */}
{currentStep === 3 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Révision et envoi
</h2>
<h2 className="text-2xl font-bold text-gray-900 mb-6">Révision et envoi</h2>
<div className="space-y-6 mb-8">
{/* Summary */}
<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">
Récapitulatif
</h3>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Récapitulatif</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Transporteur :</span>
@ -602,7 +593,10 @@ function NewBookingPageContent() {
<div className="flex justify-between border-t pt-3 mt-3">
<span className="text-gray-900 font-semibold">Prix total :</span>
<span className="text-2xl font-bold text-green-600">
{formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')}
{formatPrice(
formData.totalPriceForSorting,
formData.primaryCurrency || 'EUR'
)}
</span>
</div>
</div>
@ -617,7 +611,8 @@ function NewBookingPageContent() {
<li className="flex items-start">
<span className="mr-2">1.</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>
</li>
<li className="flex items-start">
@ -629,13 +624,15 @@ function NewBookingPageContent() {
<li className="flex items-start">
<span className="mr-2">3.</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>
</li>
<li className="flex items-start">
<span className="mr-2">4.</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>
</li>
</ul>
@ -647,7 +644,7 @@ function NewBookingPageContent() {
<input
type="checkbox"
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"
/>
<span className="ml-3 text-sm text-gray-700">
@ -693,7 +690,9 @@ function NewBookingPageContent() {
export default function NewBookingPage() {
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 />
</Suspense>
);

View File

@ -124,7 +124,9 @@ export default function BookingDetailPage() {
</div>
{booking.specialInstructions && (
<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>
</div>
)}
@ -145,7 +147,9 @@ export default function BookingDetailPage() {
</div>
{container.containerNumber && (
<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>
</div>
)}
@ -243,7 +247,9 @@ export default function BookingDetailPage() {
</div>
<div className="min-w-0 flex-1 pt-1.5">
<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">
{new Date(booking.createdAt).toLocaleString(dateLocale)}
</p>

View File

@ -30,7 +30,11 @@ export default function BookingsListPage() {
}
}, [searchParams]);
const { data: csvData, isLoading, error: csvError } = useQuery({
const {
data: csvData,
isLoading,
error: csvError,
} = useQuery({
queryKey: ['csv-bookings'],
queryFn: () =>
listCsvBookings({
@ -64,10 +68,15 @@ export default function BookingsListPage() {
case 'status':
return booking.status?.toLowerCase().includes(term);
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);
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:
return true;
}
@ -77,7 +86,9 @@ export default function BookingsListPage() {
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 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" />
<div>
<p className="font-medium text-amber-800">{t('transferBanner.title')}</p>
<p className="text-sm text-amber-700 mt-0.5">
{t('transferBanner.message')}
</p>
<p className="text-sm text-amber-700 mt-0.5">{t('transferBanner.message')}</p>
</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>
)}
<PageHeader
@ -159,14 +173,18 @@ export default function BookingsListPage() {
filename={t('exportFilename')}
columns={[
{ key: 'id', label: t('export.id') },
{ key: 'palletCount', label: t('export.pallets'), 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: 'palletCount', label: t('export.pallets'), 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: 'origin', label: t('export.origin') },
{ key: 'destination', label: t('export.destination') },
{ key: 'carrierName', label: t('export.carrier') },
{ 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: 'status', label: t('export.status'), format: v => getStatusLabel(v) },
{
key: 'createdAt',
label: t('export.createdAt'),
format: v => (v ? new Date(v).toLocaleDateString(dateLocale) : ''),
},
]}
/>
<Link
@ -280,13 +298,17 @@ export default function BookingsListPage() {
{booking.type === 'csv' ? booking.carrierName : booking.carrier || ''}
</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)}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<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">
{booking.type === 'csv'
? t('units.palletsShort', { count: booking.palletCount })
@ -294,25 +316,36 @@ export default function BookingsListPage() {
</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">
{booking.type === 'csv'
? 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 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">
{(booking.createdAt || booking.requestedAt)
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(dateLocale, { day: '2-digit', month: '2-digit', year: '2-digit' })
{booking.createdAt || booking.requestedAt
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(
dateLocale,
{ day: '2-digit', month: '2-digit', year: '2-digit' }
)
: 'N/A'}
</div>
</div>
</div>
<div className="text-xs text-gray-400">
{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 || '-' })}
</div>
</div>
@ -353,7 +386,9 @@ export default function BookingsListPage() {
<div className="text-sm font-medium text-gray-900">
{booking.type === 'csv'
? t('units.palletsCount', { count: booking.palletCount })
: t('units.containersCount', { count: booking.containers?.length || 0 })}
: t('units.containersCount', {
count: booking.containers?.length || 0,
})}
</div>
<div className="text-xs text-gray-500">
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
@ -364,15 +399,15 @@ export default function BookingsListPage() {
{booking.type === 'csv'
? t('units.kg', { value: booking.weightKG })
: booking.totalWeight
? t('units.kg', { value: booking.totalWeight })
: 'N/A'}
? t('units.kg', { value: booking.totalWeight })
: 'N/A'}
</div>
<div className="text-xs text-gray-500">
{booking.type === 'csv'
? t('units.cbm', { value: booking.volumeCBM })
: booking.totalVolume
? t('units.cbm', { value: booking.totalVolume })
: ''}
? t('units.cbm', { value: booking.totalVolume })
: ''}
</div>
</td>
<td className="px-6 py-4">
@ -397,21 +432,24 @@ export default function BookingsListPage() {
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{(booking.createdAt || booking.requestedAt)
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(dateLocale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
{booking.createdAt || booking.requestedAt
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(
dateLocale,
{
day: '2-digit',
month: '2-digit',
year: 'numeric',
}
)
: 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-left text-sm font-medium">
{booking.type === 'csv'
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
{booking.type === 'csv'
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy">
{booking.bookingNumber || '-'}
{booking.bookingNumber || '-'}
</td>
</tr>
))}
@ -444,12 +482,15 @@ export default function BookingsListPage() {
start: startIndex + 1,
end: Math.min(endIndex, totalBookings),
total: totalBookings,
b: (chunks) => <span className="font-medium">{chunks}</span>,
b: chunks => <span className="font-medium">{chunks}</span>,
})}
</p>
</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
onClick={() => setPage(page - 1)}
disabled={page === 1}
@ -457,21 +498,40 @@ export default function BookingsListPage() {
>
<span className="sr-only">{t('pagination.previous')}</span>
<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>
</button>
{[...Array(totalPages)].map((_, idx) => {
const pageNum = idx + 1;
const showPage = pageNum === 1 ||
pageNum === totalPages ||
(pageNum >= page - 1 && pageNum <= page + 1);
const showPage =
pageNum === 1 ||
pageNum === totalPages ||
(pageNum >= page - 1 && pageNum <= page + 1);
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) {
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;
@ -497,7 +557,11 @@ export default function BookingsListPage() {
>
<span className="sr-only">{t('pagination.next')}</span>
<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>
</button>
</nav>
@ -523,9 +587,7 @@ export default function BookingsListPage() {
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('empty.title')}</h3>
<p className="mt-1 text-sm text-gray-500">
{searchTerm || statusFilter
? t('empty.hasFilters')
: t('empty.noBookings')}
{searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings')}
</p>
<div className="mt-6">
<Link

View File

@ -61,8 +61,17 @@ export default function UserDocumentsPage() {
const getFileType = (fileName: string): string => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const typeMap: Record<string, string> = {
pdf: 'PDF', doc: 'Word', docx: 'Word', xls: 'Excel', xlsx: 'Excel',
jpg: 'Image', jpeg: 'Image', png: 'Image', gif: 'Image', txt: 'Text', csv: 'CSV',
pdf: 'PDF',
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();
};
@ -151,7 +160,7 @@ export default function UserDocumentsPage() {
const getDocumentIcon = (type: string): ReactNode => {
const typeLower = type.toLowerCase();
const cls = "h-6 w-6";
const cls = 'h-6 w-6';
const iconMap: Record<string, ReactNode> = {
'application/pdf': <FileText className={`${cls} text-red-500`} />,
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
@ -356,8 +365,12 @@ export default function UserDocumentsPage() {
{ key: 'quoteNumber', label: t('export.quoteNumber') },
{ key: 'route', label: t('export.route') },
{ key: 'carrierName', label: t('export.carrier') },
{ 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: 'status', label: t('export.status'), format: v => getStatusLabel(v) },
{
key: 'uploadedAt',
label: t('export.uploadedAt'),
format: v => (v ? new Date(v).toLocaleDateString(locale) : ''),
},
]}
/>
<button
@ -365,8 +378,18 @@ export default function UserDocumentsPage() {
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"
>
<svg 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
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>
<span className="hidden sm:inline">{t('addDocument.buttonLabel')}</span>
</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="grid grid-cols-1 md:grid-cols-3 gap-4">
<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
type="text"
placeholder={t('filters.searchPlaceholder')}
@ -406,7 +431,9 @@ export default function UserDocumentsPage() {
/>
</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
type="text"
placeholder="Ex: #F2CAD5E1"
@ -416,7 +443,9 @@ export default function UserDocumentsPage() {
/>
</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
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
@ -443,13 +472,27 @@ export default function UserDocumentsPage() {
<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">{t('table.documentName')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.type')}</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>
<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">
{t('table.type')}
</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>
</thead>
<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>
</td>
<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)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<div className="relative inline-block text-left">
<button
onClick={(e) => {
onClick={e => {
e.stopPropagation();
toggleDropdown(`${doc.bookingId}-${doc.id}`);
}}
@ -505,7 +550,7 @@ export default function UserDocumentsPage() {
{openDropdownId === `${doc.bookingId}-${doc.id}` && (
<div
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">
<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"
>
<svg 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
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>
{t('actions.download')}
</button>
@ -524,8 +579,18 @@ export default function UserDocumentsPage() {
onClick={() => handleReplaceClick(doc)}
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">
<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
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>
{t('actions.replace')}
</button>
@ -587,7 +652,10 @@ export default function UserDocumentsPage() {
<option value={100}>100</option>
</select>
</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
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
@ -595,7 +663,11 @@ export default function UserDocumentsPage() {
>
<span className="sr-only">{t('pagination.previous')}</span>
<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>
</button>
@ -633,7 +705,11 @@ export default function UserDocumentsPage() {
>
<span className="sr-only">{t('pagination.next')}</span>
<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>
</button>
</nav>
@ -647,20 +723,37 @@ export default function UserDocumentsPage() {
{showAddModal && (
<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="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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<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">
<svg 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
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>
</div>
<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>
<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
value={selectedBookingId || ''}
onChange={e => setSelectedBookingId(e.target.value)}
@ -669,13 +762,19 @@ export default function UserDocumentsPage() {
<option value="">{t('addDocument.selectBookingPlaceholder')}</option>
{bookingsAvailableForDocuments.map(booking => (
<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>
))}
</select>
</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
ref={fileInputRef}
type="file"
@ -683,7 +782,9 @@ export default function UserDocumentsPage() {
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"
/>
<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>
@ -698,13 +799,30 @@ export default function UserDocumentsPage() {
>
{uploadingFiles ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" 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
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
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>
{t('addDocument.uploading')}
</>
) : t('addDocument.add')}
) : (
t('addDocument.add')
)}
</button>
<button
type="button"
@ -723,34 +841,58 @@ export default function UserDocumentsPage() {
{showReplaceModal && documentToReplace && (
<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="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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<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">
<svg 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
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>
</div>
<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="bg-gray-50 rounded-lg p-3">
<p className="text-sm text-gray-500">{t('replaceDocument.currentDocument')}</p>
<p className="text-sm font-medium text-gray-900 mt-1">{documentToReplace.fileName}</p>
<p className="text-sm text-gray-500">
{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">
{t('replaceDocument.booking')}: {documentToReplace.quoteNumber} - {documentToReplace.route}
{t('replaceDocument.booking')}: {documentToReplace.quoteNumber} -{' '}
{documentToReplace.route}
</p>
</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
ref={replaceFileInputRef}
type="file"
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"
/>
<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>
@ -765,13 +907,30 @@ export default function UserDocumentsPage() {
>
{replacingFile ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" 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
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
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>
{t('replaceDocument.replacing')}
</>
) : t('replaceDocument.replace')}
) : (
t('replaceDocument.replace')
)}
</button>
<button
type="button"

View File

@ -52,17 +52,39 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
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.bookings'), href: '/dashboard/bookings', icon: Package },
{ 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.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.users'), href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature },
] : []),
{
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.users'),
href: '/dashboard/settings/users',
icon: Users,
requiredFeature: 'user_management' as PlanFeature,
},
]
: []),
];
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 items-center justify-between h-16 px-6 border-b">
<Link href="/dashboard" className="text-2xl font-bold text-blue-600">
<Image
src="/assets/logos/logo-black.svg"
alt="Xpeditis"
width={50}
height={60}
priority
className="h-auto"
/>
<Image
src="/assets/logos/logo-black.svg"
alt="Xpeditis"
width={50}
height={60}
priority
className="h-auto"
/>
</Link>
<button
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">
{user?.firstName} {user?.lastName}
</p>
{subscription?.planDetails?.statusBadge && subscription.planDetails.statusBadge !== 'none' && (
<StatusBadge badge={subscription.planDetails.statusBadge} size="sm" />
)}
{subscription?.planDetails?.statusBadge &&
subscription.planDetails.statusBadge !== 'none' && (
<StatusBadge badge={subscription.planDetails.statusBadge} size="sm" />
)}
</div>
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
</div>
@ -195,8 +218,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<LanguageSwitcher variant="light" />
<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">
{user?.firstName?.[0]}{user?.lastName?.[0]}
<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"
>
{user?.firstName?.[0]}
{user?.lastName?.[0]}
</Link>
</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/track-trace', icon: Search, label: t('bottomNav.tracking') },
{ href: '/dashboard/profile', icon: User, label: t('bottomNav.profile') },
].map((item) => {
const active = item.href === '/dashboard' ? pathname === item.href : pathname.startsWith(item.href);
].map(item => {
const active =
item.href === '/dashboard' ? pathname === item.href : pathname.startsWith(item.href);
return (
<Link
key={item.href}

View File

@ -11,7 +11,24 @@ import {
deleteNotification,
} from '@/lib/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';
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',
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) => {
@ -97,7 +116,7 @@ export default function NotificationsPage() {
};
const getNotificationIcon = (type: string): ReactNode => {
const iconClass = "h-8 w-8";
const iconClass = 'h-8 w-8';
const icons: Record<string, ReactNode> = {
booking_created: <Package className={`${iconClass} text-blue-600`} />,
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" />
<span className="text-sm font-medium text-gray-700">{t('filter.label')}</span>
<div className="flex space-x-2">
{(['all', 'unread', 'read'] as const).map((filter) => (
{(['all', 'unread', 'read'] as const).map(filter => (
<button
key={filter}
onClick={() => {
@ -209,7 +228,9 @@ export default function NotificationsPage() {
) : notifications.length === 0 ? (
<div className="flex items-center justify-center py-20">
<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>
<p className="text-gray-500">
{selectedFilter === 'unread' ? t('empty.upToDate') : t('empty.none')}
@ -245,7 +266,7 @@ export default function NotificationsPage() {
)}
</div>
<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"
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"
/>
</svg>
<span className="font-medium">{formatTime(notification.createdAt)}</span>
<span className="font-medium">
{formatTime(notification.createdAt)}
</span>
</span>
<span className="px-3 py-1 bg-gray-100 rounded-full text-gray-700 font-medium">
{notification.type.replace(/_/g, ' ').toUpperCase()}
@ -284,10 +307,10 @@ export default function NotificationsPage() {
notification.priority === 'urgent'
? 'bg-red-100 text-red-700'
: notification.priority === 'high'
? 'bg-orange-100 text-orange-700'
: notification.priority === 'medium'
? 'bg-yellow-100 text-yellow-700'
: 'bg-blue-100 text-blue-700'
? 'bg-orange-100 text-orange-700'
: notification.priority === 'medium'
? 'bg-yellow-100 text-yellow-700'
: 'bg-blue-100 text-blue-700'
}`}
>
{getPriorityLabel(notification.priority)}
@ -329,12 +352,12 @@ export default function NotificationsPage() {
current: currentPage,
total: totalPages,
items: total,
b: (chunks) => <span className="font-semibold">{chunks}</span>,
b: chunks => <span className="font-semibold">{chunks}</span>,
})}
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
onClick={() => setCurrentPage(p => Math.max(1, p - 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"
>
@ -370,7 +393,7 @@ export default function NotificationsPage() {
})}
</div>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
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"
>

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>
<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">
{t('subtitle')}
</p>
<p className="text-gray-600 mt-0.5 sm:mt-1 text-xs sm:text-sm">{t('subtitle')}</p>
</div>
<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: 'acceptedBookings', label: t('export.accepted') },
{ 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: 'acceptanceRate', label: t('export.acceptanceRate'), format: (v) => v?.toFixed(1) || '0' },
{ key: 'avgPriceUSD', label: t('export.avgPrice'), format: (v) => v?.toFixed(2) || '0' },
{
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: '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">
@ -167,9 +181,7 @@ export default function DashboardPage() {
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
<>
<p className="text-2xl font-bold text-gray-900">
{csvKpis?.totalPending || 0}
</p>
<p className="text-2xl font-bold text-gray-900">{csvKpis?.totalPending || 0}</p>
<p className="text-xs text-gray-500 mt-1">
{t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })}
</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">
<TrendingUp className="h-5 w-5 text-green-600" />
</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">
{csvKpisLoading ? '--' : `${(csvKpis?.acceptanceRate ?? 0).toFixed(1)}%`}
</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">
<Package className="h-5 w-5 text-blue-600" />
</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">
{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">
<Weight className="h-5 w-5 text-purple-600" />
</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">
{csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`}
<span className="text-sm font-normal text-gray-500 ml-1">CBM</span>
@ -373,7 +391,9 @@ export default function DashboardPage() {
{carrier.carrierName}
</h3>
<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>{numberFormat.format(carrier.totalWeightKG)} KG</span>
</div>

View File

@ -251,7 +251,10 @@ export default function ProfilePage() {
<form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<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')}
</label>
<input
@ -268,7 +271,10 @@ export default function ProfilePage() {
</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')}
</label>
<input
@ -305,14 +311,19 @@ export default function ProfilePage() {
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"
>
{updateProfileMutation.isPending ? t('profileForm.saving') : t('profileForm.save')}
{updateProfileMutation.isPending
? t('profileForm.saving')
: t('profileForm.save')}
</button>
</div>
</form>
) : (
<form onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)} className="space-y-6">
<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')}
</label>
<input
@ -330,7 +341,10 @@ export default function ProfilePage() {
</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')}
</label>
<input
@ -349,7 +363,10 @@ export default function ProfilePage() {
</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')}
</label>
<input
@ -372,7 +389,9 @@ export default function ProfilePage() {
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"
>
{updatePasswordMutation.isPending ? t('passwordForm.submitting') : t('passwordForm.submit')}
{updatePasswordMutation.isPending
? t('passwordForm.submitting')
: t('passwordForm.submit')}
</button>
</div>
</form>

View File

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

View File

@ -6,7 +6,16 @@ import { useRouter } from '@/i18n/navigation';
import { useTranslations, useLocale } from 'next-intl';
import { searchCsvRatesWithOffers } from '@/lib/api/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 {
eco: CsvRateSearchResult;
@ -76,8 +85,12 @@ export default function SearchResultsPage() {
};
}
const sorted = [...results].sort((a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting);
const fastest = [...results].sort((a, b) => (a.adjustedTransitDays ?? a.transitDays) - (b.adjustedTransitDays ?? b.transitDays));
const sorted = [...results].sort(
(a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting
);
const fastest = [...results].sort(
(a, b) => (a.adjustedTransitDays ?? a.transitDays) - (b.adjustedTransitDays ?? b.transitDays)
);
return {
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="max-w-7xl mx-auto">
<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>
<p className="text-red-700 mb-4">{error}</p>
<button
@ -143,11 +158,11 @@ export default function SearchResultsPage() {
</button>
<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>
<p className="text-gray-600 mb-4">
{t('noResultsMessage', { origin, destination })}
</p>
<p className="text-gray-600 mb-4">{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">
<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')}
@ -226,10 +241,14 @@ export default function SearchResultsPage() {
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('resultsTitle')}</h1>
<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
? t('summaryWithPallets', { volume: volumeCBM, weight: weightKG, count: palletCount })
? t('summaryWithPallets', {
volume: volumeCBM,
weight: weightKG,
count: palletCount,
})
: t('summary', { volume: volumeCBM, weight: weightKG })}
</p>
</div>
@ -274,23 +293,31 @@ export default function SearchResultsPage() {
<div className="bg-white rounded-lg p-4 mb-4">
<div className="text-center mb-3">
<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 className="border-t border-gray-200 pt-3 space-y-2 text-sm">
<div className="flex justify-between">
<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 className="flex justify-between">
<span className="text-gray-600">{t('transit')}</span>
<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>
</div>
<div className="flex justify-between">
<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>
@ -298,7 +325,9 @@ export default function SearchResultsPage() {
<button
onClick={() => {
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`}
>
@ -314,11 +343,16 @@ export default function SearchResultsPage() {
{/* All Results */}
<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">
{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>
<h3 className="text-xl font-bold text-gray-900">{result.companyName}</h3>
@ -327,20 +361,26 @@ export default function SearchResultsPage() {
</p>
</div>
<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>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<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">
{formatPrice(result.priceBreakdown.totalFreight)}
</p>
</div>
<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">
{formatPrice(result.priceBreakdown.totalFob)}
</p>
@ -359,7 +399,11 @@ export default function SearchResultsPage() {
<div className="flex items-center justify-between">
<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' && (
<span className="text-orange-600 flex items-center">
<AlertTriangle className="h-4 w-4 mr-1" /> DG non accepté
@ -374,7 +418,9 @@ export default function SearchResultsPage() {
<button
onClick={() => {
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"
>

View File

@ -136,7 +136,9 @@ export default function RateSearchPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Origin Port */}
<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
type="text"
required
@ -317,7 +319,9 @@ export default function RateSearchPage() {
{/* Error */}
{searchError && (
<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>
)}
@ -341,7 +345,9 @@ export default function RateSearchPage() {
</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">
<input
type="range"
@ -400,7 +406,9 @@ export default function RateSearchPage() {
<div className="lg:col-span-3 space-y-4">
<div className="flex items-center justify-between">
<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>
</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"
/>
</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">
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>
</div>
)}

View File

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

View File

@ -174,7 +174,12 @@ export default function OrganizationSettingsPage() {
label: t('tabs.information'),
icon: (
<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>
),
},
@ -183,31 +188,53 @@ export default function OrganizationSettingsPage() {
label: t('tabs.address'),
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.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" />
<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
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
),
},
...(canViewBilling ? [
{
id: 'subscription' as TabType,
label: t('tabs.subscription'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
),
},
{
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>
),
},
] : []),
...(canViewBilling
? [
{
id: 'subscription' as TabType,
label: t('tabs.subscription'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
),
},
{
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 (
@ -220,8 +247,18 @@ export default function OrganizationSettingsPage() {
{successMessage && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
<svg
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>
<p className="text-green-800 font-medium">{successMessage}</p>
</div>
@ -231,8 +268,18 @@ export default function OrganizationSettingsPage() {
{error && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg
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>
<p className="text-red-800 font-medium">{error}</p>
</div>
@ -242,8 +289,18 @@ export default function OrganizationSettingsPage() {
{!canEdit && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<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">
<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
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>
<p className="text-blue-800 font-medium">{t('readOnlyWarning')}</p>
</div>
@ -253,7 +310,7 @@ export default function OrganizationSettingsPage() {
<div className="bg-white rounded-lg shadow-md">
<div className="border-b border-gray-200">
<nav className="flex -mb-px overflow-x-auto">
{tabs.map((tab) => (
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
@ -298,7 +355,9 @@ export default function OrganizationSettingsPage() {
<input
type="text"
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}
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')}
@ -325,7 +384,9 @@ export default function OrganizationSettingsPage() {
</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
type="tel"
value={formData.contact_phone}
@ -337,7 +398,9 @@ export default function OrganizationSettingsPage() {
</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
type="email"
value={formData.contact_email}

View File

@ -199,7 +199,10 @@ export default function UsersManagementPage() {
};
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) => {
@ -235,7 +238,10 @@ export default function UsersManagementPage() {
const pagedUsers = allUsers.slice((usersPage - 1) * PAGE_SIZE, usersPage * PAGE_SIZE);
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 (
<div className="space-y-6">
@ -244,16 +250,26 @@ export default function UsersManagementPage() {
<div className="flex items-start">
<div className="flex-shrink-0">
<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>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-amber-800">{t('license.limitTitle')}</h3>
<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>
<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')}
</Link>
</div>
@ -262,27 +278,37 @@ export default function UsersManagementPage() {
</div>
)}
{licenseStatus && licenseStatus.canInvite && licenseStatus.availableLicenses <= 2 && licenseStatus.maxLicenses !== -1 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<svg className="h-5 w-5 text-blue-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
<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" />
</svg>
<span className="text-sm text-blue-800">
{t('license.remaining', {
count: licenseStatus.availableLicenses,
used: licenseStatus.usedLicenses,
max: licenseStatus.maxLicenses,
})}
</span>
{licenseStatus &&
licenseStatus.canInvite &&
licenseStatus.availableLicenses <= 2 &&
licenseStatus.maxLicenses !== -1 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<svg className="h-5 w-5 text-blue-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
<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"
/>
</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>
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-blue-600 hover:text-blue-800">
{t('license.manageLink')}
</Link>
</div>
</div>
)}
)}
<PageHeader
title={t('header.title')}
@ -296,9 +322,21 @@ export default function UsersManagementPage() {
{ key: 'firstName', label: t('export.firstName') },
{ key: 'lastName', label: t('export.lastName') },
{ 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: 'createdAt', label: t('export.createdAt'), format: (v) => v ? new Date(v).toLocaleDateString(dateLocale) : '' },
{
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: 'createdAt',
label: t('export.createdAt'),
format: v => (v ? new Date(v).toLocaleDateString(dateLocale) : ''),
},
]}
/>
{licenseStatus?.canInvite ? (
@ -340,7 +378,9 @@ export default function UsersManagementPage() {
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">{t('users.title')}</h2>
{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>
{isLoading ? (
@ -354,12 +394,24 @@ export default function UsersManagementPage() {
<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">{t('users.table.user')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.email')}</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>
<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">
{t('users.table.email')}
</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>
</thead>
<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">
<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">
{user.firstName[0]}{user.lastName[0]}
{user.firstName[0]}
{user.lastName[0]}
</div>
<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>
</div>
@ -390,14 +445,18 @@ export default function UsersManagementPage() {
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="USER">{t('modal.roles.USER')}</option>
<option value="VIEWER">{t('modal.roles.VIEWER')}</option>
</select>
</td>
<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')}
</span>
</td>
@ -406,7 +465,7 @@ export default function UsersManagementPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={(e) => {
onClick={e => {
if (openMenuId === user.id) {
setOpenMenuId(null);
setMenuPosition(null);
@ -418,7 +477,11 @@ export default function UsersManagementPage() {
}}
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" />
</svg>
</button>
@ -428,23 +491,46 @@ export default function UsersManagementPage() {
</tbody>
</table>
</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">
<svg 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
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>
<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>
<div className="mt-6">
{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>
{t('actions.invite')}
</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>
{t('actions.upgrade')}
</Link>
@ -466,12 +552,24 @@ export default function UsersManagementPage() {
<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">{t('invitations.table.user')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.email')}</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>
<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">
{t('invitations.table.email')}
</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>
</thead>
<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">
<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">
{inv.firstName[0]}{inv.lastName[0]}
{inv.firstName[0]}
{inv.lastName[0]}
</div>
<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>
</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">
<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}
</span>
</td>
@ -499,18 +604,32 @@ export default function UsersManagementPage() {
{new Date(inv.expiresAt).toLocaleDateString(dateLocale)}
</td>
<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')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={() => handleCancelInvitation(inv.id, `${inv.firstName} ${inv.lastName}`)}
onClick={() =>
handleCancelInvitation(inv.id, `${inv.firstName} ${inv.lastName}`)
}
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"
>
<svg 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
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>
{t('invitations.cancel')}
</button>
@ -521,7 +640,11 @@ export default function UsersManagementPage() {
</tbody>
</table>
</div>
<Pagination page={invitationsPage} total={allPending.length} onPage={setInvitationsPage} />
<Pagination
page={invitationsPage}
total={allPending.length}
onPage={setInvitationsPage}
/>
</div>
)}
@ -529,7 +652,10 @@ export default function UsersManagementPage() {
<>
<div
className="fixed inset-0 z-[998]"
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
onClick={() => {
setOpenMenuId(null);
setMenuPosition(null);
}}
/>
<div
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 ? (
<>
<svg 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
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>
<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">
<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
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>
<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>
@ -571,10 +721,22 @@ export default function UsersManagementPage() {
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"
>
<svg 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
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>
<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>
</div>
</div>
@ -584,21 +746,34 @@ export default function UsersManagementPage() {
{showInviteModal && (
<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="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>
<div className="flex items-center justify-between mb-4">
<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">
<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>
</button>
</div>
<form onSubmit={handleInvite} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<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
type="text"
required
@ -608,7 +783,9 @@ export default function UsersManagementPage() {
/>
</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
type="text"
required
@ -619,7 +796,9 @@ export default function UsersManagementPage() {
</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
type="email"
required
@ -629,7 +808,9 @@ export default function UsersManagementPage() {
/>
</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
value={inviteForm.role}
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}

View File

@ -33,16 +33,99 @@ interface SearchHistoryItem {
type CarrierDescKey = 'containerOrBl' | 'containerBlOrBooking' | 'containerOnly';
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: '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 },
{
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: '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';
@ -63,10 +146,12 @@ export default function TrackTracePage() {
if (savedHistory) {
try {
const parsed = JSON.parse(savedHistory);
setSearchHistory(parsed.map((item: any) => ({
...item,
timestamp: new Date(item.timestamp)
})));
setSearchHistory(
parsed.map((item: any) => ({
...item,
timestamp: new Date(item.timestamp),
}))
);
} catch (e) {
console.error('Failed to parse search history:', e);
}
@ -100,9 +185,16 @@ export default function TrackTracePage() {
timestamp: new Date(),
};
const updatedHistory = [newHistoryItem, ...searchHistory.filter(
h => !(h.trackingNumber === newHistoryItem.trackingNumber && h.carrierId === newHistoryItem.carrierId)
)].slice(0, 10);
const updatedHistory = [
newHistoryItem,
...searchHistory.filter(
h =>
!(
h.trackingNumber === newHistoryItem.trackingNumber &&
h.carrierId === newHistoryItem.carrierId
)
),
].slice(0, 10);
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}`}
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>
<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>
))}
</div>
@ -199,7 +295,10 @@ export default function TrackTracePage() {
{/* Tracking Number Input */}
<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')}
</label>
<div className="flex gap-3">
@ -236,14 +335,15 @@ export default function TrackTracePage() {
{/* Map Toggle */}
<div className="flex flex-wrap gap-3 pt-2">
<Button
variant={showMap ? "default" : "outline"}
variant={showMap ? 'default' : 'outline'}
onClick={() => {
setShowMap(!showMap);
if (!showMap) setIsMapLoading(true);
}}
className={showMap
? "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"
className={
showMap
? '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" />
@ -263,9 +363,13 @@ export default function TrackTracePage() {
{/* Vessel Position Map */}
{showMap && (
<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 */}
<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="p-2 bg-white/20 rounded-lg">
<Globe className="h-6 w-6" />
@ -309,7 +413,9 @@ export default function TrackTracePage() {
</div>
{/* 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 && (
<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">
@ -317,9 +423,18 @@ export default function TrackTracePage() {
<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="flex gap-1">
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" 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
className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"
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>
@ -338,7 +453,9 @@ export default function TrackTracePage() {
/>
{/* 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">
<Anchor className="h-4 w-4 text-blue-600" />
{t('map.legend')}
@ -364,7 +481,9 @@ export default function TrackTracePage() {
</div>
{/* 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="text-center">
<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()}
</div>
<div>
<p className="font-mono text-sm font-medium text-gray-900">{item.trackingNumber}</p>
<p className="text-xs text-gray-500">{item.carrierName} {formatTimeAgo(item.timestamp)}</p>
<p className="font-mono text-sm font-medium text-gray-900">
{item.trackingNumber}
</p>
<p className="text-xs text-gray-500">
{item.carrierName} {formatTimeAgo(item.timestamp)}
</p>
</div>
</div>
<button
onClick={(e) => {
onClick={e => {
e.stopPropagation();
handleDeleteHistory(item.id);
}}

View File

@ -7,20 +7,35 @@ export default async function AssurancePage() {
const t = await getTranslations('dashboard.wikiPages');
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 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'];
return (
<div className="space-y-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">
<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>
{t('backToWiki')}
</Link>
@ -42,10 +57,14 @@ export default async function AssurancePage() {
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-3">
<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 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">
{clause.includes.map((item, j) => (
<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">
<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">
{extensions.map((ext) => (
{extensions.map(ext => (
<Card key={ext.name} className="bg-white">
<CardContent className="pt-4">
<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">
<CardContent className="pt-6">
<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>
</CardContent>
</Card>

View File

@ -7,19 +7,32 @@ export default async function CalculFretPage() {
const t = await getTranslations('dashboard.wikiPages');
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<{
name: string; description: string; typical: string;
name: string;
description: string;
typical: string;
}>;
const exampleItems = t.raw('calculFret.exampleItems') as Array<{ item: string; amount: string }>;
return (
<div className="space-y-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">
<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>
{t('backToWiki')}
</Link>
@ -46,7 +59,7 @@ export default async function CalculFretPage() {
</tr>
</thead>
<tbody>
{surcharges.map((s) => (
{surcharges.map(s => (
<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-medium">{s.name}</td>
@ -60,14 +73,18 @@ export default async function CalculFretPage() {
</div>
<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">
{additionalCosts.map((cost) => (
{additionalCosts.map(cost => (
<Card key={cost.name} className="bg-white">
<CardContent className="pt-4">
<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-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>
</Card>
))}
@ -86,7 +103,10 @@ export default async function CalculFretPage() {
</thead>
<tbody>
{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 text-right font-mono">{item.amount}</td>
</tr>

View File

@ -7,17 +7,36 @@ export default async function ConteneursPage() {
const t = await getTranslations('dashboard.wikiPages');
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 (
<div className="space-y-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">
<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>
{t('backToWiki')}
</Link>
@ -45,7 +64,7 @@ export default async function ConteneursPage() {
</tr>
</thead>
<tbody>
{containers.map((c) => (
{containers.map(c => (
<tr key={c.type} className="border-t hover:bg-gray-50">
<td className="p-3">
<div className="font-semibold text-gray-900">{c.type}</div>
@ -63,9 +82,11 @@ export default async function ConteneursPage() {
</div>
<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">
{specialEquipment.map((eq) => (
{specialEquipment.map(eq => (
<Card key={eq.name} className="bg-white">
<CardContent className="pt-4">
<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">
<thead>
<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">{t('conteneurs.colRecommendation')}</th>
<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">
{t('conteneurs.colRecommendation')}
</th>
</tr>
</thead>
<tbody>

View File

@ -7,17 +7,34 @@ export default async function DocumentsTransportPage() {
const t = await getTranslations('dashboard.wikiPages');
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 (
<div className="space-y-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">
<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>
{t('backToWiki')}
</Link>
@ -32,21 +49,30 @@ export default async function DocumentsTransportPage() {
</div>
<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">
{documents.map((doc) => (
{documents.map(doc => (
<Card key={doc.name} className="bg-white">
<CardContent className="pt-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<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>
<p className="text-sm text-gray-600 mb-2">{doc.description}</p>
<div className="flex flex-wrap gap-1">
{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>
@ -58,9 +84,11 @@ export default async function DocumentsTransportPage() {
</div>
<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">
{additionalDocs.map((doc) => (
{additionalDocs.map(doc => (
<Card key={doc.name} className="bg-white">
<CardContent className="pt-4">
<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">
<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">
{blFunctions.map((fn) => (
{blFunctions.map(fn => (
<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>
<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() {
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<{
name: string; mandatory: boolean; description: string;
name: string;
mandatory: boolean;
description: string;
}>;
const duties = t.raw('douanes.duties') as Array<{ type: string; description: string }>;
return (
<div className="space-y-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">
<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>
{t('backToWiki')}
</Link>
@ -43,7 +57,7 @@ export default async function DouanesPage() {
</tr>
</thead>
<tbody>
{regimes.map((r) => (
{regimes.map(r => (
<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-medium">{r.name}</td>
@ -58,11 +72,13 @@ export default async function DouanesPage() {
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.documentsTitle')}</h2>
<div className="space-y-3">
{documents.map((doc) => (
{documents.map(doc => (
<Card key={doc.name} className="bg-white">
<CardContent className="py-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')}
</span>
<div>
@ -79,7 +95,7 @@ export default async function DouanesPage() {
<div className="mt-8">
<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">
{duties.map((d) => (
{duties.map(d => (
<Card key={d.type} className="bg-white">
<CardContent className="pt-4">
<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 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 packagingGroups = t.raw('imdg.packagingGroups') as Array<{ group: string; description: string }>;
const packagingGroups = t.raw('imdg.packagingGroups') as Array<{
group: string;
description: string;
}>;
return (
<div className="space-y-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">
<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>
{t('backToWiki')}
</Link>
@ -34,17 +48,21 @@ export default async function ImdgPage() {
<div className="mt-8">
<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">
{classes.map((cls) => (
{classes.map(cls => (
<Card key={cls.class} className="bg-white border-orange-200">
<CardContent className="pt-4">
<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>
</div>
<p className="text-sm text-gray-600">{cls.description}</p>
{cls.subdivisions && (
<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">
{cls.subdivisions.map((sub, j) => (
<li key={j}> {sub}</li>
@ -61,7 +79,7 @@ export default async function ImdgPage() {
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.documentsTitle')}</h2>
<div className="space-y-3">
{documents.map((doc) => (
{documents.map(doc => (
<Card key={doc.name} className="bg-white">
<CardContent className="py-3">
<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>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{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 (
<Card key={pg.group} className={`border ${colors[i]}`}>
<CardContent className="pt-4">

View File

@ -7,22 +7,40 @@ export default async function IncotermsPage() {
const t = await getTranslations('dashboard.wikiPages');
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<{
name: string; description: string; terms: string[];
name: string;
description: string;
terms: string[];
}>;
const keyPoints = t.raw('incoterms.keyPoints') 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 (
<div className="space-y-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">
<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>
{t('backToWiki')}
</Link>
@ -59,8 +77,13 @@ export default async function IncotermsPage() {
<h3 className="font-semibold text-gray-900">{cat.name}</h3>
<p className="text-sm text-gray-600 mt-1">{cat.description}</p>
<div className="flex flex-wrap gap-1 mt-3">
{cat.terms.map((term) => (
<span key={term} className={`px-2 py-0.5 rounded text-xs font-mono font-bold ${categoryColors[i]}`}>{term}</span>
{cat.terms.map(term => (
<span
key={term}
className={`px-2 py-0.5 rounded text-xs font-mono font-bold ${categoryColors[i]}`}
>
{term}
</span>
))}
</div>
</CardContent>
@ -83,7 +106,7 @@ export default async function IncotermsPage() {
</tr>
</thead>
<tbody>
{list.map((item) => (
{list.map(item => (
<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-medium">{item.name}</td>

View File

@ -6,17 +6,33 @@ import { Scale } from 'lucide-react';
export default async function LclVsFclPage() {
const t = await getTranslations('dashboard.wikiPages');
const criteria = t.raw('lclVsFcl.criteria') as Array<{ criterion: string; lcl: string; fcl: string }>;
const lclProcess = t.raw('lclVsFcl.lclProcess') as Array<{ step: string; title: string; description: string }>;
const criteria = t.raw('lclVsFcl.criteria') as Array<{
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 chooseFcl = t.raw('lclVsFcl.chooseFcl') as string[];
return (
<div className="space-y-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">
<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>
{t('backToWiki')}
</Link>
@ -57,7 +73,7 @@ export default async function LclVsFclPage() {
</tr>
</thead>
<tbody>
{criteria.map((row) => (
{criteria.map(row => (
<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 text-gray-600">{row.lcl}</td>
@ -72,7 +88,7 @@ export default async function LclVsFclPage() {
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lclVsFcl.lclProcessTitle')}</h2>
<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 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}

View File

@ -10,15 +10,29 @@ export default async function LettreCreditPage() {
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 errors = t.raw('lettreCredit.errors') as string[];
const datesItems = t.raw('lettreCredit.datesItems') as Array<{ label: string; description: string }>;
const costsItems = t.raw('lettreCredit.costsItems') as Array<{ label: string; description: string }>;
const datesItems = t.raw('lettreCredit.datesItems') as Array<{
label: string;
description: string;
}>;
const costsItems = t.raw('lettreCredit.costsItems') as Array<{
label: string;
description: string;
}>;
return (
<div className="space-y-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">
<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>
{t('backToWiki')}
</Link>
@ -35,7 +49,7 @@ export default async function LettreCreditPage() {
<div className="mt-8">
<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">
{types.map((type) => (
{types.map(type => (
<Card key={type.name} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{type.name}</h4>
@ -57,7 +71,7 @@ export default async function LettreCreditPage() {
</tr>
</thead>
<tbody>
{parties.map((p) => (
{parties.map(p => (
<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 text-gray-600">{p.description}</td>
@ -71,7 +85,7 @@ export default async function LettreCreditPage() {
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.documentsTitle')}</h2>
<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">
<span className="text-green-500 mt-0.5 flex-shrink-0"></span>
<div>
@ -108,7 +122,7 @@ export default async function LettreCreditPage() {
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('lettreCredit.datesTitle')}</h3>
<div className="space-y-3">
{datesItems.map((item) => (
{datesItems.map(item => (
<div key={item.label}>
<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>
@ -121,7 +135,7 @@ export default async function LettreCreditPage() {
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('lettreCredit.costsTitle')}</h3>
<div className="space-y-3">
{costsItems.map((item) => (
{costsItems.map(item => (
<div key={item.label}>
<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>

View File

@ -17,7 +17,19 @@ import {
type LucideIcon,
} 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 {
key: TopicKey;
@ -27,18 +39,63 @@ interface 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: 'containers', icon: Package, href: '/dashboard/wiki/conteneurs', tags: ["20'", "40'", 'Reefer', 'Open Top'] },
{
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: '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: '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: '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: '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: '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: '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() {
@ -54,7 +111,7 @@ export default async function WikiPage() {
{/* Cards Grid */}
<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;
return (
<Link key={topic.href} href={topic.href} className="block group">
@ -74,7 +131,7 @@ export default async function WikiPage() {
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{topic.tags.map((tag) => (
{topic.tags.map(tag => (
<span
key={tag}
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 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<{
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<{
rank: number; port: string; country: string; teu: string;
rank: number;
port: string;
country: string;
teu: string;
}>;
return (
<div className="space-y-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">
<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>
{t('backToWiki')}
</Link>
@ -36,20 +55,33 @@ export default async function PortsRoutesPage() {
</div>
<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">
{routes.map((route) => (
{routes.map(route => (
<Card key={route.name} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900 mb-1">{route.name}</h4>
<p className="text-sm text-gray-600 mb-3">{route.description}</p>
<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">{t('portsRoutes.colTransit')}: <strong className="text-blue-600">{route.transitTime}</strong></span>
<span className="text-gray-500">
{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 className="flex flex-wrap gap-1 mt-2">
{route.majorPorts.map((port) => (
<span key={port} className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded">{port}</span>
{route.majorPorts.map(port => (
<span
key={port}
className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded"
>
{port}
</span>
))}
</div>
</CardContent>
@ -61,15 +93,19 @@ export default async function PortsRoutesPage() {
<div className="mt-8">
<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">
{passages.map((p) => (
{passages.map(p => (
<Card key={p.name} className="bg-white border-blue-200">
<CardContent className="pt-4">
<div className="flex items-start justify-between mb-2">
<div>
<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>
<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>
<p className="text-sm text-gray-600">{p.description}</p>
</CardContent>
@ -91,7 +127,7 @@ export default async function PortsRoutesPage() {
</tr>
</thead>
<tbody>
{ports.map((port) => (
{ports.map(port => (
<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-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 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<{
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 seasonalVariations = t.raw('transitTime.seasonalVariations') as string[];
@ -28,9 +38,17 @@ export default async function TransitTimePage() {
return (
<div className="space-y-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">
<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>
{t('backToWiki')}
</Link>
@ -48,7 +66,7 @@ export default async function TransitTimePage() {
<CardContent className="pt-6">
<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">
{keyTerms.map((term) => (
{keyTerms.map(term => (
<div key={term.key}>
<h4 className="font-medium text-blue-800">{term.key}</h4>
<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>
<div className="space-y-3">
{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">
<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">
@ -71,10 +92,14 @@ export default async function TransitTimePage() {
<div className="flex-1">
<div className="flex flex-wrap items-center gap-2 mb-1">
<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>
<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>
</CardContent>
@ -84,7 +109,9 @@ export default async function TransitTimePage() {
</div>
<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">
<CardContent className="pt-6">
<div className="overflow-x-auto">
@ -97,7 +124,7 @@ export default async function TransitTimePage() {
</tr>
</thead>
<tbody>
{transitTimes.map((tt) => (
{transitTimes.map(tt => (
<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-center font-mono text-blue-600">{tt.time}</td>
@ -137,7 +164,7 @@ export default async function TransitTimePage() {
<div className="mt-8">
<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">
{lateFees.map((fee) => (
{lateFees.map(fee => (
<Card key={fee.name} className="bg-white border-red-200">
<CardContent className="pt-4">
<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">
<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>
<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">
{potentialDelays.map((d, i) => <li key={i}> {d}</li>)}
{potentialDelays.map((d, i) => (
<li key={i}> {d}</li>
))}
</ul>
</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">
{seasonalVariations.map((v, i) => <li key={i}> {v}</li>)}
{seasonalVariations.map((v, i) => (
<li key={i}> {v}</li>
))}
</ul>
</div>
</div>
@ -183,7 +220,9 @@ export default async function TransitTimePage() {
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">{t('transitTime.rolloverCausesTitle')}</h4>
<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>
<p className="text-xs text-gray-500 mt-3">{t('transitTime.rolloverImpact')}</p>
</div>
@ -194,7 +233,9 @@ export default async function TransitTimePage() {
<CardContent className="pt-6">
<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">
{tips.map((tip, i) => <li key={i}>{tip}</li>)}
{tips.map((tip, i) => (
<li key={i}>{tip}</li>
))}
</ul>
</CardContent>
</Card>

View File

@ -7,21 +7,40 @@ export default async function VGMPage() {
const t = await getTranslations('dashboard.wikiPages');
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 methods = t.raw('vgm.methods') as Array<{
method: string; name: string; description: string;
process: string[]; advantages: string[]; disadvantages: string[];
const elements = t.raw('vgm.elements') as Array<{
element: string;
description: 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 tips = t.raw('vgm.tips') as string[];
return (
<div className="space-y-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">
<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>
{t('backToWiki')}
</Link>
@ -39,7 +58,7 @@ export default async function VGMPage() {
<CardContent className="pt-6">
<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">
{why.map((item) => (
{why.map(item => (
<div key={item.title}>
<h4 className="font-medium">{item.title}</h4>
<p className="text-sm mt-0.5">{item.description}</p>
@ -54,16 +73,23 @@ export default async function VGMPage() {
<Card className="bg-white">
<CardContent className="pt-6">
<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 className="space-y-3">
{elements.map((item) => (
<div key={item.element} className="flex items-center justify-between py-3 border-b last:border-0">
{elements.map(item => (
<div
key={item.element}
className="flex items-center justify-between py-3 border-b last:border-0"
>
<div>
<h4 className="font-medium text-gray-900">{item.element}</h4>
<p className="text-sm text-gray-600">{item.description}</p>
</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>
@ -74,11 +100,13 @@ export default async function VGMPage() {
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('vgm.methodsTitle')}</h2>
<div className="space-y-4">
{methods.map((method) => (
{methods.map(method => (
<Card key={method.method} className="bg-white">
<CardHeader className="pb-2">
<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>
</CardTitle>
</CardHeader>
@ -88,25 +116,33 @@ export default async function VGMPage() {
<div>
<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">
{method.process.map((step, i) => <li key={i}>{step}</li>)}
{method.process.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</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">
{method.advantages.map((adv) => (
{method.advantages.map(adv => (
<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>
))}
</ul>
</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">
{method.disadvantages.map((dis) => (
{method.disadvantages.map(dis => (
<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>
))}
</ul>
@ -122,7 +158,7 @@ export default async function VGMPage() {
<CardContent className="pt-6">
<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">
{responsibilities.map((r) => (
{responsibilities.map(r => (
<div key={r.role} className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">{r.role}</h4>
<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">
<CardContent className="pt-6">
<div className="space-y-3">
{sanctions.map((s) => (
<div key={s.region} className="flex items-center justify-between py-3 border-b last:border-0">
{sanctions.map(s => (
<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="text-sm text-red-600">{s.sanction}</span>
</div>
@ -170,7 +209,9 @@ export default async function VGMPage() {
<CardContent className="pt-6">
<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">
{tips.map((tip, i) => <li key={i}>{tip}</li>)}
{tips.map((tip, i) => (
<li key={i}>{tip}</li>
))}
</ul>
</CardContent>
</Card>

View File

@ -1,4 +1,4 @@
"use client";
'use client';
import dynamic from 'next/dynamic';
@ -24,9 +24,7 @@ export default function DemoPage() {
<div className="max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Démo Carte Maritime</h1>
<p className="text-gray-600">
Visualisation de la route entre Marseille et Barcelone
</p>
<p className="text-gray-600">Visualisation de la route entre Marseille et Barcelone</p>
</div>
<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">
Route: Port de Marseille Port de Barcelone
</h2>
<p className="text-blue-100 text-sm mt-1">
Distance approximative: ~350 km par la mer
</p>
<p className="text-blue-100 text-sm mt-1">Distance approximative: ~350 km par la mer</p>
</div>
<PortRouteMap portA={portA} portB={portB} height="600px" />
@ -46,12 +42,16 @@ export default function DemoPage() {
<div>
<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-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>
<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-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>

View File

@ -50,8 +50,18 @@ export default function ForgotPasswordPage() {
<>
<div className="mb-8">
<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">
<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
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>
</div>
<h1 className="text-h1 text-brand-navy mb-2">{t('successTitle')}</h1>
@ -61,9 +71,7 @@ export default function ForgotPasswordPage() {
__html: t('successMessage', { email }),
}}
/>
<p className="text-body-sm text-neutral-500 mt-3">
{t('successHint')}
</p>
<p className="text-body-sm text-neutral-500 mt-3">{t('successHint')}</p>
</div>
<Link href="/login" className="btn-primary w-full text-center block text-lg">
{t('backToLogin')}
@ -73,15 +81,23 @@ export default function ForgotPasswordPage() {
<>
<div className="mb-8">
<h1 className="text-h1 text-brand-navy mb-2">{t('title')}</h1>
<p className="text-body text-neutral-600">
{t('subtitle')}
</p>
<p className="text-body text-neutral-600">{t('subtitle')}</p>
</div>
{error && (
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
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>
<p className="text-body-sm text-red-800">{error}</p>
</div>
@ -115,9 +131,17 @@ export default function ForgotPasswordPage() {
</form>
<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">
<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>
{t('backToLogin')}
</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="max-w-xl">
<h2 className="text-display-sm mb-6 text-white">{t('sidePanel.title')}</h2>
<p className="text-body-lg text-neutral-200 mb-12">
{t('sidePanel.description')}
</p>
<p className="text-body-lg text-neutral-200 mb-12">{t('sidePanel.description')}</p>
<div className="space-y-6">
<div className="flex items-start">
<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">
<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
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>
</div>
<div className="ml-4">
<h3 className="text-h5 mb-1 text-white">{t('sidePanel.features.secure.title')}</h3>
<p className="text-body-sm text-neutral-300">{t('sidePanel.features.secure.description')}</p>
<h3 className="text-h5 mb-1 text-white">
{t('sidePanel.features.secure.title')}
</h3>
<p className="text-body-sm text-neutral-300">
{t('sidePanel.features.secure.description')}
</p>
</div>
</div>
<div className="flex items-start">
<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">
<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
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>
</div>
<div className="ml-4">
<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>
@ -177,9 +225,30 @@ export default function ForgotPasswordPage() {
</div>
<div className="absolute bottom-0 right-0 opacity-10">
<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 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" />
<circle
cx="200"
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>
</div>
</div>

View File

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

View File

@ -135,9 +135,7 @@ function LoginPageContent() {
<div className="mb-8">
<h1 className="text-h1 text-brand-navy mb-2">{tLogin('title')}</h1>
<p className="text-body text-neutral-600">
{tLogin('subtitle')}
</p>
<p className="text-body text-neutral-600">{tLogin('subtitle')}</p>
</div>
{error && (
@ -181,7 +179,10 @@ function LoginPageContent() {
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
/>
{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">
<path
strokeLinecap="round"
@ -196,7 +197,10 @@ function LoginPageContent() {
</div>
<div>
<label htmlFor="password" className={`label ${fieldErrors.password ? 'text-red-600' : ''}`}>
<label
htmlFor="password"
className={`label ${fieldErrors.password ? 'text-red-600' : ''}`}
>
{tLogin('passwordLabel')}
</label>
<input
@ -216,7 +220,10 @@ function LoginPageContent() {
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
/>
{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">
<path
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="max-w-xl">
<h2 className="text-display-sm mb-6 text-white">{tPanel('title')}</h2>
<p className="text-body-lg text-neutral-200 mb-12">
{tPanel('description')}
</p>
<p className="text-body-lg text-neutral-200 mb-12">{tPanel('description')}</p>
<div className="space-y-6">
<div className="flex items-start">
@ -309,7 +314,9 @@ function LoginPageContent() {
</svg>
</div>
<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">
{tPanel('features.instantRates.description')}
</p>
@ -372,11 +379,15 @@ function LoginPageContent() {
</div>
<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 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>

View File

@ -38,9 +38,7 @@ export default function NotFound() {
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" />
<span className="text-white/90 text-sm font-medium font-heading">
Erreur 404
</span>
<span className="text-white/90 text-sm font-medium font-heading">Erreur 404</span>
</motion.div>
{/* Animated Ship + Waves illustration */}
@ -59,11 +57,7 @@ export default function NotFound() {
xmlns="http://www.w3.org/2000/svg"
>
{/* Hull */}
<path
d="M40 75 L55 95 L145 95 L160 75 Z"
fill="#34CCCD"
opacity="0.9"
/>
<path d="M40 75 L55 95 L145 95 L160 75 Z" fill="#34CCCD" opacity="0.9" />
{/* Deck */}
<rect x="60" y="58" width="80" height="17" rx="2" fill="white" opacity="0.9" />
{/* Bridge */}
@ -75,7 +69,15 @@ export default function NotFound() {
<rect x="95" y="22" width="12" height="13" rx="1" fill="#10183A" opacity="0.8" />
<rect x="95" y="22" width="12" height="4" rx="1" fill="#34CCCD" opacity="0.6" />
{/* Mast */}
<line x1="102" y1="10" x2="102" y2="22" stroke="white" strokeWidth="1.5" opacity="0.7" />
<line
x1="102"
y1="10"
x2="102"
y2="22"
stroke="white"
strokeWidth="1.5"
opacity="0.7"
/>
{/* Flag */}
<path d="M102 10 L115 15 L102 20" fill="#34CCCD" opacity="0.8" />
{/* Containers on deck */}
@ -144,8 +146,8 @@ export default function NotFound() {
transition={{ duration: 0.8, delay: 0.6 }}
className="text-lg lg:text-xl text-white/70 mb-12 max-w-2xl mx-auto leading-relaxed font-body"
>
Ce navire a pris le large... La page que vous cherchez
n&apos;existe pas ou a é déplacée.
Ce navire a pris le large... La page que vous cherchez n&apos;existe pas ou a é
déplacée.
</motion.p>
{/* CTA Buttons */}
@ -194,17 +196,32 @@ export default function NotFound() {
<style jsx global>{`
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
25% { transform: translateY(-6px) rotate(1deg); }
75% { transform: translateY(4px) rotate(-1deg); }
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
25% {
transform: translateY(-6px) rotate(1deg);
}
75% {
transform: translateY(4px) rotate(-1deg);
}
}
@keyframes wave {
0% { transform: translateX(-50%) translateX(0); }
100% { transform: translateX(-50%) translateX(-50px); }
0% {
transform: translateX(-50%) translateX(0);
}
100% {
transform: translateX(-50%) translateX(-50px);
}
}
@keyframes wave-slow {
0% { transform: translateX(-50%) translateX(0); }
100% { transform: translateX(-50%) translateX(50px); }
0% {
transform: translateX(-50%) translateX(0);
}
100% {
transform: translateX(-50%) translateX(50px);
}
}
.animate-float {
animation: float 4s ease-in-out infinite;

View File

@ -62,7 +62,13 @@ function AnimatedCounter({
}, [end, duration, isActive]);
const display = decimals > 0 ? count.toFixed(decimals) : Math.floor(count).toString();
return <>{prefix}{display}{suffix}</>;
return (
<>
{prefix}
{display}
{suffix}
</>
);
}
export default function LandingPage() {
@ -139,12 +145,18 @@ export default function LandingPage() {
},
];
const stats = [
{ 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: 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 }>> = {
@ -245,12 +257,13 @@ export default function LandingPage() {
},
] as const;
const testimonials = (t.raw('testimonials.items') as Array<{
quote: string;
author: string;
role: string;
company: string;
}>) ?? [];
const testimonials =
(t.raw('testimonials.items') as Array<{
quote: string;
author: string;
role: string;
company: string;
}>) ?? [];
const containerVariants = {
hidden: { opacity: 0, y: 50 },
@ -289,10 +302,7 @@ export default function LandingPage() {
playsInline
className="absolute inset-0 w-full h-full object-cover"
>
<source
src="https://assets.mixkit.co/videos/36264/36264-720.mp4"
type="video/mp4"
/>
<source src="https://assets.mixkit.co/videos/36264/36264-720.mp4" type="video/mp4" />
<div
className="absolute inset-0"
style={{
@ -520,7 +530,6 @@ export default function LandingPage() {
</div>
</section>
{/* Partner Logos Section */}
<section className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
@ -531,9 +540,7 @@ export default function LandingPage() {
transition={{ duration: 0.6 }}
className="text-center mb-12"
>
<h3 className="text-2xl font-bold text-brand-navy mb-2">
{t('partners.title')}
</h3>
<h3 className="text-2xl font-bold text-brand-navy mb-2">{t('partners.title')}</h3>
<p className="text-gray-600">{t('partners.subtitle')}</p>
</motion.div>
@ -604,7 +611,9 @@ export default function LandingPage() {
transition={{ duration: 0.6, delay: 0.2 }}
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')}
</span>
<button
@ -619,7 +628,9 @@ export default function LandingPage() {
}`}
/>
</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')}
</span>
{billingYearly && (
@ -635,7 +646,7 @@ export default function LandingPage() {
animate={isPricingInView ? 'visible' : 'hidden'}
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 planDescription = t(`pricing.plans.${plan.key}.description` 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="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 className={`w-1.5 h-1.5 rounded-full bg-gradient-to-r ${plan.accentColor}`} />
<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
className={`w-1.5 h-1.5 rounded-full bg-gradient-to-r ${plan.accentColor}`}
/>
{planName}
</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}
</h3>
</div>
@ -685,34 +702,48 @@ export default function LandingPage() {
<div className="mb-6">
{plan.monthlyPrice === null ? (
<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')}
</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')}
</p>
</div>
) : plan.monthlyPrice === 0 ? (
<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')}
</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')}
</p>
</div>
) : (
<div>
<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}
</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')}
</span>
</div>
{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', {
price: numberFormat.format(plan.yearlyPrice ?? 0),
})}
@ -726,18 +757,30 @@ export default function LandingPage() {
)}
</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">
<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 className="flex items-center gap-2">
<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 className="flex items-center gap-2">
<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 })}
</span>
</div>
@ -747,15 +790,25 @@ export default function LandingPage() {
{planFeatures.map((feature, featureIndex) => (
<li key={featureIndex} className="flex items-start gap-2.5">
{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 ${
feature.included
? plan.highlighted ? 'text-white/90' : 'text-gray-700'
: plan.highlighted ? 'text-white/30' : 'text-gray-400'
}`}>
<span
className={`text-sm ${
feature.included
? plan.highlighted
? 'text-white/90'
: 'text-gray-700'
: plan.highlighted
? 'text-white/30'
: 'text-gray-400'
}`}
>
{t(`pricing.features.${feature.key}` as any)}
</span>
</li>
@ -768,8 +821,8 @@ export default function LandingPage() {
plan.highlighted
? 'bg-brand-turquoise text-white hover:bg-brand-turquoise/90 shadow-lg shadow-brand-turquoise/30 hover:shadow-xl'
: plan.key === 'bronze'
? '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-gray-100 text-brand-navy hover:bg-gray-200'
: 'bg-brand-navy text-white hover:bg-brand-navy/90 shadow-md hover:shadow-lg'
}`}
>
{planCta}
@ -786,9 +839,7 @@ export default function LandingPage() {
transition={{ duration: 0.8, delay: 0.5 }}
className="mt-12 text-center space-y-2"
>
<p className="text-gray-600 text-sm">
{t('pricing.noCommitment')}
</p>
<p className="text-gray-600 text-sm">{t('pricing.noCommitment')}</p>
<p className="text-sm text-gray-500">
{t('pricing.questions')}{' '}
<Link href="/contact" className="text-brand-turquoise font-medium hover:underline">
@ -816,10 +867,10 @@ export default function LandingPage() {
transition={{ duration: 0.8 }}
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-4">{t('howItWorks.title')}</h2>
<p className="text-xl text-white/80 max-w-2xl mx-auto">
{t('howItWorks.subtitle')}
</p>
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-4">
{t('howItWorks.title')}
</h2>
<p className="text-xl text-white/80 max-w-2xl mx-auto">{t('howItWorks.subtitle')}</p>
</motion.div>
<div className="relative">
@ -886,9 +937,7 @@ export default function LandingPage() {
<span>{tCommon('tryNow')}</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
<p className="mt-3 text-white/50 text-sm">
{t('howItWorks.ctaHint')}
</p>
<p className="mt-3 text-white/50 text-sm">{t('howItWorks.ctaHint')}</p>
</motion.div>
</div>
</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">
{t('cta.title')}
</h2>
<p className="text-base sm:text-xl text-gray-600 mb-8 sm:mb-10">
{t('cta.subtitle')}
</p>
<p className="text-base sm:text-xl text-gray-600 mb-8 sm:mb-10">{t('cta.subtitle')}</p>
</motion.div>
<motion.div

View File

@ -106,7 +106,10 @@ export default function PressPage() {
<LandingHeader activePage="press" />
{/* 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 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" />
@ -203,9 +206,7 @@ export default function PressPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('releasesTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('releasesSubtitle')}
</p>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('releasesSubtitle')}</p>
</motion.div>
<motion.div
@ -214,7 +215,7 @@ export default function PressPage() {
animate={isNewsInView ? 'visible' : 'hidden'}
className="space-y-6"
>
{RELEASES.map((release) => (
{RELEASES.map(release => (
<motion.div
key={release.id}
variants={itemVariants}
@ -272,9 +273,7 @@ export default function PressPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('coverageTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('coverageSubtitle')}
</p>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('coverageSubtitle')}</p>
</motion.div>
<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">
{t(`coverage.${article.key}.title`)}
</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>
</motion.a>
@ -321,12 +322,8 @@ export default function PressPage() {
transition={{ duration: 0.8 }}
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('kitTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('kitSubtitle')}
</p>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('kitTitle')}</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('kitSubtitle')}</p>
</motion.div>
<motion.div
@ -335,7 +332,7 @@ export default function PressPage() {
animate={isResourcesInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-3 gap-8"
>
{KIT_ITEMS.map((item) => {
{KIT_ITEMS.map(item => {
const IconComponent = item.icon;
return (
<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">
<IconComponent className="w-8 h-8 text-brand-turquoise" />
</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>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">{t(`kit.${item.key}.format`)}</span>
@ -367,7 +366,10 @@ export default function PressPage() {
</section>
{/* 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">
<motion.div
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">
{t('milestonesTitle')}
</h2>
<p className="text-xl text-white/80 max-w-2xl mx-auto">
{t('milestonesSubtitle')}
</p>
<p className="text-xl text-white/80 max-w-2xl mx-auto">{t('milestonesSubtitle')}</p>
</motion.div>
<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">
<IconComponent className="w-10 h-10 text-white" />
</div>
<div className="text-2xl font-bold text-brand-turquoise mb-1">{milestone.key}</div>
<div className="text-white/80 max-w-[150px]">{t(`milestones.${milestone.key}` as any)}</div>
<div className="text-2xl font-bold text-brand-turquoise mb-1">
{milestone.key}
</div>
<div className="text-white/80 max-w-[150px]">
{t(`milestones.${milestone.key}` as any)}
</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">
{t('contact.title')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('contact.body')}
</p>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('contact.body')}</p>
</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>
<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="flex items-center space-x-4">
<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>
<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="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" />
</div>
<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>
<p className="text-gray-600 text-sm">
{t('contact.responsibleBio')}
</p>
<div className="text-brand-turquoise font-medium mb-2">
{t('contact.responsibleRole')}
</div>
<p className="text-gray-600 text-sm">{t('contact.responsibleBio')}</p>
</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">
<Quote className="w-8 h-8 text-brand-turquoise flex-shrink-0" />
<div>
<p className="text-gray-600 italic mb-4">
&ldquo;{t('contact.quote')}&rdquo;
</p>
<p className="text-gray-600 italic mb-4">&ldquo;{t('contact.quote')}&rdquo;</p>
<div className="text-sm">
<span className="font-bold text-brand-navy">Jean-Pierre Durand</span>
<span className="text-gray-500">{t('contact.quoteRole')}</span>

View File

@ -177,7 +177,9 @@ export default function PricingPage() {
{/* Billing toggle */}
<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')}
</span>
<button
@ -192,7 +194,9 @@ export default function PricingPage() {
}`}
/>
</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')}
</span>
{billing === 'yearly' && (
@ -206,7 +210,7 @@ export default function PricingPage() {
{/* Plans grid */}
<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">
{PLANS.map((plan) => (
{PLANS.map(plan => (
<div
key={plan.key}
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">
<h3 className="text-xl font-bold text-gray-900">{t(`plans.${plan.key}.name`)}</h3>
{plan.badge && (
<Shield className={`w-5 h-5 ${
plan.badge === 'silver' ? 'text-slate-500' :
plan.badge === 'gold' ? 'text-yellow-500' :
'text-purple-500'
}`} />
<Shield
className={`w-5 h-5 ${
plan.badge === 'silver'
? 'text-slate-500'
: plan.badge === 'gold'
? 'text-yellow-500'
: 'text-purple-500'
}`}
/>
)}
</div>
@ -249,7 +257,9 @@ export default function PricingPage() {
{billing === 'monthly'
? formatPrice(plan.monthlyPrice)
: 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>
{billing === 'yearly' && (
<p className="text-sm text-gray-500 mt-1">
@ -284,7 +294,7 @@ export default function PricingPage() {
{/* Features */}
<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">
{feature.included ? (
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />

View File

@ -3,12 +3,22 @@
import { useRef } from 'react';
import { motion, useInView } from 'framer-motion';
import { useTranslations } from 'next-intl';
import { Shield, Eye, Lock, UserCheck, Database, Globe, Mail, Calendar, type LucideIcon } from 'lucide-react';
import {
Shield,
Eye,
Lock,
UserCheck,
Database,
Globe,
Mail,
Calendar,
type LucideIcon,
} from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout';
const SECTION_KEYS = ['data', 'use', 'protection', 'rights', 'transfers', 'retention'] as const;
const ICONS: Record<typeof SECTION_KEYS[number], LucideIcon> = {
const ICONS: Record<(typeof SECTION_KEYS)[number], LucideIcon> = {
data: Database,
use: Eye,
protection: Lock,
@ -18,9 +28,9 @@ const ICONS: Record<typeof SECTION_KEYS[number], LucideIcon> = {
};
function renderInlineBold(content: string) {
return content.split('**').map((part, i) =>
i % 2 === 1 ? <strong key={i}>{part}</strong> : part
);
return content
.split('**')
.map((part, i) => (i % 2 === 1 ? <strong key={i}>{part}</strong> : part));
}
export default function PrivacyPage() {
@ -58,7 +68,10 @@ export default function PrivacyPage() {
<LandingHeader />
{/* 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 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" />
@ -116,7 +129,7 @@ export default function PrivacyPage() {
animate={isContentInView ? 'visible' : 'hidden'}
className="space-y-12"
>
{SECTION_KEYS.map((key) => {
{SECTION_KEYS.map(key => {
const IconComponent = ICONS[key];
return (
<motion.div

View File

@ -64,7 +64,8 @@ function RegisterPageContent() {
const validateStep1 = (): string | null => {
if (!firstName.trim() || firstName.trim().length < 2) return t('fieldErrors.firstNameMin');
if (!lastName.trim() || lastName.trim().length < 2) return t('fieldErrors.lastNameMin');
if (!email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return t('fieldErrors.emailInvalid');
if (!email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
return t('fieldErrors.emailInvalid');
if (password.length < 12) return t('fieldErrors.passwordMin');
if (password !== confirmPassword) return t('fieldErrors.passwordsMismatch');
return null;
@ -148,65 +149,128 @@ function RegisterPageContent() {
<h2 className="text-display-sm mb-6 text-white">
{invitation ? t('sidePanel.titleInvitation') : t('sidePanel.titleDefault')}
</h2>
<p className="text-body-lg text-neutral-200 mb-12">
{t('sidePanel.description')}
</p>
<p className="text-body-lg text-neutral-200 mb-12">{t('sidePanel.description')}</p>
<div className="space-y-6">
<div className="flex items-start">
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
<svg
className="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div className="ml-4">
<h3 className="text-h5 mb-1 text-white">{t('sidePanel.features.trial.title')}</h3>
<p className="text-body-sm text-neutral-300">{t('sidePanel.features.trial.description')}</p>
<p className="text-body-sm text-neutral-300">
{t('sidePanel.features.trial.description')}
</p>
</div>
</div>
<div className="flex items-start">
<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">
<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
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>
</div>
<div className="ml-4">
<h3 className="text-h5 mb-1 text-white">{t('sidePanel.features.security.title')}</h3>
<p className="text-body-sm text-neutral-300">{t('sidePanel.features.security.description')}</p>
<h3 className="text-h5 mb-1 text-white">
{t('sidePanel.features.security.title')}
</h3>
<p className="text-body-sm text-neutral-300">
{t('sidePanel.features.security.description')}
</p>
</div>
</div>
<div className="flex items-start">
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
<svg
className="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</div>
<div className="ml-4">
<h3 className="text-h5 mb-1 text-white">{t('sidePanel.features.support.title')}</h3>
<p className="text-body-sm text-neutral-300">{t('sidePanel.features.support.description')}</p>
<p className="text-body-sm text-neutral-300">
{t('sidePanel.features.support.description')}
</p>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-8 mt-12 pt-12 border-t border-neutral-700">
<div>
<div className="text-display-sm text-brand-turquoise">2k+</div>
<div className="text-body-sm text-neutral-300 mt-1">{t('sidePanel.stats.companies')}</div>
<div className="text-body-sm text-neutral-300 mt-1">
{t('sidePanel.stats.companies')}
</div>
</div>
<div>
<div className="text-display-sm text-brand-turquoise">150+</div>
<div className="text-body-sm text-neutral-300 mt-1">{t('sidePanel.stats.countries')}</div>
<div className="text-body-sm text-neutral-300 mt-1">
{t('sidePanel.stats.countries')}
</div>
</div>
<div>
<div className="text-display-sm text-brand-turquoise">24/7</div>
<div className="text-body-sm text-neutral-300 mt-1">{t('sidePanel.stats.support')}</div>
<div className="text-body-sm text-neutral-300 mt-1">
{t('sidePanel.stats.support')}
</div>
</div>
</div>
</div>
</div>
<div className="absolute bottom-0 right-0 opacity-10">
<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 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" />
<circle
cx="200"
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>
</div>
</div>
@ -233,27 +297,49 @@ function RegisterPageContent() {
<div className="mb-8">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors ${
step >= 1 ? 'bg-brand-navy text-white' : 'bg-neutral-100 text-neutral-400'
}`}>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors ${
step >= 1 ? 'bg-brand-navy text-white' : 'bg-neutral-100 text-neutral-400'
}`}
>
{step > 1 ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M5 13l4 4L19 7"
/>
</svg>
) : '1'}
) : (
'1'
)}
</div>
<span className={`text-body-sm font-medium ${step >= 1 ? 'text-brand-navy' : 'text-neutral-400'}`}>
<span
className={`text-body-sm font-medium ${step >= 1 ? 'text-brand-navy' : 'text-neutral-400'}`}
>
{t('stepAccount')}
</span>
</div>
<div className={`flex-1 h-0.5 transition-colors ${step >= 2 ? 'bg-brand-navy' : 'bg-neutral-200'}`} />
<div
className={`flex-1 h-0.5 transition-colors ${step >= 2 ? 'bg-brand-navy' : 'bg-neutral-200'}`}
/>
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors ${
step >= 2 ? 'bg-brand-navy text-white' : 'bg-neutral-100 text-neutral-400'
}`}>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors ${
step >= 2 ? 'bg-brand-navy text-white' : 'bg-neutral-100 text-neutral-400'
}`}
>
2
</div>
<span className={`text-body-sm font-medium ${step >= 2 ? 'text-brand-navy' : 'text-neutral-400'}`}>
<span
className={`text-body-sm font-medium ${step >= 2 ? 'text-brand-navy' : 'text-neutral-400'}`}
>
{t('stepOrganization')}
</span>
</div>
@ -268,9 +354,7 @@ function RegisterPageContent() {
<>
<h1 className="text-h1 text-brand-navy mb-2">{t('invitationTitle')}</h1>
<div className="p-3 bg-green-50 border border-green-200 rounded-lg">
<p className="text-body-sm text-green-800">
{t('invitationValid')}
</p>
<p className="text-body-sm text-green-800">{t('invitationValid')}</p>
</div>
</>
) : step === 1 ? (
@ -288,8 +372,18 @@ function RegisterPageContent() {
{error && (
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
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>
<p className="text-body-sm text-red-800">{error}</p>
</div>
@ -299,7 +393,9 @@ function RegisterPageContent() {
<form onSubmit={handleStep1} className="space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="label">{t('firstNameLabel')}</label>
<label htmlFor="firstName" className="label">
{t('firstNameLabel')}
</label>
<input
id="firstName"
type="text"
@ -312,7 +408,9 @@ function RegisterPageContent() {
/>
</div>
<div>
<label htmlFor="lastName" className="label">{t('lastNameLabel')}</label>
<label htmlFor="lastName" className="label">
{t('lastNameLabel')}
</label>
<input
id="lastName"
type="text"
@ -327,7 +425,9 @@ function RegisterPageContent() {
</div>
<div>
<label htmlFor="email" className="label">{t('emailLabel')}</label>
<label htmlFor="email" className="label">
{t('emailLabel')}
</label>
<input
id="email"
type="email"
@ -342,7 +442,9 @@ function RegisterPageContent() {
</div>
<div>
<label htmlFor="password" className="label">{t('passwordLabel')}</label>
<label htmlFor="password" className="label">
{t('passwordLabel')}
</label>
<input
id="password"
type="password"
@ -358,7 +460,9 @@ function RegisterPageContent() {
</div>
<div>
<label htmlFor="confirmPassword" className="label">{t('confirmPasswordLabel')}</label>
<label htmlFor="confirmPassword" className="label">
{t('confirmPasswordLabel')}
</label>
<input
id="confirmPassword"
type="password"
@ -377,18 +481,18 @@ function RegisterPageContent() {
disabled={isLoading}
className="btn-primary w-full text-lg disabled:opacity-50 disabled:cursor-not-allowed mt-2"
>
{isLoading
? t('submitting')
: invitation
? t('submit')
: t('continue')}
{isLoading ? t('submitting') : invitation ? t('submit') : t('continue')}
</button>
<p className="text-body-xs text-center text-neutral-500">
{t('termsAccept')}{' '}
<Link href="/terms" className="link">{t('termsLink')}</Link>{' '}
<Link href="/terms" className="link">
{t('termsLink')}
</Link>{' '}
{t('termsAnd')}{' '}
<Link href="/privacy" className="link">{t('privacyLink')}</Link>
<Link href="/privacy" className="link">
{t('privacyLink')}
</Link>
</p>
</form>
)}
@ -396,7 +500,9 @@ function RegisterPageContent() {
{step === 2 && !invitation && (
<form onSubmit={handleStep2} className="space-y-5">
<div>
<label htmlFor="organizationName" className="label">{t('organizationNameLabel')} *</label>
<label htmlFor="organizationName" className="label">
{t('organizationNameLabel')} *
</label>
<input
id="organizationName"
type="text"
@ -410,7 +516,9 @@ function RegisterPageContent() {
</div>
<div>
<label htmlFor="organizationType" className="label">{t('organizationTypeLabel')} *</label>
<label htmlFor="organizationType" className="label">
{t('organizationTypeLabel')} *
</label>
<select
id="organizationType"
value={organizationType}
@ -426,7 +534,9 @@ function RegisterPageContent() {
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="siren" className="label">{t('sirenLabel')} *</label>
<label htmlFor="siren" className="label">
{t('sirenLabel')} *
</label>
<input
id="siren"
type="text"
@ -441,7 +551,10 @@ function RegisterPageContent() {
<p className="mt-1 text-body-xs text-neutral-500">{t('sirenHint')}</p>
</div>
<div>
<label htmlFor="siret" className="label">{t('siretLabel')} <span className="text-neutral-400 font-normal">{t('siretOptional')}</span></label>
<label htmlFor="siret" className="label">
{t('siretLabel')}{' '}
<span className="text-neutral-400 font-normal">{t('siretOptional')}</span>
</label>
<input
id="siret"
type="text"
@ -457,7 +570,9 @@ function RegisterPageContent() {
</div>
<div>
<label htmlFor="street" className="label">{t('streetLabel')} *</label>
<label htmlFor="street" className="label">
{t('streetLabel')} *
</label>
<input
id="street"
type="text"
@ -472,7 +587,9 @@ function RegisterPageContent() {
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="city" className="label">{t('cityLabel')} *</label>
<label htmlFor="city" className="label">
{t('cityLabel')} *
</label>
<input
id="city"
type="text"
@ -485,7 +602,9 @@ function RegisterPageContent() {
/>
</div>
<div>
<label htmlFor="postalCode" className="label">{t('postalCodeLabel')} *</label>
<label htmlFor="postalCode" className="label">
{t('postalCodeLabel')} *
</label>
<input
id="postalCode"
type="text"
@ -502,7 +621,8 @@ function RegisterPageContent() {
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="state" className="label">
{t('stateLabel')} <span className="text-neutral-400 font-normal">{t('stateOptional')}</span>
{t('stateLabel')}{' '}
<span className="text-neutral-400 font-normal">{t('stateOptional')}</span>
</label>
<input
id="state"
@ -515,7 +635,9 @@ function RegisterPageContent() {
/>
</div>
<div>
<label htmlFor="country" className="label">{t('countryLabel')} *</label>
<label htmlFor="country" className="label">
{t('countryLabel')} *
</label>
<input
id="country"
type="text"
@ -534,7 +656,10 @@ function RegisterPageContent() {
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => { setStep(1); setError(''); }}
onClick={() => {
setStep(1);
setError('');
}}
disabled={isLoading}
className="btn-secondary flex-1 text-lg disabled:opacity-50"
>
@ -551,9 +676,13 @@ function RegisterPageContent() {
<p className="text-body-xs text-center text-neutral-500">
{t('termsAccept')}{' '}
<Link href="/terms" className="link">{t('termsLink')}</Link>{' '}
<Link href="/terms" className="link">
{t('termsLink')}
</Link>{' '}
{t('termsAnd')}{' '}
<Link href="/privacy" className="link">{t('privacyLink')}</Link>
<Link href="/privacy" className="link">
{t('privacyLink')}
</Link>
</p>
</form>
)}
@ -561,15 +690,23 @@ function RegisterPageContent() {
<div className="mt-8 text-center">
<p className="text-body text-neutral-600">
{t('hasAccount')}{' '}
<Link href="/login" className="link font-semibold">{t('login')}</Link>
<Link href="/login" className="link font-semibold">
{t('login')}
</Link>
</p>
</div>
<div className="mt-6 pt-6 border-t border-neutral-200">
<div className="flex flex-wrap justify-center gap-6 text-body-sm text-neutral-500">
<Link href="/contact" className="hover:text-accent transition-colors">{tFooter('contact')}</Link>
<Link href="/privacy" className="hover:text-accent transition-colors">{tFooter('privacy')}</Link>
<Link href="/terms" className="hover:text-accent transition-colors">{tFooter('terms')}</Link>
<Link href="/contact" className="hover:text-accent transition-colors">
{tFooter('contact')}
</Link>
<Link href="/privacy" className="hover:text-accent transition-colors">
{tFooter('privacy')}
</Link>
<Link href="/terms" className="hover:text-accent transition-colors">
{tFooter('terms')}
</Link>
</div>
</div>
</div>

View File

@ -79,16 +79,27 @@ function ResetPasswordContent() {
<>
<div className="mb-8">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-6">
<svg className="w-8 h-8 text-red-600" 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
className="w-8 h-8 text-red-600"
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>
</div>
<h1 className="text-h1 text-brand-navy mb-2">{t('invalidTokenTitle')}</h1>
<p className="text-body text-neutral-600">
{t('invalidTokenMessage')}
</p>
<p className="text-body text-neutral-600">{t('invalidTokenMessage')}</p>
</div>
<Link href="/forgot-password" className="btn-primary w-full text-center block text-lg">
<Link
href="/forgot-password"
className="btn-primary w-full text-center block text-lg"
>
{t('requestNew')}
</Link>
</>
@ -96,14 +107,22 @@ function ResetPasswordContent() {
<>
<div className="mb-8">
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
<svg
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="M5 13l4 4L19 7"
/>
</svg>
</div>
<h1 className="text-h1 text-brand-navy mb-2">{t('successTitle')}</h1>
<p className="text-body text-neutral-600">
{t('successMessage')}
</p>
<p className="text-body text-neutral-600">{t('successMessage')}</p>
</div>
<Link href="/login" className="btn-primary w-full text-center block text-lg">
{t('goToLogin')}
@ -113,15 +132,23 @@ function ResetPasswordContent() {
<>
<div className="mb-8">
<h1 className="text-h1 text-brand-navy mb-2">{t('title')}</h1>
<p className="text-body text-neutral-600">
{t('subtitle')}
</p>
<p className="text-body text-neutral-600">{t('subtitle')}</p>
</div>
{error && (
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
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>
<p className="text-body-sm text-red-800">{error}</p>
</div>
@ -173,9 +200,17 @@ function ResetPasswordContent() {
</form>
<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">
<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>
{t('backToLogin')}
</Link>
@ -204,14 +239,22 @@ function ResetPasswordContent() {
<div className="absolute inset-0 flex flex-col justify-center px-16 xl:px-24 text-white">
<div className="max-w-xl">
<h2 className="text-display-sm mb-6 text-white">{t('sidePanel.title')}</h2>
<p className="text-body-lg text-neutral-200 mb-12">
{t('sidePanel.description')}
</p>
<p className="text-body-lg text-neutral-200 mb-12">{t('sidePanel.description')}</p>
<div className="space-y-4">
{tips.map((tip) => (
{tips.map(tip => (
<div key={tip} className="flex items-center gap-3">
<svg className="w-5 h-5 text-brand-turquoise flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
<svg
className="w-5 h-5 text-brand-turquoise flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<p className="text-body-sm text-neutral-300">{tip}</p>
</div>
@ -221,9 +264,30 @@ function ResetPasswordContent() {
</div>
<div className="absolute bottom-0 right-0 opacity-10">
<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 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" />
<circle
cx="200"
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>
</div>
</div>

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