fix blog
This commit is contained in:
parent
f5eaa4e083
commit
3d65693395
840
apps/backend/apps/frontend/package-lock.json
generated
Normal file
840
apps/backend/apps/frontend/package-lock.json
generated
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/backend/apps/frontend/package.json
Normal file
15
apps/backend/apps/frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,114 +1,113 @@
|
|||||||
/**
|
/**
|
||||||
* Script pour créer un booking de test avec statut PENDING
|
* Script pour créer un booking de test avec statut PENDING
|
||||||
* Usage: node create-test-booking.js
|
* Usage: node create-test-booking.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { Client } = require('pg');
|
const { Client } = require('pg');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
async function createTestBooking() {
|
async function createTestBooking() {
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
host: process.env.DATABASE_HOST || 'localhost',
|
host: process.env.DATABASE_HOST || 'localhost',
|
||||||
port: parseInt(process.env.DATABASE_PORT || '5432'),
|
port: parseInt(process.env.DATABASE_PORT || '5432'),
|
||||||
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
||||||
user: process.env.DATABASE_USER || 'xpeditis',
|
user: process.env.DATABASE_USER || 'xpeditis',
|
||||||
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
console.log('✅ Connecté à la base de données');
|
console.log('✅ Connecté à la base de données');
|
||||||
|
|
||||||
const bookingId = uuidv4();
|
const bookingId = uuidv4();
|
||||||
const confirmationToken = uuidv4();
|
const confirmationToken = uuidv4();
|
||||||
const userId = '8cf7d5b3-d94f-44aa-bb5a-080002919dd1'; // User demo@xpeditis.com
|
const userId = '8cf7d5b3-d94f-44aa-bb5a-080002919dd1'; // User demo@xpeditis.com
|
||||||
const organizationId = '199fafa9-d26f-4cf9-9206-73432baa8f63';
|
const organizationId = '199fafa9-d26f-4cf9-9206-73432baa8f63';
|
||||||
|
|
||||||
// Create dummy documents in JSONB format
|
// Create dummy documents in JSONB format
|
||||||
const dummyDocuments = JSON.stringify([
|
const dummyDocuments = JSON.stringify([
|
||||||
{
|
{
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
type: 'BILL_OF_LADING',
|
type: 'BILL_OF_LADING',
|
||||||
fileName: 'bill-of-lading.pdf',
|
fileName: 'bill-of-lading.pdf',
|
||||||
filePath: 'https://dummy-storage.com/documents/bill-of-lading.pdf',
|
filePath: 'https://dummy-storage.com/documents/bill-of-lading.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
size: 102400, // 100KB
|
size: 102400, // 100KB
|
||||||
uploadedAt: new Date().toISOString(),
|
uploadedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
type: 'PACKING_LIST',
|
type: 'PACKING_LIST',
|
||||||
fileName: 'packing-list.pdf',
|
fileName: 'packing-list.pdf',
|
||||||
filePath: 'https://dummy-storage.com/documents/packing-list.pdf',
|
filePath: 'https://dummy-storage.com/documents/packing-list.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
size: 51200, // 50KB
|
size: 51200, // 50KB
|
||||||
uploadedAt: new Date().toISOString(),
|
uploadedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
type: 'COMMERCIAL_INVOICE',
|
type: 'COMMERCIAL_INVOICE',
|
||||||
fileName: 'commercial-invoice.pdf',
|
fileName: 'commercial-invoice.pdf',
|
||||||
filePath: 'https://dummy-storage.com/documents/commercial-invoice.pdf',
|
filePath: 'https://dummy-storage.com/documents/commercial-invoice.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
size: 76800, // 75KB
|
size: 76800, // 75KB
|
||||||
uploadedAt: new Date().toISOString(),
|
uploadedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO csv_bookings (
|
INSERT INTO csv_bookings (
|
||||||
id, user_id, organization_id, carrier_name, carrier_email,
|
id, user_id, organization_id, carrier_name, carrier_email,
|
||||||
origin, destination, volume_cbm, weight_kg, pallet_count,
|
origin, destination, volume_cbm, weight_kg, pallet_count,
|
||||||
price_usd, price_eur, primary_currency, transit_days, container_type,
|
price_usd, price_eur, primary_currency, transit_days, container_type,
|
||||||
status, confirmation_token, requested_at, notes, documents
|
status, confirmation_token, requested_at, notes, documents
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||||
$11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19
|
$11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19
|
||||||
) RETURNING id, confirmation_token;
|
) RETURNING id, confirmation_token;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const values = [
|
const values = [
|
||||||
bookingId,
|
bookingId,
|
||||||
userId,
|
userId,
|
||||||
organizationId,
|
organizationId,
|
||||||
'Test Carrier',
|
'Test Carrier',
|
||||||
'test@carrier.com',
|
'test@carrier.com',
|
||||||
'NLRTM', // Rotterdam
|
'NLRTM', // Rotterdam
|
||||||
'USNYC', // New York
|
'USNYC', // New York
|
||||||
25.5, // volume_cbm
|
25.5, // volume_cbm
|
||||||
3500, // weight_kg
|
3500, // weight_kg
|
||||||
10, // pallet_count
|
10, // pallet_count
|
||||||
1850.50, // price_usd
|
1850.5, // price_usd
|
||||||
1665.45, // price_eur
|
1665.45, // price_eur
|
||||||
'USD', // primary_currency
|
'USD', // primary_currency
|
||||||
28, // transit_days
|
28, // transit_days
|
||||||
'LCL', // container_type
|
'LCL', // container_type
|
||||||
'PENDING', // status - IMPORTANT!
|
'PENDING', // status - IMPORTANT!
|
||||||
confirmationToken,
|
confirmationToken,
|
||||||
'Test booking created by script',
|
'Test booking created by script',
|
||||||
dummyDocuments, // documents JSONB
|
dummyDocuments, // documents JSONB
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = await client.query(query, values);
|
const result = await client.query(query, values);
|
||||||
|
|
||||||
console.log('\n🎉 Booking de test créé avec succès!');
|
console.log('\n🎉 Booking de test créé avec succès!');
|
||||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
console.log(`📦 Booking ID: ${bookingId}`);
|
console.log(`📦 Booking ID: ${bookingId}`);
|
||||||
console.log(`🔑 Token: ${confirmationToken}`);
|
console.log(`🔑 Token: ${confirmationToken}`);
|
||||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||||
console.log('🔗 URLs de test:');
|
console.log('🔗 URLs de test:');
|
||||||
console.log(` Accept: http://localhost:3000/carrier/accept/${confirmationToken}`);
|
console.log(` Accept: http://localhost:3000/carrier/accept/${confirmationToken}`);
|
||||||
console.log(` Reject: http://localhost:3000/carrier/reject/${confirmationToken}`);
|
console.log(` Reject: http://localhost:3000/carrier/reject/${confirmationToken}`);
|
||||||
console.log('\n📧 URL API (pour curl):');
|
console.log('\n📧 URL API (pour curl):');
|
||||||
console.log(` curl http://localhost:4000/api/v1/csv-bookings/accept/${confirmationToken}`);
|
console.log(` curl http://localhost:4000/api/v1/csv-bookings/accept/${confirmationToken}`);
|
||||||
console.log('\n✅ Ce booking est en statut PENDING et peut être accepté/refusé.\n');
|
console.log('\n✅ Ce booking est en statut PENDING et peut être accepté/refusé.\n');
|
||||||
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error('❌ Erreur:', error.message);
|
||||||
console.error('❌ Erreur:', error.message);
|
console.error(error);
|
||||||
console.error(error);
|
} finally {
|
||||||
} finally {
|
await client.end();
|
||||||
await client.end();
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
createTestBooking();
|
||||||
createTestBooking();
|
|
||||||
|
|||||||
@ -1,321 +1,324 @@
|
|||||||
/**
|
/**
|
||||||
* Script de debug pour tester le flux complet d'envoi d'email
|
* Script de debug pour tester le flux complet d'envoi d'email
|
||||||
*
|
*
|
||||||
* Ce script teste:
|
* Ce script teste:
|
||||||
* 1. Connexion SMTP
|
* 1. Connexion SMTP
|
||||||
* 2. Envoi d'un email simple
|
* 2. Envoi d'un email simple
|
||||||
* 3. Envoi avec le template complet
|
* 3. Envoi avec le template complet
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
|
|
||||||
console.log('\n🔍 DEBUG - Flux d\'envoi d\'email transporteur\n');
|
console.log("\n🔍 DEBUG - Flux d'envoi d'email transporteur\n");
|
||||||
console.log('='.repeat(60));
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
// 1. Afficher la configuration
|
// 1. Afficher la configuration
|
||||||
console.log('\n📋 CONFIGURATION ACTUELLE:');
|
console.log('\n📋 CONFIGURATION ACTUELLE:');
|
||||||
console.log('----------------------------');
|
console.log('----------------------------');
|
||||||
console.log('SMTP_HOST:', process.env.SMTP_HOST);
|
console.log('SMTP_HOST:', process.env.SMTP_HOST);
|
||||||
console.log('SMTP_PORT:', process.env.SMTP_PORT);
|
console.log('SMTP_PORT:', process.env.SMTP_PORT);
|
||||||
console.log('SMTP_SECURE:', process.env.SMTP_SECURE);
|
console.log('SMTP_SECURE:', process.env.SMTP_SECURE);
|
||||||
console.log('SMTP_USER:', process.env.SMTP_USER);
|
console.log('SMTP_USER:', process.env.SMTP_USER);
|
||||||
console.log('SMTP_PASS:', process.env.SMTP_PASS ? '***' + process.env.SMTP_PASS.slice(-4) : 'NON DÉFINI');
|
console.log(
|
||||||
console.log('SMTP_FROM:', process.env.SMTP_FROM);
|
'SMTP_PASS:',
|
||||||
console.log('APP_URL:', process.env.APP_URL);
|
process.env.SMTP_PASS ? '***' + process.env.SMTP_PASS.slice(-4) : 'NON DÉFINI'
|
||||||
|
);
|
||||||
// 2. Vérifier les variables requises
|
console.log('SMTP_FROM:', process.env.SMTP_FROM);
|
||||||
console.log('\n✅ VÉRIFICATION DES VARIABLES:');
|
console.log('APP_URL:', process.env.APP_URL);
|
||||||
console.log('--------------------------------');
|
|
||||||
const requiredVars = ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS'];
|
// 2. Vérifier les variables requises
|
||||||
const missing = requiredVars.filter(v => !process.env[v]);
|
console.log('\n✅ VÉRIFICATION DES VARIABLES:');
|
||||||
if (missing.length > 0) {
|
console.log('--------------------------------');
|
||||||
console.error('❌ Variables manquantes:', missing.join(', '));
|
const requiredVars = ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS'];
|
||||||
process.exit(1);
|
const missing = requiredVars.filter(v => !process.env[v]);
|
||||||
} else {
|
if (missing.length > 0) {
|
||||||
console.log('✅ Toutes les variables requises sont présentes');
|
console.error('❌ Variables manquantes:', missing.join(', '));
|
||||||
}
|
process.exit(1);
|
||||||
|
} else {
|
||||||
// 3. Créer le transporter avec la même configuration que le backend
|
console.log('✅ Toutes les variables requises sont présentes');
|
||||||
console.log('\n🔧 CRÉATION DU TRANSPORTER:');
|
}
|
||||||
console.log('----------------------------');
|
|
||||||
|
// 3. Créer le transporter avec la même configuration que le backend
|
||||||
const host = process.env.SMTP_HOST;
|
console.log('\n🔧 CRÉATION DU TRANSPORTER:');
|
||||||
const port = parseInt(process.env.SMTP_PORT);
|
console.log('----------------------------');
|
||||||
const user = process.env.SMTP_USER;
|
|
||||||
const pass = process.env.SMTP_PASS;
|
const host = process.env.SMTP_HOST;
|
||||||
const secure = process.env.SMTP_SECURE === 'true';
|
const port = parseInt(process.env.SMTP_PORT);
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
// Même logique que dans email.adapter.ts
|
const pass = process.env.SMTP_PASS;
|
||||||
const useDirectIP = host.includes('mailtrap.io');
|
const secure = process.env.SMTP_SECURE === 'true';
|
||||||
const actualHost = useDirectIP ? '3.209.246.195' : host;
|
|
||||||
const serverName = useDirectIP ? 'smtp.mailtrap.io' : host;
|
// Même logique que dans email.adapter.ts
|
||||||
|
const useDirectIP = host.includes('mailtrap.io');
|
||||||
console.log('Configuration détectée:');
|
const actualHost = useDirectIP ? '3.209.246.195' : host;
|
||||||
console.log(' Host original:', host);
|
const serverName = useDirectIP ? 'smtp.mailtrap.io' : host;
|
||||||
console.log(' Utilise IP directe:', useDirectIP);
|
|
||||||
console.log(' Host réel:', actualHost);
|
console.log('Configuration détectée:');
|
||||||
console.log(' Server name (TLS):', serverName);
|
console.log(' Host original:', host);
|
||||||
console.log(' Port:', port);
|
console.log(' Utilise IP directe:', useDirectIP);
|
||||||
console.log(' Secure:', secure);
|
console.log(' Host réel:', actualHost);
|
||||||
|
console.log(' Server name (TLS):', serverName);
|
||||||
const transporter = nodemailer.createTransport({
|
console.log(' Port:', port);
|
||||||
host: actualHost,
|
console.log(' Secure:', secure);
|
||||||
port,
|
|
||||||
secure,
|
const transporter = nodemailer.createTransport({
|
||||||
auth: {
|
host: actualHost,
|
||||||
user,
|
port,
|
||||||
pass,
|
secure,
|
||||||
},
|
auth: {
|
||||||
tls: {
|
user,
|
||||||
rejectUnauthorized: false,
|
pass,
|
||||||
servername: serverName,
|
},
|
||||||
},
|
tls: {
|
||||||
connectionTimeout: 10000,
|
rejectUnauthorized: false,
|
||||||
greetingTimeout: 10000,
|
servername: serverName,
|
||||||
socketTimeout: 30000,
|
},
|
||||||
dnsTimeout: 10000,
|
connectionTimeout: 10000,
|
||||||
});
|
greetingTimeout: 10000,
|
||||||
|
socketTimeout: 30000,
|
||||||
// 4. Tester la connexion
|
dnsTimeout: 10000,
|
||||||
console.log('\n🔌 TEST DE CONNEXION SMTP:');
|
});
|
||||||
console.log('---------------------------');
|
|
||||||
|
// 4. Tester la connexion
|
||||||
async function testConnection() {
|
console.log('\n🔌 TEST DE CONNEXION SMTP:');
|
||||||
try {
|
console.log('---------------------------');
|
||||||
console.log('Vérification de la connexion...');
|
|
||||||
await transporter.verify();
|
async function testConnection() {
|
||||||
console.log('✅ Connexion SMTP réussie!');
|
try {
|
||||||
return true;
|
console.log('Vérification de la connexion...');
|
||||||
} catch (error) {
|
await transporter.verify();
|
||||||
console.error('❌ Échec de la connexion SMTP:');
|
console.log('✅ Connexion SMTP réussie!');
|
||||||
console.error(' Message:', error.message);
|
return true;
|
||||||
console.error(' Code:', error.code);
|
} catch (error) {
|
||||||
console.error(' Command:', error.command);
|
console.error('❌ Échec de la connexion SMTP:');
|
||||||
if (error.stack) {
|
console.error(' Message:', error.message);
|
||||||
console.error(' Stack:', error.stack.substring(0, 200) + '...');
|
console.error(' Code:', error.code);
|
||||||
}
|
console.error(' Command:', error.command);
|
||||||
return false;
|
if (error.stack) {
|
||||||
}
|
console.error(' Stack:', error.stack.substring(0, 200) + '...');
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
// 5. Envoyer un email de test simple
|
}
|
||||||
async function sendSimpleEmail() {
|
}
|
||||||
console.log('\n📧 TEST 1: Email simple');
|
|
||||||
console.log('------------------------');
|
// 5. Envoyer un email de test simple
|
||||||
|
async function sendSimpleEmail() {
|
||||||
try {
|
console.log('\n📧 TEST 1: Email simple');
|
||||||
const info = await transporter.sendMail({
|
console.log('------------------------');
|
||||||
from: process.env.SMTP_FROM || 'noreply@xpeditis.com',
|
|
||||||
to: 'test@example.com',
|
try {
|
||||||
subject: 'Test Simple - ' + new Date().toISOString(),
|
const info = await transporter.sendMail({
|
||||||
text: 'Ceci est un test simple',
|
from: process.env.SMTP_FROM || 'noreply@xpeditis.com',
|
||||||
html: '<h1>Test Simple</h1><p>Ceci est un test simple</p>',
|
to: 'test@example.com',
|
||||||
});
|
subject: 'Test Simple - ' + new Date().toISOString(),
|
||||||
|
text: 'Ceci est un test simple',
|
||||||
console.log('✅ Email simple envoyé avec succès!');
|
html: '<h1>Test Simple</h1><p>Ceci est un test simple</p>',
|
||||||
console.log(' Message ID:', info.messageId);
|
});
|
||||||
console.log(' Response:', info.response);
|
|
||||||
console.log(' Accepted:', info.accepted);
|
console.log('✅ Email simple envoyé avec succès!');
|
||||||
console.log(' Rejected:', info.rejected);
|
console.log(' Message ID:', info.messageId);
|
||||||
return true;
|
console.log(' Response:', info.response);
|
||||||
} catch (error) {
|
console.log(' Accepted:', info.accepted);
|
||||||
console.error('❌ Échec d\'envoi email simple:');
|
console.log(' Rejected:', info.rejected);
|
||||||
console.error(' Message:', error.message);
|
return true;
|
||||||
console.error(' Code:', error.code);
|
} catch (error) {
|
||||||
return false;
|
console.error("❌ Échec d'envoi email simple:");
|
||||||
}
|
console.error(' Message:', error.message);
|
||||||
}
|
console.error(' Code:', error.code);
|
||||||
|
return false;
|
||||||
// 6. Envoyer un email avec le template transporteur complet
|
}
|
||||||
async function sendCarrierEmail() {
|
}
|
||||||
console.log('\n📧 TEST 2: Email transporteur avec template');
|
|
||||||
console.log('--------------------------------------------');
|
// 6. Envoyer un email avec le template transporteur complet
|
||||||
|
async function sendCarrierEmail() {
|
||||||
const bookingData = {
|
console.log('\n📧 TEST 2: Email transporteur avec template');
|
||||||
bookingId: 'TEST-' + Date.now(),
|
console.log('--------------------------------------------');
|
||||||
origin: 'FRPAR',
|
|
||||||
destination: 'USNYC',
|
const bookingData = {
|
||||||
volumeCBM: 15.5,
|
bookingId: 'TEST-' + Date.now(),
|
||||||
weightKG: 1200,
|
origin: 'FRPAR',
|
||||||
palletCount: 6,
|
destination: 'USNYC',
|
||||||
priceUSD: 2500,
|
volumeCBM: 15.5,
|
||||||
priceEUR: 2250,
|
weightKG: 1200,
|
||||||
primaryCurrency: 'USD',
|
palletCount: 6,
|
||||||
transitDays: 18,
|
priceUSD: 2500,
|
||||||
containerType: '40FT',
|
priceEUR: 2250,
|
||||||
documents: [
|
primaryCurrency: 'USD',
|
||||||
{ type: 'Bill of Lading', fileName: 'bol-test.pdf' },
|
transitDays: 18,
|
||||||
{ type: 'Packing List', fileName: 'packing-test.pdf' },
|
containerType: '40FT',
|
||||||
{ type: 'Commercial Invoice', fileName: 'invoice-test.pdf' },
|
documents: [
|
||||||
],
|
{ type: 'Bill of Lading', fileName: 'bol-test.pdf' },
|
||||||
};
|
{ type: 'Packing List', fileName: 'packing-test.pdf' },
|
||||||
|
{ type: 'Commercial Invoice', fileName: 'invoice-test.pdf' },
|
||||||
const baseUrl = process.env.APP_URL || 'http://localhost:3000';
|
],
|
||||||
const acceptUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/accept`;
|
};
|
||||||
const rejectUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/reject`;
|
|
||||||
|
const baseUrl = process.env.APP_URL || 'http://localhost:3000';
|
||||||
// Template HTML (version simplifiée pour le test)
|
const acceptUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/accept`;
|
||||||
const htmlTemplate = `
|
const rejectUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/reject`;
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
// Template HTML (version simplifiée pour le test)
|
||||||
<head>
|
const htmlTemplate = `
|
||||||
<meta charset="UTF-8">
|
<!DOCTYPE html>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<html lang="fr">
|
||||||
<title>Nouvelle demande de réservation</title>
|
<head>
|
||||||
</head>
|
<meta charset="UTF-8">
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f6f8;">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<div style="max-width: 600px; margin: 20px auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);">
|
<title>Nouvelle demande de réservation</title>
|
||||||
<div style="background: linear-gradient(135deg, #045a8d, #00bcd4); color: #ffffff; padding: 30px 20px; text-align: center;">
|
</head>
|
||||||
<h1 style="margin: 0; font-size: 28px;">🚢 Nouvelle demande de réservation</h1>
|
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f6f8;">
|
||||||
<p style="margin: 5px 0 0; font-size: 14px;">Xpeditis</p>
|
<div style="max-width: 600px; margin: 20px auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);">
|
||||||
</div>
|
<div style="background: linear-gradient(135deg, #045a8d, #00bcd4); color: #ffffff; padding: 30px 20px; text-align: center;">
|
||||||
<div style="padding: 30px 20px;">
|
<h1 style="margin: 0; font-size: 28px;">🚢 Nouvelle demande de réservation</h1>
|
||||||
<p style="font-size: 16px;">Bonjour,</p>
|
<p style="margin: 5px 0 0; font-size: 14px;">Xpeditis</p>
|
||||||
<p>Vous avez reçu une nouvelle demande de réservation via Xpeditis.</p>
|
</div>
|
||||||
|
<div style="padding: 30px 20px;">
|
||||||
<h2 style="color: #045a8d; border-bottom: 2px solid #00bcd4; padding-bottom: 8px;">📋 Détails du transport</h2>
|
<p style="font-size: 16px;">Bonjour,</p>
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
<p>Vous avez reçu une nouvelle demande de réservation via Xpeditis.</p>
|
||||||
<tr style="border-bottom: 1px solid #e0e0e0;">
|
|
||||||
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Route</td>
|
<h2 style="color: #045a8d; border-bottom: 2px solid #00bcd4; padding-bottom: 8px;">📋 Détails du transport</h2>
|
||||||
<td style="padding: 12px;">${bookingData.origin} → ${bookingData.destination}</td>
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
</tr>
|
<tr style="border-bottom: 1px solid #e0e0e0;">
|
||||||
<tr style="border-bottom: 1px solid #e0e0e0;">
|
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Route</td>
|
||||||
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Volume</td>
|
<td style="padding: 12px;">${bookingData.origin} → ${bookingData.destination}</td>
|
||||||
<td style="padding: 12px;">${bookingData.volumeCBM} CBM</td>
|
</tr>
|
||||||
</tr>
|
<tr style="border-bottom: 1px solid #e0e0e0;">
|
||||||
<tr style="border-bottom: 1px solid #e0e0e0;">
|
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Volume</td>
|
||||||
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Poids</td>
|
<td style="padding: 12px;">${bookingData.volumeCBM} CBM</td>
|
||||||
<td style="padding: 12px;">${bookingData.weightKG} kg</td>
|
</tr>
|
||||||
</tr>
|
<tr style="border-bottom: 1px solid #e0e0e0;">
|
||||||
<tr style="border-bottom: 1px solid #e0e0e0;">
|
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Poids</td>
|
||||||
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Prix</td>
|
<td style="padding: 12px;">${bookingData.weightKG} kg</td>
|
||||||
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #00aa00;">
|
</tr>
|
||||||
${bookingData.priceUSD} USD
|
<tr style="border-bottom: 1px solid #e0e0e0;">
|
||||||
</td>
|
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Prix</td>
|
||||||
</tr>
|
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #00aa00;">
|
||||||
</table>
|
${bookingData.priceUSD} USD
|
||||||
|
</td>
|
||||||
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 6px; margin: 20px 0;">
|
</tr>
|
||||||
<h3 style="margin-top: 0; color: #045a8d;">📄 Documents fournis</h3>
|
</table>
|
||||||
<ul style="list-style: none; padding: 0; margin: 10px 0 0;">
|
|
||||||
${bookingData.documents.map(doc => `<li style="padding: 8px 0;">📄 <strong>${doc.type}:</strong> ${doc.fileName}</li>`).join('')}
|
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 6px; margin: 20px 0;">
|
||||||
</ul>
|
<h3 style="margin-top: 0; color: #045a8d;">📄 Documents fournis</h3>
|
||||||
</div>
|
<ul style="list-style: none; padding: 0; margin: 10px 0 0;">
|
||||||
|
${bookingData.documents.map(doc => `<li style="padding: 8px 0;">📄 <strong>${doc.type}:</strong> ${doc.fileName}</li>`).join('')}
|
||||||
<div style="text-align: center; margin: 30px 0;">
|
</ul>
|
||||||
<p style="font-weight: bold; font-size: 16px;">Veuillez confirmer votre décision :</p>
|
</div>
|
||||||
<div style="margin: 15px 0;">
|
|
||||||
<a href="${acceptUrl}" style="display: inline-block; padding: 15px 30px; background-color: #00aa00; color: #ffffff; text-decoration: none; border-radius: 6px; margin: 0 5px; min-width: 200px;">✓ Accepter la demande</a>
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
<a href="${rejectUrl}" style="display: inline-block; padding: 15px 30px; background-color: #cc0000; color: #ffffff; text-decoration: none; border-radius: 6px; margin: 0 5px; min-width: 200px;">✗ Refuser la demande</a>
|
<p style="font-weight: bold; font-size: 16px;">Veuillez confirmer votre décision :</p>
|
||||||
</div>
|
<div style="margin: 15px 0;">
|
||||||
</div>
|
<a href="${acceptUrl}" style="display: inline-block; padding: 15px 30px; background-color: #00aa00; color: #ffffff; text-decoration: none; border-radius: 6px; margin: 0 5px; min-width: 200px;">✓ Accepter la demande</a>
|
||||||
|
<a href="${rejectUrl}" style="display: inline-block; padding: 15px 30px; background-color: #cc0000; color: #ffffff; text-decoration: none; border-radius: 6px; margin: 0 5px; min-width: 200px;">✗ Refuser la demande</a>
|
||||||
<div style="background-color: #fff8e1; border-left: 4px solid #f57c00; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
</div>
|
||||||
<p style="margin: 0; font-size: 14px; color: #666;">
|
</div>
|
||||||
<strong style="color: #f57c00;">⚠️ Important</strong><br>
|
|
||||||
Cette demande expire automatiquement dans <strong>7 jours</strong> si aucune action n'est prise.
|
<div style="background-color: #fff8e1; border-left: 4px solid #f57c00; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||||
</p>
|
<p style="margin: 0; font-size: 14px; color: #666;">
|
||||||
</div>
|
<strong style="color: #f57c00;">⚠️ Important</strong><br>
|
||||||
</div>
|
Cette demande expire automatiquement dans <strong>7 jours</strong> si aucune action n'est prise.
|
||||||
<div style="background-color: #f4f6f8; padding: 20px; text-align: center; font-size: 12px; color: #666;">
|
</p>
|
||||||
<p style="margin: 5px 0; font-weight: bold; color: #045a8d;">Référence de réservation : ${bookingData.bookingId}</p>
|
</div>
|
||||||
<p style="margin: 5px 0;">© 2025 Xpeditis. Tous droits réservés.</p>
|
</div>
|
||||||
<p style="margin: 5px 0;">Cet email a été envoyé automatiquement. Merci de ne pas y répondre directement.</p>
|
<div style="background-color: #f4f6f8; padding: 20px; text-align: center; font-size: 12px; color: #666;">
|
||||||
</div>
|
<p style="margin: 5px 0; font-weight: bold; color: #045a8d;">Référence de réservation : ${bookingData.bookingId}</p>
|
||||||
</div>
|
<p style="margin: 5px 0;">© 2025 Xpeditis. Tous droits réservés.</p>
|
||||||
</body>
|
<p style="margin: 5px 0;">Cet email a été envoyé automatiquement. Merci de ne pas y répondre directement.</p>
|
||||||
</html>
|
</div>
|
||||||
`;
|
</div>
|
||||||
|
</body>
|
||||||
try {
|
</html>
|
||||||
console.log('Données du booking:');
|
`;
|
||||||
console.log(' Booking ID:', bookingData.bookingId);
|
|
||||||
console.log(' Route:', bookingData.origin, '→', bookingData.destination);
|
try {
|
||||||
console.log(' Prix:', bookingData.priceUSD, 'USD');
|
console.log('Données du booking:');
|
||||||
console.log(' Accept URL:', acceptUrl);
|
console.log(' Booking ID:', bookingData.bookingId);
|
||||||
console.log(' Reject URL:', rejectUrl);
|
console.log(' Route:', bookingData.origin, '→', bookingData.destination);
|
||||||
console.log('\nEnvoi en cours...');
|
console.log(' Prix:', bookingData.priceUSD, 'USD');
|
||||||
|
console.log(' Accept URL:', acceptUrl);
|
||||||
const info = await transporter.sendMail({
|
console.log(' Reject URL:', rejectUrl);
|
||||||
from: process.env.SMTP_FROM || 'noreply@xpeditis.com',
|
console.log('\nEnvoi en cours...');
|
||||||
to: 'carrier@test.com',
|
|
||||||
subject: `Nouvelle demande de réservation - ${bookingData.origin} → ${bookingData.destination}`,
|
const info = await transporter.sendMail({
|
||||||
html: htmlTemplate,
|
from: process.env.SMTP_FROM || 'noreply@xpeditis.com',
|
||||||
});
|
to: 'carrier@test.com',
|
||||||
|
subject: `Nouvelle demande de réservation - ${bookingData.origin} → ${bookingData.destination}`,
|
||||||
console.log('\n✅ Email transporteur envoyé avec succès!');
|
html: htmlTemplate,
|
||||||
console.log(' Message ID:', info.messageId);
|
});
|
||||||
console.log(' Response:', info.response);
|
|
||||||
console.log(' Accepted:', info.accepted);
|
console.log('\n✅ Email transporteur envoyé avec succès!');
|
||||||
console.log(' Rejected:', info.rejected);
|
console.log(' Message ID:', info.messageId);
|
||||||
console.log('\n📬 Vérifiez votre inbox Mailtrap:');
|
console.log(' Response:', info.response);
|
||||||
console.log(' URL: https://mailtrap.io/inboxes');
|
console.log(' Accepted:', info.accepted);
|
||||||
console.log(' Sujet: Nouvelle demande de réservation - FRPAR → USNYC');
|
console.log(' Rejected:', info.rejected);
|
||||||
return true;
|
console.log('\n📬 Vérifiez votre inbox Mailtrap:');
|
||||||
} catch (error) {
|
console.log(' URL: https://mailtrap.io/inboxes');
|
||||||
console.error('\n❌ Échec d\'envoi email transporteur:');
|
console.log(' Sujet: Nouvelle demande de réservation - FRPAR → USNYC');
|
||||||
console.error(' Message:', error.message);
|
return true;
|
||||||
console.error(' Code:', error.code);
|
} catch (error) {
|
||||||
console.error(' ResponseCode:', error.responseCode);
|
console.error("\n❌ Échec d'envoi email transporteur:");
|
||||||
console.error(' Response:', error.response);
|
console.error(' Message:', error.message);
|
||||||
if (error.stack) {
|
console.error(' Code:', error.code);
|
||||||
console.error(' Stack:', error.stack.substring(0, 300));
|
console.error(' ResponseCode:', error.responseCode);
|
||||||
}
|
console.error(' Response:', error.response);
|
||||||
return false;
|
if (error.stack) {
|
||||||
}
|
console.error(' Stack:', error.stack.substring(0, 300));
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
// Exécuter tous les tests
|
}
|
||||||
async function runAllTests() {
|
}
|
||||||
console.log('\n🚀 DÉMARRAGE DES TESTS');
|
|
||||||
console.log('='.repeat(60));
|
// Exécuter tous les tests
|
||||||
|
async function runAllTests() {
|
||||||
// Test 1: Connexion
|
console.log('\n🚀 DÉMARRAGE DES TESTS');
|
||||||
const connectionOk = await testConnection();
|
console.log('='.repeat(60));
|
||||||
if (!connectionOk) {
|
|
||||||
console.log('\n❌ ARRÊT: La connexion SMTP a échoué');
|
// Test 1: Connexion
|
||||||
console.log(' Vérifiez vos credentials SMTP dans .env');
|
const connectionOk = await testConnection();
|
||||||
process.exit(1);
|
if (!connectionOk) {
|
||||||
}
|
console.log('\n❌ ARRÊT: La connexion SMTP a échoué');
|
||||||
|
console.log(' Vérifiez vos credentials SMTP dans .env');
|
||||||
// Test 2: Email simple
|
process.exit(1);
|
||||||
const simpleEmailOk = await sendSimpleEmail();
|
}
|
||||||
if (!simpleEmailOk) {
|
|
||||||
console.log('\n⚠️ L\'email simple a échoué, mais on continue...');
|
// Test 2: Email simple
|
||||||
}
|
const simpleEmailOk = await sendSimpleEmail();
|
||||||
|
if (!simpleEmailOk) {
|
||||||
// Test 3: Email transporteur
|
console.log("\n⚠️ L'email simple a échoué, mais on continue...");
|
||||||
const carrierEmailOk = await sendCarrierEmail();
|
}
|
||||||
|
|
||||||
// Résumé
|
// Test 3: Email transporteur
|
||||||
console.log('\n' + '='.repeat(60));
|
const carrierEmailOk = await sendCarrierEmail();
|
||||||
console.log('📊 RÉSUMÉ DES TESTS:');
|
|
||||||
console.log('='.repeat(60));
|
// Résumé
|
||||||
console.log('Connexion SMTP:', connectionOk ? '✅ OK' : '❌ ÉCHEC');
|
console.log('\n' + '='.repeat(60));
|
||||||
console.log('Email simple:', simpleEmailOk ? '✅ OK' : '❌ ÉCHEC');
|
console.log('📊 RÉSUMÉ DES TESTS:');
|
||||||
console.log('Email transporteur:', carrierEmailOk ? '✅ OK' : '❌ ÉCHEC');
|
console.log('='.repeat(60));
|
||||||
|
console.log('Connexion SMTP:', connectionOk ? '✅ OK' : '❌ ÉCHEC');
|
||||||
if (connectionOk && simpleEmailOk && carrierEmailOk) {
|
console.log('Email simple:', simpleEmailOk ? '✅ OK' : '❌ ÉCHEC');
|
||||||
console.log('\n✅ TOUS LES TESTS ONT RÉUSSI!');
|
console.log('Email transporteur:', carrierEmailOk ? '✅ OK' : '❌ ÉCHEC');
|
||||||
console.log(' Le système d\'envoi d\'email fonctionne correctement.');
|
|
||||||
console.log(' Si vous ne recevez pas les emails dans le backend,');
|
if (connectionOk && simpleEmailOk && carrierEmailOk) {
|
||||||
console.log(' le problème vient de l\'intégration NestJS.');
|
console.log('\n✅ TOUS LES TESTS ONT RÉUSSI!');
|
||||||
} else {
|
console.log(" Le système d'envoi d'email fonctionne correctement.");
|
||||||
console.log('\n❌ CERTAINS TESTS ONT ÉCHOUÉ');
|
console.log(' Si vous ne recevez pas les emails dans le backend,');
|
||||||
console.log(' Vérifiez les erreurs ci-dessus pour comprendre le problème.');
|
console.log(" le problème vient de l'intégration NestJS.");
|
||||||
}
|
} else {
|
||||||
|
console.log('\n❌ CERTAINS TESTS ONT ÉCHOUÉ');
|
||||||
console.log('\n' + '='.repeat(60));
|
console.log(' Vérifiez les erreurs ci-dessus pour comprendre le problème.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lancer les tests
|
console.log('\n' + '='.repeat(60));
|
||||||
runAllTests()
|
}
|
||||||
.then(() => {
|
|
||||||
console.log('\n✅ Tests terminés\n');
|
// Lancer les tests
|
||||||
process.exit(0);
|
runAllTests()
|
||||||
})
|
.then(() => {
|
||||||
.catch(error => {
|
console.log('\n✅ Tests terminés\n');
|
||||||
console.error('\n❌ Erreur fatale:', error);
|
process.exit(0);
|
||||||
process.exit(1);
|
})
|
||||||
});
|
.catch(error => {
|
||||||
|
console.error('\n❌ Erreur fatale:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,106 +1,106 @@
|
|||||||
/**
|
/**
|
||||||
* Script to delete test documents from MinIO
|
* Script to delete test documents from MinIO
|
||||||
*
|
*
|
||||||
* Deletes only small test files (< 1000 bytes) created by upload-test-documents.js
|
* Deletes only small test files (< 1000 bytes) created by upload-test-documents.js
|
||||||
* Preserves real uploaded documents (larger files)
|
* Preserves real uploaded documents (larger files)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { S3Client, ListObjectsV2Command, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
const { S3Client, ListObjectsV2Command, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
||||||
const BUCKET_NAME = 'xpeditis-documents';
|
const BUCKET_NAME = 'xpeditis-documents';
|
||||||
const TEST_FILE_SIZE_THRESHOLD = 1000; // Files smaller than 1KB are likely test files
|
const TEST_FILE_SIZE_THRESHOLD = 1000; // Files smaller than 1KB are likely test files
|
||||||
|
|
||||||
// Initialize MinIO client
|
// Initialize MinIO client
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
endpoint: MINIO_ENDPOINT,
|
endpoint: MINIO_ENDPOINT,
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
||||||
},
|
},
|
||||||
forcePathStyle: true,
|
forcePathStyle: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function deleteTestDocuments() {
|
async function deleteTestDocuments() {
|
||||||
try {
|
try {
|
||||||
console.log('📋 Listing all files in bucket:', BUCKET_NAME);
|
console.log('📋 Listing all files in bucket:', BUCKET_NAME);
|
||||||
|
|
||||||
// List all files
|
// List all files
|
||||||
let allFiles = [];
|
let allFiles = [];
|
||||||
let continuationToken = null;
|
let continuationToken = null;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const command = new ListObjectsV2Command({
|
const command = new ListObjectsV2Command({
|
||||||
Bucket: BUCKET_NAME,
|
Bucket: BUCKET_NAME,
|
||||||
ContinuationToken: continuationToken,
|
ContinuationToken: continuationToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await s3Client.send(command);
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
if (response.Contents) {
|
if (response.Contents) {
|
||||||
allFiles = allFiles.concat(response.Contents);
|
allFiles = allFiles.concat(response.Contents);
|
||||||
}
|
}
|
||||||
|
|
||||||
continuationToken = response.NextContinuationToken;
|
continuationToken = response.NextContinuationToken;
|
||||||
} while (continuationToken);
|
} while (continuationToken);
|
||||||
|
|
||||||
console.log(`\n📊 Found ${allFiles.length} total files\n`);
|
console.log(`\n📊 Found ${allFiles.length} total files\n`);
|
||||||
|
|
||||||
// Filter test files (small files < 1000 bytes)
|
// Filter test files (small files < 1000 bytes)
|
||||||
const testFiles = allFiles.filter(file => file.Size < TEST_FILE_SIZE_THRESHOLD);
|
const testFiles = allFiles.filter(file => file.Size < TEST_FILE_SIZE_THRESHOLD);
|
||||||
const realFiles = allFiles.filter(file => file.Size >= TEST_FILE_SIZE_THRESHOLD);
|
const realFiles = allFiles.filter(file => file.Size >= TEST_FILE_SIZE_THRESHOLD);
|
||||||
|
|
||||||
console.log(`🔍 Analysis:`);
|
console.log(`🔍 Analysis:`);
|
||||||
console.log(` Test files (< ${TEST_FILE_SIZE_THRESHOLD} bytes): ${testFiles.length}`);
|
console.log(` Test files (< ${TEST_FILE_SIZE_THRESHOLD} bytes): ${testFiles.length}`);
|
||||||
console.log(` Real files (>= ${TEST_FILE_SIZE_THRESHOLD} bytes): ${realFiles.length}\n`);
|
console.log(` Real files (>= ${TEST_FILE_SIZE_THRESHOLD} bytes): ${realFiles.length}\n`);
|
||||||
|
|
||||||
if (testFiles.length === 0) {
|
if (testFiles.length === 0) {
|
||||||
console.log('✅ No test files to delete');
|
console.log('✅ No test files to delete');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🗑️ Deleting ${testFiles.length} test files:\n`);
|
console.log(`🗑️ Deleting ${testFiles.length} test files:\n`);
|
||||||
|
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
for (const file of testFiles) {
|
for (const file of testFiles) {
|
||||||
console.log(` Deleting: ${file.Key} (${file.Size} bytes)`);
|
console.log(` Deleting: ${file.Key} (${file.Size} bytes)`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await s3Client.send(
|
await s3Client.send(
|
||||||
new DeleteObjectCommand({
|
new DeleteObjectCommand({
|
||||||
Bucket: BUCKET_NAME,
|
Bucket: BUCKET_NAME,
|
||||||
Key: file.Key,
|
Key: file.Key,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(` ❌ Failed to delete ${file.Key}:`, error.message);
|
console.error(` ❌ Failed to delete ${file.Key}:`, error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n✅ Deleted ${deletedCount} test files`);
|
console.log(`\n✅ Deleted ${deletedCount} test files`);
|
||||||
console.log(`✅ Preserved ${realFiles.length} real documents\n`);
|
console.log(`✅ Preserved ${realFiles.length} real documents\n`);
|
||||||
|
|
||||||
console.log('📂 Remaining real documents:');
|
console.log('📂 Remaining real documents:');
|
||||||
realFiles.forEach(file => {
|
realFiles.forEach(file => {
|
||||||
const filename = file.Key.split('/').pop();
|
const filename = file.Key.split('/').pop();
|
||||||
const sizeMB = (file.Size / 1024 / 1024).toFixed(2);
|
const sizeMB = (file.Size / 1024 / 1024).toFixed(2);
|
||||||
console.log(` - ${filename} (${sizeMB} MB)`);
|
console.log(` - ${filename} (${sizeMB} MB)`);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error:', error);
|
console.error('❌ Error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteTestDocuments()
|
deleteTestDocuments()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('\n✅ Script completed successfully');
|
console.log('\n✅ Script completed successfully');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error('\n❌ Script failed:', error);
|
console.error('\n❌ Script failed:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,42 +1,42 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Script to fix TypeScript imports in domain/services
|
* Script to fix TypeScript imports in domain/services
|
||||||
* Replace relative paths with path aliases
|
* Replace relative paths with path aliases
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
function fixImportsInFile(filePath) {
|
function fixImportsInFile(filePath) {
|
||||||
const content = fs.readFileSync(filePath, 'utf8');
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
let modified = content;
|
let modified = content;
|
||||||
|
|
||||||
// Replace relative imports to ../ports/ with @domain/ports/
|
// Replace relative imports to ../ports/ with @domain/ports/
|
||||||
modified = modified.replace(/from ['"]\.\.\/ports\//g, "from '@domain/ports/");
|
modified = modified.replace(/from ['"]\.\.\/ports\//g, "from '@domain/ports/");
|
||||||
modified = modified.replace(/import\s+(['"])\.\.\/ports\//g, "import $1@domain/ports/");
|
modified = modified.replace(/import\s+(['"])\.\.\/ports\//g, 'import $1@domain/ports/');
|
||||||
|
|
||||||
if (modified !== content) {
|
if (modified !== content) {
|
||||||
fs.writeFileSync(filePath, modified, 'utf8');
|
fs.writeFileSync(filePath, modified, 'utf8');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const servicesDir = path.join(__dirname, 'src/domain/services');
|
const servicesDir = path.join(__dirname, 'src/domain/services');
|
||||||
console.log('🔧 Fixing domain/services imports...\n');
|
console.log('🔧 Fixing domain/services imports...\n');
|
||||||
|
|
||||||
const files = fs.readdirSync(servicesDir);
|
const files = fs.readdirSync(servicesDir);
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.endsWith('.ts')) {
|
if (file.endsWith('.ts')) {
|
||||||
const filePath = path.join(servicesDir, file);
|
const filePath = path.join(servicesDir, file);
|
||||||
if (fixImportsInFile(filePath)) {
|
if (fixImportsInFile(filePath)) {
|
||||||
console.log(`✅ Fixed: ${filePath}`);
|
console.log(`✅ Fixed: ${filePath}`);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n✅ Fixed ${count} files in domain/services`);
|
console.log(`\n✅ Fixed ${count} files in domain/services`);
|
||||||
|
|||||||
@ -1,90 +1,90 @@
|
|||||||
/**
|
/**
|
||||||
* Script to fix dummy storage URLs in the database
|
* Script to fix dummy storage URLs in the database
|
||||||
*
|
*
|
||||||
* This script updates all document URLs from "dummy-storage.com" to proper MinIO URLs
|
* This script updates all document URLs from "dummy-storage.com" to proper MinIO URLs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { Client } = require('pg');
|
const { Client } = require('pg');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
||||||
const BUCKET_NAME = 'xpeditis-documents';
|
const BUCKET_NAME = 'xpeditis-documents';
|
||||||
|
|
||||||
async function fixDummyUrls() {
|
async function fixDummyUrls() {
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
host: process.env.DATABASE_HOST || 'localhost',
|
host: process.env.DATABASE_HOST || 'localhost',
|
||||||
port: process.env.DATABASE_PORT || 5432,
|
port: process.env.DATABASE_PORT || 5432,
|
||||||
user: process.env.DATABASE_USER || 'xpeditis',
|
user: process.env.DATABASE_USER || 'xpeditis',
|
||||||
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
||||||
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
console.log('✅ Connected to database');
|
console.log('✅ Connected to database');
|
||||||
|
|
||||||
// Get all CSV bookings with documents
|
// Get all CSV bookings with documents
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND documents::text LIKE '%dummy-storage%'`
|
`SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND documents::text LIKE '%dummy-storage%'`
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`\n📄 Found ${result.rows.length} bookings with dummy URLs\n`);
|
console.log(`\n📄 Found ${result.rows.length} bookings with dummy URLs\n`);
|
||||||
|
|
||||||
let updatedCount = 0;
|
let updatedCount = 0;
|
||||||
|
|
||||||
for (const row of result.rows) {
|
for (const row of result.rows) {
|
||||||
const bookingId = row.id;
|
const bookingId = row.id;
|
||||||
const documents = row.documents;
|
const documents = row.documents;
|
||||||
|
|
||||||
// Update each document URL
|
// Update each document URL
|
||||||
const updatedDocuments = documents.map((doc) => {
|
const updatedDocuments = documents.map(doc => {
|
||||||
if (doc.filePath && doc.filePath.includes('dummy-storage')) {
|
if (doc.filePath && doc.filePath.includes('dummy-storage')) {
|
||||||
// Extract filename from dummy URL
|
// Extract filename from dummy URL
|
||||||
const fileName = doc.fileName || doc.filePath.split('/').pop();
|
const fileName = doc.fileName || doc.filePath.split('/').pop();
|
||||||
const documentId = doc.id;
|
const documentId = doc.id;
|
||||||
|
|
||||||
// Build proper MinIO URL
|
// Build proper MinIO URL
|
||||||
const newUrl = `${MINIO_ENDPOINT}/${BUCKET_NAME}/csv-bookings/${bookingId}/${documentId}-${fileName}`;
|
const newUrl = `${MINIO_ENDPOINT}/${BUCKET_NAME}/csv-bookings/${bookingId}/${documentId}-${fileName}`;
|
||||||
|
|
||||||
console.log(` Old: ${doc.filePath}`);
|
console.log(` Old: ${doc.filePath}`);
|
||||||
console.log(` New: ${newUrl}`);
|
console.log(` New: ${newUrl}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...doc,
|
...doc,
|
||||||
filePath: newUrl,
|
filePath: newUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return doc;
|
return doc;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the database
|
// Update the database
|
||||||
await client.query(
|
await client.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
|
||||||
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
|
JSON.stringify(updatedDocuments),
|
||||||
[JSON.stringify(updatedDocuments), bookingId]
|
bookingId,
|
||||||
);
|
]);
|
||||||
|
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
console.log(`✅ Updated booking ${bookingId}\n`);
|
console.log(`✅ Updated booking ${bookingId}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n🎉 Successfully updated ${updatedCount} bookings`);
|
console.log(`\n🎉 Successfully updated ${updatedCount} bookings`);
|
||||||
console.log(`\n⚠️ Note: The actual files need to be uploaded to MinIO at the correct paths.`);
|
console.log(`\n⚠️ Note: The actual files need to be uploaded to MinIO at the correct paths.`);
|
||||||
console.log(` You can upload test files or re-create the bookings with real file uploads.`);
|
console.log(` You can upload test files or re-create the bookings with real file uploads.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error:', error);
|
console.error('❌ Error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await client.end();
|
await client.end();
|
||||||
console.log('\n👋 Disconnected from database');
|
console.log('\n👋 Disconnected from database');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fixDummyUrls()
|
fixDummyUrls()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('\n✅ Script completed successfully');
|
console.log('\n✅ Script completed successfully');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error('\n❌ Script failed:', error);
|
console.error('\n❌ Script failed:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,65 +1,68 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Script to fix TypeScript imports from relative paths to path aliases
|
* Script to fix TypeScript imports from relative paths to path aliases
|
||||||
*
|
*
|
||||||
* Replaces:
|
* Replaces:
|
||||||
* - from '../../domain/...' → from '@domain/...'
|
* - from '../../domain/...' → from '@domain/...'
|
||||||
* - from '../../../domain/...' → from '@domain/...'
|
* - from '../../../domain/...' → from '@domain/...'
|
||||||
* - from '../domain/...' → from '@domain/...'
|
* - from '../domain/...' → from '@domain/...'
|
||||||
* - from '../../../../domain/...' → from '@domain/...'
|
* - from '../../../../domain/...' → from '@domain/...'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
function fixImportsInFile(filePath) {
|
function fixImportsInFile(filePath) {
|
||||||
const content = fs.readFileSync(filePath, 'utf8');
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
let modified = content;
|
let modified = content;
|
||||||
|
|
||||||
// Replace all variations of relative domain imports with @domain alias
|
// Replace all variations of relative domain imports with @domain alias
|
||||||
modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/");
|
modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/");
|
||||||
modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/");
|
modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/");
|
||||||
modified = modified.replace(/from ['"]\.\.\/\.\.\/domain\//g, "from '@domain/");
|
modified = modified.replace(/from ['"]\.\.\/\.\.\/domain\//g, "from '@domain/");
|
||||||
modified = modified.replace(/from ['"]\.\.\/domain\//g, "from '@domain/");
|
modified = modified.replace(/from ['"]\.\.\/domain\//g, "from '@domain/");
|
||||||
|
|
||||||
// Also fix import statements (not just from)
|
// Also fix import statements (not just from)
|
||||||
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, "import $1@domain/");
|
modified = modified.replace(
|
||||||
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/domain\//g, "import $1@domain/");
|
/import\s+(['"])\.\.\/\.\.\/\.\.\/\.\.\/domain\//g,
|
||||||
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/domain\//g, "import $1@domain/");
|
'import $1@domain/'
|
||||||
modified = modified.replace(/import\s+(['"])\.\.\/domain\//g, "import $1@domain/");
|
);
|
||||||
|
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/domain\//g, 'import $1@domain/');
|
||||||
if (modified !== content) {
|
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/domain\//g, 'import $1@domain/');
|
||||||
fs.writeFileSync(filePath, modified, 'utf8');
|
modified = modified.replace(/import\s+(['"])\.\.\/domain\//g, 'import $1@domain/');
|
||||||
return true;
|
|
||||||
}
|
if (modified !== content) {
|
||||||
return false;
|
fs.writeFileSync(filePath, modified, 'utf8');
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
function walkDir(dir) {
|
return false;
|
||||||
const files = fs.readdirSync(dir);
|
}
|
||||||
let count = 0;
|
|
||||||
|
function walkDir(dir) {
|
||||||
for (const file of files) {
|
const files = fs.readdirSync(dir);
|
||||||
const filePath = path.join(dir, file);
|
let count = 0;
|
||||||
const stat = fs.statSync(filePath);
|
|
||||||
|
for (const file of files) {
|
||||||
if (stat.isDirectory()) {
|
const filePath = path.join(dir, file);
|
||||||
count += walkDir(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
} else if (file.endsWith('.ts')) {
|
|
||||||
if (fixImportsInFile(filePath)) {
|
if (stat.isDirectory()) {
|
||||||
console.log(`✅ Fixed: ${filePath}`);
|
count += walkDir(filePath);
|
||||||
count++;
|
} else if (file.endsWith('.ts')) {
|
||||||
}
|
if (fixImportsInFile(filePath)) {
|
||||||
}
|
console.log(`✅ Fixed: ${filePath}`);
|
||||||
}
|
count++;
|
||||||
|
}
|
||||||
return count;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const srcDir = path.join(__dirname, 'src');
|
return count;
|
||||||
console.log('🔧 Fixing TypeScript imports...\n');
|
}
|
||||||
|
|
||||||
const count = walkDir(srcDir);
|
const srcDir = path.join(__dirname, 'src');
|
||||||
|
console.log('🔧 Fixing TypeScript imports...\n');
|
||||||
console.log(`\n✅ Fixed ${count} files`);
|
|
||||||
|
const count = walkDir(srcDir);
|
||||||
|
|
||||||
|
console.log(`\n✅ Fixed ${count} files`);
|
||||||
|
|||||||
@ -1,81 +1,81 @@
|
|||||||
/**
|
/**
|
||||||
* Script to fix minio hostname in document URLs
|
* Script to fix minio hostname in document URLs
|
||||||
*
|
*
|
||||||
* Changes http://minio:9000 to http://localhost:9000
|
* Changes http://minio:9000 to http://localhost:9000
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { Client } = require('pg');
|
const { Client } = require('pg');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
async function fixMinioHostname() {
|
async function fixMinioHostname() {
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
host: process.env.DATABASE_HOST || 'localhost',
|
host: process.env.DATABASE_HOST || 'localhost',
|
||||||
port: process.env.DATABASE_PORT || 5432,
|
port: process.env.DATABASE_PORT || 5432,
|
||||||
user: process.env.DATABASE_USER || 'xpeditis',
|
user: process.env.DATABASE_USER || 'xpeditis',
|
||||||
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
||||||
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
console.log('✅ Connected to database');
|
console.log('✅ Connected to database');
|
||||||
|
|
||||||
// Find bookings with minio:9000 in URLs
|
// Find bookings with minio:9000 in URLs
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`SELECT id, documents FROM csv_bookings WHERE documents::text LIKE '%http://minio:9000%'`
|
`SELECT id, documents FROM csv_bookings WHERE documents::text LIKE '%http://minio:9000%'`
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`\n📄 Found ${result.rows.length} bookings with minio hostname\n`);
|
console.log(`\n📄 Found ${result.rows.length} bookings with minio hostname\n`);
|
||||||
|
|
||||||
let updatedCount = 0;
|
let updatedCount = 0;
|
||||||
|
|
||||||
for (const row of result.rows) {
|
for (const row of result.rows) {
|
||||||
const bookingId = row.id;
|
const bookingId = row.id;
|
||||||
const documents = row.documents;
|
const documents = row.documents;
|
||||||
|
|
||||||
// Update each document URL
|
// Update each document URL
|
||||||
const updatedDocuments = documents.map((doc) => {
|
const updatedDocuments = documents.map(doc => {
|
||||||
if (doc.filePath && doc.filePath.includes('http://minio:9000')) {
|
if (doc.filePath && doc.filePath.includes('http://minio:9000')) {
|
||||||
const newUrl = doc.filePath.replace('http://minio:9000', 'http://localhost:9000');
|
const newUrl = doc.filePath.replace('http://minio:9000', 'http://localhost:9000');
|
||||||
|
|
||||||
console.log(` Booking: ${bookingId}`);
|
console.log(` Booking: ${bookingId}`);
|
||||||
console.log(` Old: ${doc.filePath}`);
|
console.log(` Old: ${doc.filePath}`);
|
||||||
console.log(` New: ${newUrl}\n`);
|
console.log(` New: ${newUrl}\n`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...doc,
|
...doc,
|
||||||
filePath: newUrl,
|
filePath: newUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return doc;
|
return doc;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the database
|
// Update the database
|
||||||
await client.query(
|
await client.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
|
||||||
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
|
JSON.stringify(updatedDocuments),
|
||||||
[JSON.stringify(updatedDocuments), bookingId]
|
bookingId,
|
||||||
);
|
]);
|
||||||
|
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
console.log(`✅ Updated booking ${bookingId}\n`);
|
console.log(`✅ Updated booking ${bookingId}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n🎉 Successfully updated ${updatedCount} bookings`);
|
console.log(`\n🎉 Successfully updated ${updatedCount} bookings`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error:', error);
|
console.error('❌ Error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await client.end();
|
await client.end();
|
||||||
console.log('\n👋 Disconnected from database');
|
console.log('\n👋 Disconnected from database');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fixMinioHostname()
|
fixMinioHostname()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('\n✅ Script completed successfully');
|
console.log('\n✅ Script completed successfully');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error('\n❌ Script failed:', error);
|
console.error('\n❌ Script failed:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
const argon2 = require('argon2');
|
const argon2 = require('argon2');
|
||||||
|
|
||||||
async function generateHash() {
|
async function generateHash() {
|
||||||
const hash = await argon2.hash('Password123!', {
|
const hash = await argon2.hash('Password123!', {
|
||||||
type: argon2.argon2id,
|
type: argon2.argon2id,
|
||||||
memoryCost: 65536, // 64 MB
|
memoryCost: 65536, // 64 MB
|
||||||
timeCost: 3,
|
timeCost: 3,
|
||||||
parallelism: 4,
|
parallelism: 4,
|
||||||
});
|
});
|
||||||
console.log('Argon2id hash for "Password123!":');
|
console.log('Argon2id hash for "Password123!":');
|
||||||
console.log(hash);
|
console.log(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
generateHash().catch(console.error);
|
generateHash().catch(console.error);
|
||||||
|
|||||||
@ -1,92 +1,92 @@
|
|||||||
/**
|
/**
|
||||||
* Script to list all files in MinIO xpeditis-documents bucket
|
* Script to list all files in MinIO xpeditis-documents bucket
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
||||||
const BUCKET_NAME = 'xpeditis-documents';
|
const BUCKET_NAME = 'xpeditis-documents';
|
||||||
|
|
||||||
// Initialize MinIO client
|
// Initialize MinIO client
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
endpoint: MINIO_ENDPOINT,
|
endpoint: MINIO_ENDPOINT,
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
||||||
},
|
},
|
||||||
forcePathStyle: true,
|
forcePathStyle: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function listFiles() {
|
async function listFiles() {
|
||||||
try {
|
try {
|
||||||
console.log(`📋 Listing all files in bucket: ${BUCKET_NAME}\n`);
|
console.log(`📋 Listing all files in bucket: ${BUCKET_NAME}\n`);
|
||||||
|
|
||||||
let allFiles = [];
|
let allFiles = [];
|
||||||
let continuationToken = null;
|
let continuationToken = null;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const command = new ListObjectsV2Command({
|
const command = new ListObjectsV2Command({
|
||||||
Bucket: BUCKET_NAME,
|
Bucket: BUCKET_NAME,
|
||||||
ContinuationToken: continuationToken,
|
ContinuationToken: continuationToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await s3Client.send(command);
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
if (response.Contents) {
|
if (response.Contents) {
|
||||||
allFiles = allFiles.concat(response.Contents);
|
allFiles = allFiles.concat(response.Contents);
|
||||||
}
|
}
|
||||||
|
|
||||||
continuationToken = response.NextContinuationToken;
|
continuationToken = response.NextContinuationToken;
|
||||||
} while (continuationToken);
|
} while (continuationToken);
|
||||||
|
|
||||||
console.log(`Found ${allFiles.length} files total:\n`);
|
console.log(`Found ${allFiles.length} files total:\n`);
|
||||||
|
|
||||||
// Group by booking ID
|
// Group by booking ID
|
||||||
const byBooking = {};
|
const byBooking = {};
|
||||||
allFiles.forEach(file => {
|
allFiles.forEach(file => {
|
||||||
const parts = file.Key.split('/');
|
const parts = file.Key.split('/');
|
||||||
if (parts.length >= 3 && parts[0] === 'csv-bookings') {
|
if (parts.length >= 3 && parts[0] === 'csv-bookings') {
|
||||||
const bookingId = parts[1];
|
const bookingId = parts[1];
|
||||||
if (!byBooking[bookingId]) {
|
if (!byBooking[bookingId]) {
|
||||||
byBooking[bookingId] = [];
|
byBooking[bookingId] = [];
|
||||||
}
|
}
|
||||||
byBooking[bookingId].push({
|
byBooking[bookingId].push({
|
||||||
key: file.Key,
|
key: file.Key,
|
||||||
size: file.Size,
|
size: file.Size,
|
||||||
lastModified: file.LastModified,
|
lastModified: file.LastModified,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log(` Other: ${file.Key} (${file.Size} bytes)`);
|
console.log(` Other: ${file.Key} (${file.Size} bytes)`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`\nFiles grouped by booking:\n`);
|
console.log(`\nFiles grouped by booking:\n`);
|
||||||
Object.entries(byBooking).forEach(([bookingId, files]) => {
|
Object.entries(byBooking).forEach(([bookingId, files]) => {
|
||||||
console.log(`📦 Booking: ${bookingId.substring(0, 8)}...`);
|
console.log(`📦 Booking: ${bookingId.substring(0, 8)}...`);
|
||||||
files.forEach(file => {
|
files.forEach(file => {
|
||||||
const filename = file.key.split('/').pop();
|
const filename = file.key.split('/').pop();
|
||||||
console.log(` - ${filename} (${file.size} bytes) - ${file.lastModified}`);
|
console.log(` - ${filename} (${file.size} bytes) - ${file.lastModified}`);
|
||||||
});
|
});
|
||||||
console.log('');
|
console.log('');
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`\n📊 Summary:`);
|
console.log(`\n📊 Summary:`);
|
||||||
console.log(` Total files: ${allFiles.length}`);
|
console.log(` Total files: ${allFiles.length}`);
|
||||||
console.log(` Bookings with files: ${Object.keys(byBooking).length}`);
|
console.log(` Bookings with files: ${Object.keys(byBooking).length}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error:', error);
|
console.error('❌ Error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
listFiles()
|
listFiles()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('\n✅ Script completed successfully');
|
console.log('\n✅ Script completed successfully');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error('\n❌ Script failed:', error);
|
console.error('\n❌ Script failed:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,18 +9,21 @@ async function loginAndTestEmail() {
|
|||||||
console.log('🔐 Connexion...');
|
console.log('🔐 Connexion...');
|
||||||
const loginResponse = await axios.post(`${API_URL}/auth/login`, {
|
const loginResponse = await axios.post(`${API_URL}/auth/login`, {
|
||||||
email: 'admin@xpeditis.com',
|
email: 'admin@xpeditis.com',
|
||||||
password: 'Admin123!@#'
|
password: 'Admin123!@#',
|
||||||
});
|
});
|
||||||
|
|
||||||
const token = loginResponse.data.accessToken;
|
const token = loginResponse.data.accessToken;
|
||||||
console.log('✅ Connecté avec succès\n');
|
console.log('✅ Connecté avec succès\n');
|
||||||
|
|
||||||
// 2. Créer un CSV booking pour tester l'envoi d'email
|
// 2. Créer un CSV booking pour tester l'envoi d'email
|
||||||
console.log('📧 Création d\'une CSV booking pour tester l\'envoi d\'email...');
|
console.log("📧 Création d'une CSV booking pour tester l'envoi d'email...");
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
const testFile = Buffer.from('Test document PDF content');
|
const testFile = Buffer.from('Test document PDF content');
|
||||||
form.append('documents', testFile, { filename: 'test-doc.pdf', contentType: 'application/pdf' });
|
form.append('documents', testFile, {
|
||||||
|
filename: 'test-doc.pdf',
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
});
|
||||||
|
|
||||||
form.append('carrierName', 'Test Carrier');
|
form.append('carrierName', 'Test Carrier');
|
||||||
form.append('carrierEmail', 'testcarrier@example.com');
|
form.append('carrierEmail', 'testcarrier@example.com');
|
||||||
@ -39,8 +42,8 @@ async function loginAndTestEmail() {
|
|||||||
const bookingResponse = await axios.post(`${API_URL}/csv-bookings`, form, {
|
const bookingResponse = await axios.post(`${API_URL}/csv-bookings`, form, {
|
||||||
headers: {
|
headers: {
|
||||||
...form.getHeaders(),
|
...form.getHeaders(),
|
||||||
'Authorization': `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ CSV Booking créé:', bookingResponse.data.id);
|
console.log('✅ CSV Booking créé:', bookingResponse.data.id);
|
||||||
@ -50,7 +53,6 @@ async function loginAndTestEmail() {
|
|||||||
console.log('2. Vérifier Mailtrap inbox: https://mailtrap.io/inboxes');
|
console.log('2. Vérifier Mailtrap inbox: https://mailtrap.io/inboxes');
|
||||||
console.log('3. Email devrait être envoyé à: testcarrier@example.com');
|
console.log('3. Email devrait être envoyé à: testcarrier@example.com');
|
||||||
console.log('\n⏳ Attendez quelques secondes puis vérifiez les logs du backend...');
|
console.log('\n⏳ Attendez quelques secondes puis vérifiez les logs du backend...');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ ERREUR:');
|
console.error('❌ ERREUR:');
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
|
|||||||
@ -1,176 +1,182 @@
|
|||||||
/**
|
/**
|
||||||
* Script to restore document references in database from MinIO files
|
* Script to restore document references in database from MinIO files
|
||||||
*
|
*
|
||||||
* Scans MinIO for existing files and creates/updates database references
|
* Scans MinIO for existing files and creates/updates database references
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
const { Client } = require('pg');
|
const { Client } = require('pg');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
||||||
const BUCKET_NAME = 'xpeditis-documents';
|
const BUCKET_NAME = 'xpeditis-documents';
|
||||||
|
|
||||||
// Initialize MinIO client
|
// Initialize MinIO client
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
endpoint: MINIO_ENDPOINT,
|
endpoint: MINIO_ENDPOINT,
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
||||||
},
|
},
|
||||||
forcePathStyle: true,
|
forcePathStyle: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function restoreDocumentReferences() {
|
async function restoreDocumentReferences() {
|
||||||
const pgClient = new Client({
|
const pgClient = new Client({
|
||||||
host: process.env.DATABASE_HOST || 'localhost',
|
host: process.env.DATABASE_HOST || 'localhost',
|
||||||
port: process.env.DATABASE_PORT || 5432,
|
port: process.env.DATABASE_PORT || 5432,
|
||||||
user: process.env.DATABASE_USER || 'xpeditis',
|
user: process.env.DATABASE_USER || 'xpeditis',
|
||||||
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
||||||
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pgClient.connect();
|
await pgClient.connect();
|
||||||
console.log('✅ Connected to database\n');
|
console.log('✅ Connected to database\n');
|
||||||
|
|
||||||
// Get all MinIO files
|
// Get all MinIO files
|
||||||
console.log('📋 Listing files in MinIO...');
|
console.log('📋 Listing files in MinIO...');
|
||||||
let allFiles = [];
|
let allFiles = [];
|
||||||
let continuationToken = null;
|
let continuationToken = null;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const command = new ListObjectsV2Command({
|
const command = new ListObjectsV2Command({
|
||||||
Bucket: BUCKET_NAME,
|
Bucket: BUCKET_NAME,
|
||||||
ContinuationToken: continuationToken,
|
ContinuationToken: continuationToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await s3Client.send(command);
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
if (response.Contents) {
|
if (response.Contents) {
|
||||||
allFiles = allFiles.concat(response.Contents);
|
allFiles = allFiles.concat(response.Contents);
|
||||||
}
|
}
|
||||||
|
|
||||||
continuationToken = response.NextContinuationToken;
|
continuationToken = response.NextContinuationToken;
|
||||||
} while (continuationToken);
|
} while (continuationToken);
|
||||||
|
|
||||||
console.log(` Found ${allFiles.length} files in MinIO\n`);
|
console.log(` Found ${allFiles.length} files in MinIO\n`);
|
||||||
|
|
||||||
// Group files by booking ID
|
// Group files by booking ID
|
||||||
const filesByBooking = {};
|
const filesByBooking = {};
|
||||||
allFiles.forEach(file => {
|
allFiles.forEach(file => {
|
||||||
const parts = file.Key.split('/');
|
const parts = file.Key.split('/');
|
||||||
if (parts.length >= 3 && parts[0] === 'csv-bookings') {
|
if (parts.length >= 3 && parts[0] === 'csv-bookings') {
|
||||||
const bookingId = parts[1];
|
const bookingId = parts[1];
|
||||||
const documentId = parts[2].split('-')[0]; // Extract UUID from filename
|
const documentId = parts[2].split('-')[0]; // Extract UUID from filename
|
||||||
const fileName = parts[2].substring(37); // Remove UUID prefix (36 chars + dash)
|
const fileName = parts[2].substring(37); // Remove UUID prefix (36 chars + dash)
|
||||||
|
|
||||||
if (!filesByBooking[bookingId]) {
|
if (!filesByBooking[bookingId]) {
|
||||||
filesByBooking[bookingId] = [];
|
filesByBooking[bookingId] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
filesByBooking[bookingId].push({
|
filesByBooking[bookingId].push({
|
||||||
key: file.Key,
|
key: file.Key,
|
||||||
documentId: documentId,
|
documentId: documentId,
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
size: file.Size,
|
size: file.Size,
|
||||||
lastModified: file.LastModified,
|
lastModified: file.LastModified,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`📦 Found files for ${Object.keys(filesByBooking).length} bookings\n`);
|
console.log(`📦 Found files for ${Object.keys(filesByBooking).length} bookings\n`);
|
||||||
|
|
||||||
let updatedCount = 0;
|
let updatedCount = 0;
|
||||||
let createdDocsCount = 0;
|
let createdDocsCount = 0;
|
||||||
|
|
||||||
for (const [bookingId, files] of Object.entries(filesByBooking)) {
|
for (const [bookingId, files] of Object.entries(filesByBooking)) {
|
||||||
// Check if booking exists
|
// Check if booking exists
|
||||||
const bookingResult = await pgClient.query(
|
const bookingResult = await pgClient.query(
|
||||||
'SELECT id, documents FROM csv_bookings WHERE id = $1',
|
'SELECT id, documents FROM csv_bookings WHERE id = $1',
|
||||||
[bookingId]
|
[bookingId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (bookingResult.rows.length === 0) {
|
if (bookingResult.rows.length === 0) {
|
||||||
console.log(`⚠️ Booking not found: ${bookingId.substring(0, 8)}... (skipping)`);
|
console.log(`⚠️ Booking not found: ${bookingId.substring(0, 8)}... (skipping)`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const booking = bookingResult.rows[0];
|
const booking = bookingResult.rows[0];
|
||||||
const existingDocs = booking.documents || [];
|
const existingDocs = booking.documents || [];
|
||||||
|
|
||||||
console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`);
|
console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`);
|
||||||
console.log(` Existing documents in DB: ${existingDocs.length}`);
|
console.log(` Existing documents in DB: ${existingDocs.length}`);
|
||||||
console.log(` Files in MinIO: ${files.length}`);
|
console.log(` Files in MinIO: ${files.length}`);
|
||||||
|
|
||||||
// Create document references for files
|
// Create document references for files
|
||||||
const newDocuments = files.map(file => {
|
const newDocuments = files.map(file => {
|
||||||
// Determine MIME type from file extension
|
// Determine MIME type from file extension
|
||||||
const ext = file.fileName.split('.').pop().toLowerCase();
|
const ext = file.fileName.split('.').pop().toLowerCase();
|
||||||
const mimeTypeMap = {
|
const mimeTypeMap = {
|
||||||
pdf: 'application/pdf',
|
pdf: 'application/pdf',
|
||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
jpg: 'image/jpeg',
|
jpg: 'image/jpeg',
|
||||||
jpeg: 'image/jpeg',
|
jpeg: 'image/jpeg',
|
||||||
txt: 'text/plain',
|
txt: 'text/plain',
|
||||||
};
|
};
|
||||||
const mimeType = mimeTypeMap[ext] || 'application/octet-stream';
|
const mimeType = mimeTypeMap[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
// Determine document type
|
// Determine document type
|
||||||
let docType = 'OTHER';
|
let docType = 'OTHER';
|
||||||
if (file.fileName.toLowerCase().includes('bill-of-lading') || file.fileName.toLowerCase().includes('bol')) {
|
if (
|
||||||
docType = 'BILL_OF_LADING';
|
file.fileName.toLowerCase().includes('bill-of-lading') ||
|
||||||
} else if (file.fileName.toLowerCase().includes('packing-list')) {
|
file.fileName.toLowerCase().includes('bol')
|
||||||
docType = 'PACKING_LIST';
|
) {
|
||||||
} else if (file.fileName.toLowerCase().includes('commercial-invoice') || file.fileName.toLowerCase().includes('invoice')) {
|
docType = 'BILL_OF_LADING';
|
||||||
docType = 'COMMERCIAL_INVOICE';
|
} else if (file.fileName.toLowerCase().includes('packing-list')) {
|
||||||
}
|
docType = 'PACKING_LIST';
|
||||||
|
} else if (
|
||||||
const doc = {
|
file.fileName.toLowerCase().includes('commercial-invoice') ||
|
||||||
id: file.documentId,
|
file.fileName.toLowerCase().includes('invoice')
|
||||||
type: docType,
|
) {
|
||||||
fileName: file.fileName,
|
docType = 'COMMERCIAL_INVOICE';
|
||||||
filePath: `${MINIO_ENDPOINT}/${BUCKET_NAME}/${file.key}`,
|
}
|
||||||
mimeType: mimeType,
|
|
||||||
size: file.size,
|
const doc = {
|
||||||
uploadedAt: file.lastModified.toISOString(),
|
id: file.documentId,
|
||||||
};
|
type: docType,
|
||||||
|
fileName: file.fileName,
|
||||||
console.log(` ✅ ${file.fileName} (${(file.size / 1024).toFixed(2)} KB)`);
|
filePath: `${MINIO_ENDPOINT}/${BUCKET_NAME}/${file.key}`,
|
||||||
return doc;
|
mimeType: mimeType,
|
||||||
});
|
size: file.size,
|
||||||
|
uploadedAt: file.lastModified.toISOString(),
|
||||||
// Update the booking with new document references
|
};
|
||||||
await pgClient.query(
|
|
||||||
'UPDATE csv_bookings SET documents = $1 WHERE id = $2',
|
console.log(` ✅ ${file.fileName} (${(file.size / 1024).toFixed(2)} KB)`);
|
||||||
[JSON.stringify(newDocuments), bookingId]
|
return doc;
|
||||||
);
|
});
|
||||||
|
|
||||||
updatedCount++;
|
// Update the booking with new document references
|
||||||
createdDocsCount += newDocuments.length;
|
await pgClient.query('UPDATE csv_bookings SET documents = $1 WHERE id = $2', [
|
||||||
}
|
JSON.stringify(newDocuments),
|
||||||
|
bookingId,
|
||||||
console.log(`\n📊 Summary:`);
|
]);
|
||||||
console.log(` Bookings updated: ${updatedCount}`);
|
|
||||||
console.log(` Document references created: ${createdDocsCount}`);
|
updatedCount++;
|
||||||
console.log(`\n✅ Document references restored`);
|
createdDocsCount += newDocuments.length;
|
||||||
} catch (error) {
|
}
|
||||||
console.error('❌ Error:', error);
|
|
||||||
throw error;
|
console.log(`\n📊 Summary:`);
|
||||||
} finally {
|
console.log(` Bookings updated: ${updatedCount}`);
|
||||||
await pgClient.end();
|
console.log(` Document references created: ${createdDocsCount}`);
|
||||||
console.log('\n👋 Disconnected from database');
|
console.log(`\n✅ Document references restored`);
|
||||||
}
|
} catch (error) {
|
||||||
}
|
console.error('❌ Error:', error);
|
||||||
|
throw error;
|
||||||
restoreDocumentReferences()
|
} finally {
|
||||||
.then(() => {
|
await pgClient.end();
|
||||||
console.log('\n✅ Script completed successfully');
|
console.log('\n👋 Disconnected from database');
|
||||||
process.exit(0);
|
}
|
||||||
})
|
}
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n❌ Script failed:', error);
|
restoreDocumentReferences()
|
||||||
process.exit(1);
|
.then(() => {
|
||||||
});
|
console.log('\n✅ Script completed successfully');
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('\n❌ Script failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,44 +1,44 @@
|
|||||||
const { DataSource } = require('typeorm');
|
const { DataSource } = require('typeorm');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const AppDataSource = new DataSource({
|
const AppDataSource = new DataSource({
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
host: process.env.DATABASE_HOST,
|
host: process.env.DATABASE_HOST,
|
||||||
port: parseInt(process.env.DATABASE_PORT, 10),
|
port: parseInt(process.env.DATABASE_PORT, 10),
|
||||||
username: process.env.DATABASE_USER,
|
username: process.env.DATABASE_USER,
|
||||||
password: process.env.DATABASE_PASSWORD,
|
password: process.env.DATABASE_PASSWORD,
|
||||||
database: process.env.DATABASE_NAME,
|
database: process.env.DATABASE_NAME,
|
||||||
entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')],
|
entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')],
|
||||||
migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')],
|
migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
logging: true,
|
logging: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('🚀 Starting Xpeditis Backend Migration Script...');
|
console.log('🚀 Starting Xpeditis Backend Migration Script...');
|
||||||
console.log('📦 Initializing DataSource...');
|
console.log('📦 Initializing DataSource...');
|
||||||
|
|
||||||
AppDataSource.initialize()
|
AppDataSource.initialize()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
console.log('✅ DataSource initialized successfully');
|
console.log('✅ DataSource initialized successfully');
|
||||||
console.log('🔄 Running pending migrations...');
|
console.log('🔄 Running pending migrations...');
|
||||||
|
|
||||||
const migrations = await AppDataSource.runMigrations();
|
const migrations = await AppDataSource.runMigrations();
|
||||||
|
|
||||||
if (migrations.length === 0) {
|
if (migrations.length === 0) {
|
||||||
console.log('✅ No pending migrations');
|
console.log('✅ No pending migrations');
|
||||||
} else {
|
} else {
|
||||||
console.log(`✅ Successfully ran ${migrations.length} migration(s):`);
|
console.log(`✅ Successfully ran ${migrations.length} migration(s):`);
|
||||||
migrations.forEach((migration) => {
|
migrations.forEach(migration => {
|
||||||
console.log(` - ${migration.name}`);
|
console.log(` - ${migration.name}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await AppDataSource.destroy();
|
await AppDataSource.destroy();
|
||||||
console.log('✅ Database migrations completed successfully');
|
console.log('✅ Database migrations completed successfully');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error('❌ Error during migration:');
|
console.error('❌ Error during migration:');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -210,10 +210,7 @@ function parseSeaPorts(filePath: string): ParsedPort[] {
|
|||||||
|
|
||||||
// Validate coordinates
|
// Validate coordinates
|
||||||
const [longitude, latitude] = port.coordinates;
|
const [longitude, latitude] = port.coordinates;
|
||||||
if (
|
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||||
latitude < -90 || latitude > 90 ||
|
|
||||||
longitude < -180 || longitude > 180
|
|
||||||
) {
|
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -244,13 +241,14 @@ function generateSQLInserts(ports: ParsedPort[]): string {
|
|||||||
|
|
||||||
for (let i = 0; i < ports.length; i += batchSize) {
|
for (let i = 0; i < ports.length; i += batchSize) {
|
||||||
const batch = ports.slice(i, i + batchSize);
|
const batch = ports.slice(i, i + batchSize);
|
||||||
const values = batch.map(port => {
|
const values = batch
|
||||||
const name = port.name.replace(/'/g, "''");
|
.map(port => {
|
||||||
const city = port.city.replace(/'/g, "''");
|
const name = port.name.replace(/'/g, "''");
|
||||||
const countryName = port.countryName.replace(/'/g, "''");
|
const city = port.city.replace(/'/g, "''");
|
||||||
const timezone = port.timezone ? `'${port.timezone}'` : 'NULL';
|
const countryName = port.countryName.replace(/'/g, "''");
|
||||||
|
const timezone = port.timezone ? `'${port.timezone}'` : 'NULL';
|
||||||
|
|
||||||
return `(
|
return `(
|
||||||
'${port.code}',
|
'${port.code}',
|
||||||
'${name}',
|
'${name}',
|
||||||
'${city}',
|
'${city}',
|
||||||
@ -261,7 +259,8 @@ function generateSQLInserts(ports: ParsedPort[]): string {
|
|||||||
${timezone},
|
${timezone},
|
||||||
${port.isActive}
|
${port.isActive}
|
||||||
)`;
|
)`;
|
||||||
}).join(',\n ');
|
})
|
||||||
|
.join(',\n ');
|
||||||
|
|
||||||
batches.push(`
|
batches.push(`
|
||||||
// Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(ports.length / batchSize)} (${batch.length} ports)
|
// Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(ports.length / batchSize)} (${batch.length} ports)
|
||||||
@ -321,7 +320,9 @@ async function main() {
|
|||||||
if (!fs.existsSync(seaPortsPath)) {
|
if (!fs.existsSync(seaPortsPath)) {
|
||||||
console.error('❌ Error: /tmp/sea-ports.json not found!');
|
console.error('❌ Error: /tmp/sea-ports.json not found!');
|
||||||
console.log('Please download it first:');
|
console.log('Please download it first:');
|
||||||
console.log('curl -o /tmp/sea-ports.json https://raw.githubusercontent.com/marchah/sea-ports/master/lib/ports.json');
|
console.log(
|
||||||
|
'curl -o /tmp/sea-ports.json https://raw.githubusercontent.com/marchah/sea-ports/master/lib/ports.json'
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -342,7 +343,10 @@ async function main() {
|
|||||||
const migrationContent = generateMigration(ports);
|
const migrationContent = generateMigration(ports);
|
||||||
|
|
||||||
// Write migration file
|
// Write migration file
|
||||||
const migrationsDir = path.join(__dirname, '../src/infrastructure/persistence/typeorm/migrations');
|
const migrationsDir = path.join(
|
||||||
|
__dirname,
|
||||||
|
'../src/infrastructure/persistence/typeorm/migrations'
|
||||||
|
);
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const fileName = `${timestamp}-SeedPorts.ts`;
|
const fileName = `${timestamp}-SeedPorts.ts`;
|
||||||
const filePath = path.join(migrationsDir, fileName);
|
const filePath = path.join(migrationsDir, fileName);
|
||||||
|
|||||||
@ -5,7 +5,10 @@
|
|||||||
|
|
||||||
const Stripe = require('stripe');
|
const Stripe = require('stripe');
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr');
|
const stripe = new Stripe(
|
||||||
|
process.env.STRIPE_SECRET_KEY ||
|
||||||
|
'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr'
|
||||||
|
);
|
||||||
|
|
||||||
async function listPrices() {
|
async function listPrices() {
|
||||||
console.log('Fetching Stripe prices...\n');
|
console.log('Fetching Stripe prices...\n');
|
||||||
@ -46,7 +49,6 @@ async function listPrices() {
|
|||||||
console.log('STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx');
|
console.log('STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx');
|
||||||
console.log('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx');
|
console.log('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx');
|
||||||
console.log('STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx');
|
console.log('STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching prices:', error.message);
|
console.error('Error fetching prices:', error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,79 +1,79 @@
|
|||||||
/**
|
/**
|
||||||
* Script to set MinIO bucket policy for public read access
|
* Script to set MinIO bucket policy for public read access
|
||||||
*
|
*
|
||||||
* This allows documents to be downloaded directly via URL without authentication
|
* This allows documents to be downloaded directly via URL without authentication
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { S3Client, PutBucketPolicyCommand, GetBucketPolicyCommand } = require('@aws-sdk/client-s3');
|
const { S3Client, PutBucketPolicyCommand, GetBucketPolicyCommand } = require('@aws-sdk/client-s3');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
||||||
const BUCKET_NAME = 'xpeditis-documents';
|
const BUCKET_NAME = 'xpeditis-documents';
|
||||||
|
|
||||||
// Initialize MinIO client
|
// Initialize MinIO client
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
endpoint: MINIO_ENDPOINT,
|
endpoint: MINIO_ENDPOINT,
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
||||||
},
|
},
|
||||||
forcePathStyle: true,
|
forcePathStyle: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function setBucketPolicy() {
|
async function setBucketPolicy() {
|
||||||
try {
|
try {
|
||||||
// Policy to allow public read access to all objects in the bucket
|
// Policy to allow public read access to all objects in the bucket
|
||||||
const policy = {
|
const policy = {
|
||||||
Version: '2012-10-17',
|
Version: '2012-10-17',
|
||||||
Statement: [
|
Statement: [
|
||||||
{
|
{
|
||||||
Effect: 'Allow',
|
Effect: 'Allow',
|
||||||
Principal: '*',
|
Principal: '*',
|
||||||
Action: ['s3:GetObject'],
|
Action: ['s3:GetObject'],
|
||||||
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
|
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('📋 Setting bucket policy for:', BUCKET_NAME);
|
console.log('📋 Setting bucket policy for:', BUCKET_NAME);
|
||||||
console.log('Policy:', JSON.stringify(policy, null, 2));
|
console.log('Policy:', JSON.stringify(policy, null, 2));
|
||||||
|
|
||||||
// Set the bucket policy
|
// Set the bucket policy
|
||||||
await s3Client.send(
|
await s3Client.send(
|
||||||
new PutBucketPolicyCommand({
|
new PutBucketPolicyCommand({
|
||||||
Bucket: BUCKET_NAME,
|
Bucket: BUCKET_NAME,
|
||||||
Policy: JSON.stringify(policy),
|
Policy: JSON.stringify(policy),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('\n✅ Bucket policy set successfully!');
|
console.log('\n✅ Bucket policy set successfully!');
|
||||||
console.log(` All objects in ${BUCKET_NAME} are now publicly readable`);
|
console.log(` All objects in ${BUCKET_NAME} are now publicly readable`);
|
||||||
|
|
||||||
// Verify the policy was set
|
// Verify the policy was set
|
||||||
console.log('\n🔍 Verifying bucket policy...');
|
console.log('\n🔍 Verifying bucket policy...');
|
||||||
const getPolicy = await s3Client.send(
|
const getPolicy = await s3Client.send(
|
||||||
new GetBucketPolicyCommand({
|
new GetBucketPolicyCommand({
|
||||||
Bucket: BUCKET_NAME,
|
Bucket: BUCKET_NAME,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('✅ Current policy:', getPolicy.Policy);
|
console.log('✅ Current policy:', getPolicy.Policy);
|
||||||
|
|
||||||
console.log('\n📝 Note: This allows public read access to all documents.');
|
console.log('\n📝 Note: This allows public read access to all documents.');
|
||||||
console.log(' For production, consider using signed URLs instead.');
|
console.log(' For production, consider using signed URLs instead.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error:', error);
|
console.error('❌ Error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setBucketPolicy()
|
setBucketPolicy()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('\n✅ Script completed successfully');
|
console.log('\n✅ Script completed successfully');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error('\n❌ Script failed:', error);
|
console.error('\n❌ Script failed:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,91 +1,91 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Setup MinIO Bucket
|
* Setup MinIO Bucket
|
||||||
*
|
*
|
||||||
* Creates the required bucket for document storage if it doesn't exist
|
* Creates the required bucket for document storage if it doesn't exist
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { S3Client, CreateBucketCommand, HeadBucketCommand } = require('@aws-sdk/client-s3');
|
const { S3Client, CreateBucketCommand, HeadBucketCommand } = require('@aws-sdk/client-s3');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const BUCKET_NAME = 'xpeditis-documents';
|
const BUCKET_NAME = 'xpeditis-documents';
|
||||||
|
|
||||||
// Configure S3 client for MinIO
|
// Configure S3 client for MinIO
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
region: process.env.AWS_REGION || 'us-east-1',
|
region: process.env.AWS_REGION || 'us-east-1',
|
||||||
endpoint: process.env.AWS_S3_ENDPOINT || 'http://localhost:9000',
|
endpoint: process.env.AWS_S3_ENDPOINT || 'http://localhost:9000',
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
||||||
},
|
},
|
||||||
forcePathStyle: true, // Required for MinIO
|
forcePathStyle: true, // Required for MinIO
|
||||||
});
|
});
|
||||||
|
|
||||||
async function setupBucket() {
|
async function setupBucket() {
|
||||||
console.log('\n🪣 MinIO Bucket Setup');
|
console.log('\n🪣 MinIO Bucket Setup');
|
||||||
console.log('==========================================');
|
console.log('==========================================');
|
||||||
console.log(`Bucket name: ${BUCKET_NAME}`);
|
console.log(`Bucket name: ${BUCKET_NAME}`);
|
||||||
console.log(`Endpoint: ${process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'}`);
|
console.log(`Endpoint: ${process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if bucket exists
|
// Check if bucket exists
|
||||||
console.log('📋 Step 1: Checking if bucket exists...');
|
console.log('📋 Step 1: Checking if bucket exists...');
|
||||||
try {
|
try {
|
||||||
await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }));
|
await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }));
|
||||||
console.log(`✅ Bucket '${BUCKET_NAME}' already exists`);
|
console.log(`✅ Bucket '${BUCKET_NAME}' already exists`);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('✅ Setup complete! The bucket is ready to use.');
|
console.log('✅ Setup complete! The bucket is ready to use.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
|
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
|
||||||
console.log(`ℹ️ Bucket '${BUCKET_NAME}' does not exist`);
|
console.log(`ℹ️ Bucket '${BUCKET_NAME}' does not exist`);
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create bucket
|
// Create bucket
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('📋 Step 2: Creating bucket...');
|
console.log('📋 Step 2: Creating bucket...');
|
||||||
await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME }));
|
await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME }));
|
||||||
console.log(`✅ Bucket '${BUCKET_NAME}' created successfully!`);
|
console.log(`✅ Bucket '${BUCKET_NAME}' created successfully!`);
|
||||||
|
|
||||||
// Verify creation
|
// Verify creation
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('📋 Step 3: Verifying bucket...');
|
console.log('📋 Step 3: Verifying bucket...');
|
||||||
await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }));
|
await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }));
|
||||||
console.log(`✅ Bucket '${BUCKET_NAME}' verified!`);
|
console.log(`✅ Bucket '${BUCKET_NAME}' verified!`);
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('==========================================');
|
console.log('==========================================');
|
||||||
console.log('✅ Setup complete! The bucket is ready to use.');
|
console.log('✅ Setup complete! The bucket is ready to use.');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('You can now:');
|
console.log('You can now:');
|
||||||
console.log(' 1. Create CSV bookings via the frontend');
|
console.log(' 1. Create CSV bookings via the frontend');
|
||||||
console.log(' 2. Upload documents to this bucket');
|
console.log(' 2. Upload documents to this bucket');
|
||||||
console.log(' 3. View files at: http://localhost:9001 (MinIO Console)');
|
console.log(' 3. View files at: http://localhost:9001 (MinIO Console)');
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('');
|
console.error('');
|
||||||
console.error('❌ ERROR: Failed to setup bucket');
|
console.error('❌ ERROR: Failed to setup bucket');
|
||||||
console.error('');
|
console.error('');
|
||||||
console.error('Error details:');
|
console.error('Error details:');
|
||||||
console.error(` Name: ${error.name}`);
|
console.error(` Name: ${error.name}`);
|
||||||
console.error(` Message: ${error.message}`);
|
console.error(` Message: ${error.message}`);
|
||||||
if (error.$metadata) {
|
if (error.$metadata) {
|
||||||
console.error(` HTTP Status: ${error.$metadata.httpStatusCode}`);
|
console.error(` HTTP Status: ${error.$metadata.httpStatusCode}`);
|
||||||
}
|
}
|
||||||
console.error('');
|
console.error('');
|
||||||
console.error('Common solutions:');
|
console.error('Common solutions:');
|
||||||
console.error(' 1. Check if MinIO is running: docker ps | grep minio');
|
console.error(' 1. Check if MinIO is running: docker ps | grep minio');
|
||||||
console.error(' 2. Verify credentials in .env file');
|
console.error(' 2. Verify credentials in .env file');
|
||||||
console.error(' 3. Ensure AWS_S3_ENDPOINT is set correctly');
|
console.error(' 3. Ensure AWS_S3_ENDPOINT is set correctly');
|
||||||
console.error('');
|
console.error('');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupBucket();
|
setupBucket();
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { WebhooksModule } from './application/webhooks/webhooks.module';
|
|||||||
import { GDPRModule } from './application/gdpr/gdpr.module';
|
import { GDPRModule } from './application/gdpr/gdpr.module';
|
||||||
import { CsvBookingsModule } from './application/csv-bookings.module';
|
import { CsvBookingsModule } from './application/csv-bookings.module';
|
||||||
import { AdminModule } from './application/admin/admin.module';
|
import { AdminModule } from './application/admin/admin.module';
|
||||||
|
import { BlogModule } from './application/blog/blog.module';
|
||||||
import { LogsModule } from './application/logs/logs.module';
|
import { LogsModule } from './application/logs/logs.module';
|
||||||
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
|
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
|
||||||
import { ApiKeysModule } from './application/api-keys/api-keys.module';
|
import { ApiKeysModule } from './application/api-keys/api-keys.module';
|
||||||
@ -179,6 +180,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
GDPRModule,
|
GDPRModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
BlogModule,
|
||||||
SubscriptionsModule,
|
SubscriptionsModule,
|
||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
LogsModule,
|
LogsModule,
|
||||||
|
|||||||
@ -29,18 +29,20 @@ import { CsvBookingsModule } from '../csv-bookings.module';
|
|||||||
// Email
|
// Email
|
||||||
import { EmailModule } from '@infrastructure/email/email.module';
|
import { EmailModule } from '@infrastructure/email/email.module';
|
||||||
|
|
||||||
/**
|
// Blog
|
||||||
* Admin Module
|
import { BlogModule } from '../blog/blog.module';
|
||||||
*
|
|
||||||
* Provides admin-only endpoints for managing all data in the system.
|
// Storage
|
||||||
* All endpoints require ADMIN role.
|
import { StorageModule } from '@infrastructure/storage/storage.module';
|
||||||
*/
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]),
|
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]),
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
CsvBookingsModule,
|
CsvBookingsModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
|
BlogModule,
|
||||||
|
StorageModule,
|
||||||
],
|
],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
22
apps/backend/src/application/blog/blog.module.ts
Normal file
22
apps/backend/src/application/blog/blog.module.ts
Normal 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 {}
|
||||||
@ -6,6 +6,7 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Param,
|
Param,
|
||||||
Body,
|
Body,
|
||||||
|
Query,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
@ -15,14 +16,22 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
Inject,
|
Inject,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { memoryStorage } from 'multer';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import * as path from 'path';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiNotFoundResponse,
|
ApiNotFoundResponse,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
ApiConsumes,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
@ -56,6 +65,25 @@ import {
|
|||||||
// Email imports
|
// Email imports
|
||||||
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
||||||
|
|
||||||
|
// Blog imports
|
||||||
|
import { BlogService } from '../services/blog.service';
|
||||||
|
import { CreateBlogPostDto, UpdateBlogPostDto } from '../dto/blog-post.dto';
|
||||||
|
import { BlogPost } from '@domain/entities/blog-post.entity';
|
||||||
|
import type { BlogPostCategory } from '@domain/entities/blog-post.entity';
|
||||||
|
|
||||||
|
// Storage imports
|
||||||
|
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
|
||||||
|
|
||||||
|
const BLOG_IMAGES_BUCKET = 'xpeditis-blog';
|
||||||
|
const ALLOWED_IMAGE_MIMETYPES = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
'image/svg+xml',
|
||||||
|
];
|
||||||
|
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin Controller
|
* Admin Controller
|
||||||
*
|
*
|
||||||
@ -80,7 +108,9 @@ export class AdminController {
|
|||||||
private readonly csvBookingService: CsvBookingService,
|
private readonly csvBookingService: CsvBookingService,
|
||||||
@Inject(SIRET_VERIFICATION_PORT)
|
@Inject(SIRET_VERIFICATION_PORT)
|
||||||
private readonly siretVerificationPort: SiretVerificationPort,
|
private readonly siretVerificationPort: SiretVerificationPort,
|
||||||
@Inject(EMAIL_PORT) private readonly emailPort: EmailPort
|
@Inject(EMAIL_PORT) private readonly emailPort: EmailPort,
|
||||||
|
private readonly blogService: BlogService,
|
||||||
|
@Inject(STORAGE_PORT) private readonly storage: StoragePort
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ==================== USERS ENDPOINTS ====================
|
// ==================== USERS ENDPOINTS ====================
|
||||||
@ -912,4 +942,134 @@ export class AdminController {
|
|||||||
this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`);
|
this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`);
|
||||||
return { success: true, message: 'Document deleted successfully' };
|
return { success: true, message: 'Document deleted successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== BLOG ENDPOINTS ====================
|
||||||
|
|
||||||
|
@Post('blog/images')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('image', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: MAX_IMAGE_SIZE },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (ALLOWED_IMAGE_MIMETYPES.includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(
|
||||||
|
new BadRequestException('Only image files are allowed (jpg, png, webp, gif, svg)'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Upload a blog image to storage (Admin only)' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
schema: { properties: { url: { type: 'string' }, filename: { type: 'string' } } },
|
||||||
|
})
|
||||||
|
async uploadBlogImage(
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: UserPayload
|
||||||
|
): Promise<{ url: string; filename: string }> {
|
||||||
|
if (!file) throw new BadRequestException('No image file provided');
|
||||||
|
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Uploading blog image: ${file.originalname}`);
|
||||||
|
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
const sanitizedName = path
|
||||||
|
.basename(file.originalname, ext)
|
||||||
|
.replace(/[^a-z0-9]/gi, '-')
|
||||||
|
.toLowerCase();
|
||||||
|
const filename = `${uuidv4()}-${sanitizedName}${ext}`;
|
||||||
|
const key = `blog-images/${filename}`;
|
||||||
|
|
||||||
|
await this.storage.upload({
|
||||||
|
bucket: BLOG_IMAGES_BUCKET,
|
||||||
|
key,
|
||||||
|
body: file.buffer,
|
||||||
|
contentType: file.mimetype,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`[ADMIN] Blog image uploaded: ${key}`);
|
||||||
|
return { url: `/api/v1/blog/images/${filename}`, filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('blog')
|
||||||
|
@ApiOperation({ summary: 'List all blog posts (Admin only)' })
|
||||||
|
@ApiQuery({ name: 'status', required: false })
|
||||||
|
@ApiQuery({ name: 'category', required: false })
|
||||||
|
@ApiQuery({ name: 'search', required: false })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'offset', required: false, type: Number })
|
||||||
|
async listBlogPosts(
|
||||||
|
@Query('status') status?: any,
|
||||||
|
@Query('category') category?: BlogPostCategory,
|
||||||
|
@Query('search') search?: string,
|
||||||
|
@Query('limit') limit = 50,
|
||||||
|
@Query('offset') offset = 0,
|
||||||
|
@CurrentUser() user?: UserPayload
|
||||||
|
) {
|
||||||
|
this.logger.log(`[ADMIN: ${user?.email}] Listing blog posts`);
|
||||||
|
const { posts, total } = await this.blogService.listAllPosts({
|
||||||
|
status,
|
||||||
|
category,
|
||||||
|
search,
|
||||||
|
limit: Number(limit),
|
||||||
|
offset: Number(offset),
|
||||||
|
});
|
||||||
|
return { posts: posts.map(this.mapBlogPostToDto), total };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('blog')
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
|
@ApiOperation({ summary: 'Create a blog post (Admin only)' })
|
||||||
|
async createBlogPost(@Body() dto: CreateBlogPostDto, @CurrentUser() user: UserPayload) {
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Creating blog post: ${dto.slug}`);
|
||||||
|
const post = await this.blogService.createPost(dto);
|
||||||
|
return this.mapBlogPostToDto(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('blog/:id')
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
|
@ApiOperation({ summary: 'Update a blog post (Admin only)' })
|
||||||
|
async updateBlogPost(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateBlogPostDto,
|
||||||
|
@CurrentUser() user: UserPayload
|
||||||
|
) {
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Updating blog post: ${id}`);
|
||||||
|
const post = await this.blogService.updatePost(id, dto);
|
||||||
|
return this.mapBlogPostToDto(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('blog/:id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: 'Delete a blog post (Admin only)' })
|
||||||
|
async deleteBlogPost(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: UserPayload
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Deleting blog post: ${id}`);
|
||||||
|
await this.blogService.deletePost(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapBlogPostToDto(post: BlogPost) {
|
||||||
|
return {
|
||||||
|
id: post.id,
|
||||||
|
title: post.title,
|
||||||
|
slug: post.slug,
|
||||||
|
excerpt: post.excerpt,
|
||||||
|
content: post.content,
|
||||||
|
coverImageUrl: post.coverImageUrl,
|
||||||
|
category: post.category,
|
||||||
|
tags: post.tags,
|
||||||
|
authorName: post.authorName,
|
||||||
|
status: post.status,
|
||||||
|
isFeatured: post.isFeatured,
|
||||||
|
publishedAt: post.publishedAt,
|
||||||
|
createdAt: post.createdAt,
|
||||||
|
updatedAt: post.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
131
apps/backend/src/application/controllers/blog.controller.ts
Normal file
131
apps/backend/src/application/controllers/blog.controller.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
150
apps/backend/src/application/dto/blog-post.dto.ts
Normal file
150
apps/backend/src/application/dto/blog-post.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
139
apps/backend/src/application/services/blog.service.ts
Normal file
139
apps/backend/src/application/services/blog.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
132
apps/backend/src/domain/entities/blog-post.entity.ts
Normal file
132
apps/backend/src/domain/entities/blog-post.entity.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/backend/src/domain/ports/out/blog-post.repository.ts
Normal file
22
apps/backend/src/domain/ports/out/blog-post.repository.ts
Normal 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>;
|
||||||
|
}
|
||||||
@ -66,4 +66,9 @@ export interface StoragePort {
|
|||||||
* List objects in a bucket
|
* List objects in a bucket
|
||||||
*/
|
*/
|
||||||
list(bucket: string, prefix?: string): Promise<StorageObject[]>;
|
list(bucket: string, prefix?: string): Promise<StorageObject[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a bucket exists, creating it if it does not
|
||||||
|
*/
|
||||||
|
ensureBucket(bucket: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,8 @@ import {
|
|||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
HeadObjectCommand,
|
HeadObjectCommand,
|
||||||
|
HeadBucketCommand,
|
||||||
|
CreateBucketCommand,
|
||||||
ListObjectsV2Command,
|
ListObjectsV2Command,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
@ -70,6 +72,23 @@ export class S3StorageAdapter implements StoragePort {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async ensureBucket(bucket: string): Promise<void> {
|
||||||
|
if (!this.s3Client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.s3Client.send(new HeadBucketCommand({ Bucket: bucket }));
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err.$metadata?.httpStatusCode;
|
||||||
|
if (status === 404 || err.name === 'NoSuchBucket' || err.name === 'NotFound') {
|
||||||
|
this.logger.log(`Bucket "${bucket}" not found — creating it automatically`);
|
||||||
|
await this.s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||||
|
this.logger.log(`Bucket "${bucket}" created`);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async upload(options: UploadOptions): Promise<StorageObject> {
|
async upload(options: UploadOptions): Promise<StorageObject> {
|
||||||
if (!this.s3Client) {
|
if (!this.s3Client) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@ -77,6 +96,8 @@ export class S3StorageAdapter implements StoragePort {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.ensureBucket(options.bucket);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
Bucket: options.bucket,
|
Bucket: options.bucket,
|
||||||
@ -108,6 +129,12 @@ export class S3StorageAdapter implements StoragePort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async download(options: DownloadOptions): Promise<Buffer> {
|
async download(options: DownloadOptions): Promise<Buffer> {
|
||||||
|
if (!this.s3Client) {
|
||||||
|
throw new Error(
|
||||||
|
'S3 Storage is not configured. Set AWS_S3_ENDPOINT or AWS credentials in .env'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
Bucket: options.bucket,
|
Bucket: options.bucket,
|
||||||
|
|||||||
@ -1,102 +1,102 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
const { Client } = require('pg');
|
const { Client } = require('pg');
|
||||||
const { DataSource } = require('typeorm');
|
const { DataSource } = require('typeorm');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
async function waitForPostgres(maxAttempts = 30) {
|
async function waitForPostgres(maxAttempts = 30) {
|
||||||
console.log('⏳ Waiting for PostgreSQL to be ready...');
|
console.log('⏳ Waiting for PostgreSQL to be ready...');
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
host: process.env.DATABASE_HOST,
|
host: process.env.DATABASE_HOST,
|
||||||
port: parseInt(process.env.DATABASE_PORT, 10),
|
port: parseInt(process.env.DATABASE_PORT, 10),
|
||||||
user: process.env.DATABASE_USER,
|
user: process.env.DATABASE_USER,
|
||||||
password: process.env.DATABASE_PASSWORD,
|
password: process.env.DATABASE_PASSWORD,
|
||||||
database: process.env.DATABASE_NAME,
|
database: process.env.DATABASE_NAME,
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.connect();
|
await client.connect();
|
||||||
await client.end();
|
await client.end();
|
||||||
console.log('✅ PostgreSQL is ready');
|
console.log('✅ PostgreSQL is ready');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`⏳ Attempt ${attempt}/${maxAttempts} - PostgreSQL not ready, retrying...`);
|
console.log(`⏳ Attempt ${attempt}/${maxAttempts} - PostgreSQL not ready, retrying...`);
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('❌ Failed to connect to PostgreSQL after', maxAttempts, 'attempts');
|
console.error('❌ Failed to connect to PostgreSQL after', maxAttempts, 'attempts');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runMigrations() {
|
async function runMigrations() {
|
||||||
console.log('🔄 Running database migrations...');
|
console.log('🔄 Running database migrations...');
|
||||||
|
|
||||||
const AppDataSource = new DataSource({
|
const AppDataSource = new DataSource({
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
host: process.env.DATABASE_HOST,
|
host: process.env.DATABASE_HOST,
|
||||||
port: parseInt(process.env.DATABASE_PORT, 10),
|
port: parseInt(process.env.DATABASE_PORT, 10),
|
||||||
username: process.env.DATABASE_USER,
|
username: process.env.DATABASE_USER,
|
||||||
password: process.env.DATABASE_PASSWORD,
|
password: process.env.DATABASE_PASSWORD,
|
||||||
database: process.env.DATABASE_NAME,
|
database: process.env.DATABASE_NAME,
|
||||||
entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')],
|
entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')],
|
||||||
migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')],
|
migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
logging: true,
|
logging: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await AppDataSource.initialize();
|
await AppDataSource.initialize();
|
||||||
console.log('✅ DataSource initialized');
|
console.log('✅ DataSource initialized');
|
||||||
|
|
||||||
const migrations = await AppDataSource.runMigrations();
|
const migrations = await AppDataSource.runMigrations();
|
||||||
|
|
||||||
if (migrations.length === 0) {
|
if (migrations.length === 0) {
|
||||||
console.log('✅ No pending migrations');
|
console.log('✅ No pending migrations');
|
||||||
} else {
|
} else {
|
||||||
console.log(`✅ Successfully ran ${migrations.length} migration(s):`);
|
console.log(`✅ Successfully ran ${migrations.length} migration(s):`);
|
||||||
migrations.forEach((migration) => {
|
migrations.forEach(migration => {
|
||||||
console.log(` - ${migration.name}`);
|
console.log(` - ${migration.name}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await AppDataSource.destroy();
|
await AppDataSource.destroy();
|
||||||
console.log('✅ Database migrations completed');
|
console.log('✅ Database migrations completed');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error during migration:', error);
|
console.error('❌ Error during migration:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startApplication() {
|
function startApplication() {
|
||||||
console.log('🚀 Starting NestJS application...');
|
console.log('🚀 Starting NestJS application...');
|
||||||
|
|
||||||
const app = spawn('node', ['dist/main'], {
|
const app = spawn('node', ['dist/main'], {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: process.env
|
env: process.env,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('exit', (code) => {
|
app.on('exit', code => {
|
||||||
process.exit(code);
|
process.exit(code);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('SIGTERM', () => app.kill('SIGTERM'));
|
process.on('SIGTERM', () => app.kill('SIGTERM'));
|
||||||
process.on('SIGINT', () => app.kill('SIGINT'));
|
process.on('SIGINT', () => app.kill('SIGINT'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🚀 Starting Xpeditis Backend...');
|
console.log('🚀 Starting Xpeditis Backend...');
|
||||||
|
|
||||||
await waitForPostgres();
|
await waitForPostgres();
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
startApplication();
|
startApplication();
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch(error => {
|
||||||
console.error('❌ Startup failed:', error);
|
console.error('❌ Startup failed:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,154 +1,154 @@
|
|||||||
/**
|
/**
|
||||||
* Script to sync database with MinIO
|
* Script to sync database with MinIO
|
||||||
*
|
*
|
||||||
* Removes document references from database for files that no longer exist in MinIO
|
* Removes document references from database for files that no longer exist in MinIO
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
const { Client } = require('pg');
|
const { Client } = require('pg');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
||||||
const BUCKET_NAME = 'xpeditis-documents';
|
const BUCKET_NAME = 'xpeditis-documents';
|
||||||
|
|
||||||
// Initialize MinIO client
|
// Initialize MinIO client
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
endpoint: MINIO_ENDPOINT,
|
endpoint: MINIO_ENDPOINT,
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
||||||
},
|
},
|
||||||
forcePathStyle: true,
|
forcePathStyle: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function syncDatabase() {
|
async function syncDatabase() {
|
||||||
const pgClient = new Client({
|
const pgClient = new Client({
|
||||||
host: process.env.DATABASE_HOST || 'localhost',
|
host: process.env.DATABASE_HOST || 'localhost',
|
||||||
port: process.env.DATABASE_PORT || 5432,
|
port: process.env.DATABASE_PORT || 5432,
|
||||||
user: process.env.DATABASE_USER || 'xpeditis',
|
user: process.env.DATABASE_USER || 'xpeditis',
|
||||||
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
||||||
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pgClient.connect();
|
await pgClient.connect();
|
||||||
console.log('✅ Connected to database\n');
|
console.log('✅ Connected to database\n');
|
||||||
|
|
||||||
// Get all MinIO files
|
// Get all MinIO files
|
||||||
console.log('📋 Listing files in MinIO...');
|
console.log('📋 Listing files in MinIO...');
|
||||||
let allMinioFiles = [];
|
let allMinioFiles = [];
|
||||||
let continuationToken = null;
|
let continuationToken = null;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const command = new ListObjectsV2Command({
|
const command = new ListObjectsV2Command({
|
||||||
Bucket: BUCKET_NAME,
|
Bucket: BUCKET_NAME,
|
||||||
ContinuationToken: continuationToken,
|
ContinuationToken: continuationToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await s3Client.send(command);
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
if (response.Contents) {
|
if (response.Contents) {
|
||||||
allMinioFiles = allMinioFiles.concat(response.Contents.map(f => f.Key));
|
allMinioFiles = allMinioFiles.concat(response.Contents.map(f => f.Key));
|
||||||
}
|
}
|
||||||
|
|
||||||
continuationToken = response.NextContinuationToken;
|
continuationToken = response.NextContinuationToken;
|
||||||
} while (continuationToken);
|
} while (continuationToken);
|
||||||
|
|
||||||
console.log(` Found ${allMinioFiles.length} files in MinIO\n`);
|
console.log(` Found ${allMinioFiles.length} files in MinIO\n`);
|
||||||
|
|
||||||
// Create a set for faster lookup
|
// Create a set for faster lookup
|
||||||
const minioFilesSet = new Set(allMinioFiles);
|
const minioFilesSet = new Set(allMinioFiles);
|
||||||
|
|
||||||
// Get all bookings with documents
|
// Get all bookings with documents
|
||||||
const result = await pgClient.query(
|
const result = await pgClient.query(
|
||||||
`SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND jsonb_array_length(documents::jsonb) > 0`
|
`SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND jsonb_array_length(documents::jsonb) > 0`
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`📄 Found ${result.rows.length} bookings with documents in database\n`);
|
console.log(`📄 Found ${result.rows.length} bookings with documents in database\n`);
|
||||||
|
|
||||||
let updatedCount = 0;
|
let updatedCount = 0;
|
||||||
let removedDocsCount = 0;
|
let removedDocsCount = 0;
|
||||||
let emptyBookingsCount = 0;
|
let emptyBookingsCount = 0;
|
||||||
|
|
||||||
for (const row of result.rows) {
|
for (const row of result.rows) {
|
||||||
const bookingId = row.id;
|
const bookingId = row.id;
|
||||||
const documents = row.documents;
|
const documents = row.documents;
|
||||||
|
|
||||||
// Filter documents to keep only those that exist in MinIO
|
// Filter documents to keep only those that exist in MinIO
|
||||||
const validDocuments = [];
|
const validDocuments = [];
|
||||||
const missingDocuments = [];
|
const missingDocuments = [];
|
||||||
|
|
||||||
for (const doc of documents) {
|
for (const doc of documents) {
|
||||||
if (!doc.filePath) {
|
if (!doc.filePath) {
|
||||||
missingDocuments.push(doc);
|
missingDocuments.push(doc);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the S3 key from the URL
|
// Extract the S3 key from the URL
|
||||||
try {
|
try {
|
||||||
const url = new URL(doc.filePath);
|
const url = new URL(doc.filePath);
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
// Remove leading slash and bucket name
|
// Remove leading slash and bucket name
|
||||||
const key = pathname.substring(1).replace(`${BUCKET_NAME}/`, '');
|
const key = pathname.substring(1).replace(`${BUCKET_NAME}/`, '');
|
||||||
|
|
||||||
if (minioFilesSet.has(key)) {
|
if (minioFilesSet.has(key)) {
|
||||||
validDocuments.push(doc);
|
validDocuments.push(doc);
|
||||||
} else {
|
} else {
|
||||||
missingDocuments.push(doc);
|
missingDocuments.push(doc);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(` ⚠️ Invalid URL for booking ${bookingId}: ${doc.filePath}`);
|
console.error(` ⚠️ Invalid URL for booking ${bookingId}: ${doc.filePath}`);
|
||||||
missingDocuments.push(doc);
|
missingDocuments.push(doc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missingDocuments.length > 0) {
|
if (missingDocuments.length > 0) {
|
||||||
console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`);
|
console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`);
|
||||||
console.log(` Total documents: ${documents.length}`);
|
console.log(` Total documents: ${documents.length}`);
|
||||||
console.log(` Valid documents: ${validDocuments.length}`);
|
console.log(` Valid documents: ${validDocuments.length}`);
|
||||||
console.log(` Missing documents: ${missingDocuments.length}`);
|
console.log(` Missing documents: ${missingDocuments.length}`);
|
||||||
|
|
||||||
missingDocuments.forEach(doc => {
|
missingDocuments.forEach(doc => {
|
||||||
console.log(` ❌ ${doc.fileName || 'Unknown'}`);
|
console.log(` ❌ ${doc.fileName || 'Unknown'}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the database
|
// Update the database
|
||||||
await pgClient.query(
|
await pgClient.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
|
||||||
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
|
JSON.stringify(validDocuments),
|
||||||
[JSON.stringify(validDocuments), bookingId]
|
bookingId,
|
||||||
);
|
]);
|
||||||
|
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
removedDocsCount += missingDocuments.length;
|
removedDocsCount += missingDocuments.length;
|
||||||
|
|
||||||
if (validDocuments.length === 0) {
|
if (validDocuments.length === 0) {
|
||||||
emptyBookingsCount++;
|
emptyBookingsCount++;
|
||||||
console.log(` ⚠️ This booking now has NO documents`);
|
console.log(` ⚠️ This booking now has NO documents`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n📊 Summary:`);
|
console.log(`\n📊 Summary:`);
|
||||||
console.log(` Bookings updated: ${updatedCount}`);
|
console.log(` Bookings updated: ${updatedCount}`);
|
||||||
console.log(` Documents removed from DB: ${removedDocsCount}`);
|
console.log(` Documents removed from DB: ${removedDocsCount}`);
|
||||||
console.log(` Bookings with no documents: ${emptyBookingsCount}`);
|
console.log(` Bookings with no documents: ${emptyBookingsCount}`);
|
||||||
console.log(`\n✅ Database synchronized with MinIO`);
|
console.log(`\n✅ Database synchronized with MinIO`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error:', error);
|
console.error('❌ Error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await pgClient.end();
|
await pgClient.end();
|
||||||
console.log('\n👋 Disconnected from database');
|
console.log('\n👋 Disconnected from database');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
syncDatabase()
|
syncDatabase()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('\n✅ Script completed successfully');
|
console.log('\n✅ Script completed successfully');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error('\n❌ Script failed:', error);
|
console.error('\n❌ Script failed:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,7 +12,7 @@ const API_BASE = 'http://localhost:4000/api/v1';
|
|||||||
// Test credentials - you need to use real credentials from your database
|
// Test credentials - you need to use real credentials from your database
|
||||||
const TEST_USER = {
|
const TEST_USER = {
|
||||||
email: 'admin@xpeditis.com', // Change this to a real user email
|
email: 'admin@xpeditis.com', // Change this to a real user email
|
||||||
password: 'Admin123!', // Change this to the real password
|
password: 'Admin123!', // Change this to the real password
|
||||||
};
|
};
|
||||||
|
|
||||||
async function testWorkflow() {
|
async function testWorkflow() {
|
||||||
@ -56,16 +56,12 @@ async function testWorkflow() {
|
|||||||
contentType: 'application/pdf',
|
contentType: 'application/pdf',
|
||||||
});
|
});
|
||||||
|
|
||||||
const bookingResponse = await axios.post(
|
const bookingResponse = await axios.post(`${API_BASE}/csv-bookings`, form, {
|
||||||
`${API_BASE}/csv-bookings`,
|
headers: {
|
||||||
form,
|
...form.getHeaders(),
|
||||||
{
|
Authorization: `Bearer ${token}`,
|
||||||
headers: {
|
},
|
||||||
...form.getHeaders(),
|
});
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('✅ Booking created successfully!');
|
console.log('✅ Booking created successfully!');
|
||||||
console.log('📦 Booking ID:', bookingResponse.data.id);
|
console.log('📦 Booking ID:', bookingResponse.data.id);
|
||||||
@ -80,7 +76,9 @@ async function testWorkflow() {
|
|||||||
console.error('❌ Error:', error.response?.data || error.message);
|
console.error('❌ Error:', error.response?.data || error.message);
|
||||||
|
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
console.error('\n⚠️ Authentication failed. Please update TEST_USER credentials in the script.');
|
console.error(
|
||||||
|
'\n⚠️ Authentication failed. Please update TEST_USER credentials in the script.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.response?.status === 400) {
|
if (error.response?.status === 400) {
|
||||||
|
|||||||
@ -1,228 +1,230 @@
|
|||||||
/**
|
/**
|
||||||
* Script de test pour vérifier l'envoi d'email aux transporteurs
|
* Script de test pour vérifier l'envoi d'email aux transporteurs
|
||||||
*
|
*
|
||||||
* Usage: node test-carrier-email-fix.js
|
* Usage: node test-carrier-email-fix.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
|
|
||||||
async function testEmailConfig() {
|
async function testEmailConfig() {
|
||||||
console.log('🔍 Test de configuration email Mailtrap...\n');
|
console.log('🔍 Test de configuration email Mailtrap...\n');
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
host: process.env.SMTP_HOST || 'sandbox.smtp.mailtrap.io',
|
host: process.env.SMTP_HOST || 'sandbox.smtp.mailtrap.io',
|
||||||
port: parseInt(process.env.SMTP_PORT || '2525'),
|
port: parseInt(process.env.SMTP_PORT || '2525'),
|
||||||
user: process.env.SMTP_USER || '2597bd31d265eb',
|
user: process.env.SMTP_USER || '2597bd31d265eb',
|
||||||
pass: process.env.SMTP_PASS || 'cd126234193c89',
|
pass: process.env.SMTP_PASS || 'cd126234193c89',
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('📧 Configuration SMTP:');
|
console.log('📧 Configuration SMTP:');
|
||||||
console.log(` Host: ${config.host}`);
|
console.log(` Host: ${config.host}`);
|
||||||
console.log(` Port: ${config.port}`);
|
console.log(` Port: ${config.port}`);
|
||||||
console.log(` User: ${config.user}`);
|
console.log(` User: ${config.user}`);
|
||||||
console.log(` Pass: ${config.pass.substring(0, 4)}***\n`);
|
console.log(` Pass: ${config.pass.substring(0, 4)}***\n`);
|
||||||
|
|
||||||
// Test 1: Configuration standard (peut échouer avec timeout DNS)
|
// Test 1: Configuration standard (peut échouer avec timeout DNS)
|
||||||
console.log('Test 1: Configuration standard...');
|
console.log('Test 1: Configuration standard...');
|
||||||
try {
|
try {
|
||||||
const transporter1 = nodemailer.createTransport({
|
const transporter1 = nodemailer.createTransport({
|
||||||
host: config.host,
|
host: config.host,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
secure: false,
|
secure: false,
|
||||||
auth: {
|
auth: {
|
||||||
user: config.user,
|
user: config.user,
|
||||||
pass: config.pass,
|
pass: config.pass,
|
||||||
},
|
},
|
||||||
connectionTimeout: 10000,
|
connectionTimeout: 10000,
|
||||||
greetingTimeout: 10000,
|
greetingTimeout: 10000,
|
||||||
socketTimeout: 30000,
|
socketTimeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter1.sendMail({
|
await transporter1.sendMail({
|
||||||
from: 'noreply@xpeditis.com',
|
from: 'noreply@xpeditis.com',
|
||||||
to: 'test@xpeditis.com',
|
to: 'test@xpeditis.com',
|
||||||
subject: 'Test Email - Configuration Standard',
|
subject: 'Test Email - Configuration Standard',
|
||||||
html: '<h1>Test réussi!</h1><p>Configuration standard fonctionne.</p>',
|
html: '<h1>Test réussi!</h1><p>Configuration standard fonctionne.</p>',
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ Test 1 RÉUSSI - Configuration standard OK\n');
|
console.log('✅ Test 1 RÉUSSI - Configuration standard OK\n');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Test 1 ÉCHOUÉ:', error.message);
|
console.error('❌ Test 1 ÉCHOUÉ:', error.message);
|
||||||
console.error(' Code:', error.code);
|
console.error(' Code:', error.code);
|
||||||
console.error(' Timeout?', error.message.includes('ETIMEOUT'));
|
console.error(' Timeout?', error.message.includes('ETIMEOUT'));
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test 2: Configuration avec IP directe (devrait toujours fonctionner)
|
// Test 2: Configuration avec IP directe (devrait toujours fonctionner)
|
||||||
console.log('Test 2: Configuration avec IP directe...');
|
console.log('Test 2: Configuration avec IP directe...');
|
||||||
try {
|
try {
|
||||||
const useDirectIP = config.host.includes('mailtrap.io');
|
const useDirectIP = config.host.includes('mailtrap.io');
|
||||||
const actualHost = useDirectIP ? '3.209.246.195' : config.host;
|
const actualHost = useDirectIP ? '3.209.246.195' : config.host;
|
||||||
const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host;
|
const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host;
|
||||||
|
|
||||||
console.log(` Utilisation IP directe: ${useDirectIP}`);
|
console.log(` Utilisation IP directe: ${useDirectIP}`);
|
||||||
console.log(` Host réel: ${actualHost}`);
|
console.log(` Host réel: ${actualHost}`);
|
||||||
console.log(` Server name (TLS): ${serverName}`);
|
console.log(` Server name (TLS): ${serverName}`);
|
||||||
|
|
||||||
const transporter2 = nodemailer.createTransport({
|
const transporter2 = nodemailer.createTransport({
|
||||||
host: actualHost,
|
host: actualHost,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
secure: false,
|
secure: false,
|
||||||
auth: {
|
auth: {
|
||||||
user: config.user,
|
user: config.user,
|
||||||
pass: config.pass,
|
pass: config.pass,
|
||||||
},
|
},
|
||||||
tls: {
|
tls: {
|
||||||
rejectUnauthorized: false,
|
rejectUnauthorized: false,
|
||||||
servername: serverName,
|
servername: serverName,
|
||||||
},
|
},
|
||||||
connectionTimeout: 10000,
|
connectionTimeout: 10000,
|
||||||
greetingTimeout: 10000,
|
greetingTimeout: 10000,
|
||||||
socketTimeout: 30000,
|
socketTimeout: 30000,
|
||||||
dnsTimeout: 10000,
|
dnsTimeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await transporter2.sendMail({
|
const result = await transporter2.sendMail({
|
||||||
from: 'noreply@xpeditis.com',
|
from: 'noreply@xpeditis.com',
|
||||||
to: 'test@xpeditis.com',
|
to: 'test@xpeditis.com',
|
||||||
subject: 'Test Email - Configuration IP Directe',
|
subject: 'Test Email - Configuration IP Directe',
|
||||||
html: '<h1>Test réussi!</h1><p>Configuration avec IP directe fonctionne.</p>',
|
html: '<h1>Test réussi!</h1><p>Configuration avec IP directe fonctionne.</p>',
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ Test 2 RÉUSSI - Configuration IP directe OK');
|
console.log('✅ Test 2 RÉUSSI - Configuration IP directe OK');
|
||||||
console.log(` Message ID: ${result.messageId}`);
|
console.log(` Message ID: ${result.messageId}`);
|
||||||
console.log(` Response: ${result.response}\n`);
|
console.log(` Response: ${result.response}\n`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Test 2 ÉCHOUÉ:', error.message);
|
console.error('❌ Test 2 ÉCHOUÉ:', error.message);
|
||||||
console.error(' Code:', error.code);
|
console.error(' Code:', error.code);
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test 3: Template HTML de booking transporteur
|
// Test 3: Template HTML de booking transporteur
|
||||||
console.log('Test 3: Envoi avec template HTML complet...');
|
console.log('Test 3: Envoi avec template HTML complet...');
|
||||||
try {
|
try {
|
||||||
const useDirectIP = config.host.includes('mailtrap.io');
|
const useDirectIP = config.host.includes('mailtrap.io');
|
||||||
const actualHost = useDirectIP ? '3.209.246.195' : config.host;
|
const actualHost = useDirectIP ? '3.209.246.195' : config.host;
|
||||||
const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host;
|
const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host;
|
||||||
|
|
||||||
const transporter3 = nodemailer.createTransport({
|
const transporter3 = nodemailer.createTransport({
|
||||||
host: actualHost,
|
host: actualHost,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
secure: false,
|
secure: false,
|
||||||
auth: {
|
auth: {
|
||||||
user: config.user,
|
user: config.user,
|
||||||
pass: config.pass,
|
pass: config.pass,
|
||||||
},
|
},
|
||||||
tls: {
|
tls: {
|
||||||
rejectUnauthorized: false,
|
rejectUnauthorized: false,
|
||||||
servername: serverName,
|
servername: serverName,
|
||||||
},
|
},
|
||||||
connectionTimeout: 10000,
|
connectionTimeout: 10000,
|
||||||
greetingTimeout: 10000,
|
greetingTimeout: 10000,
|
||||||
socketTimeout: 30000,
|
socketTimeout: 30000,
|
||||||
dnsTimeout: 10000,
|
dnsTimeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bookingData = {
|
const bookingData = {
|
||||||
bookingId: 'TEST-' + Date.now(),
|
bookingId: 'TEST-' + Date.now(),
|
||||||
origin: 'FRPAR',
|
origin: 'FRPAR',
|
||||||
destination: 'USNYC',
|
destination: 'USNYC',
|
||||||
volumeCBM: 10.5,
|
volumeCBM: 10.5,
|
||||||
weightKG: 850,
|
weightKG: 850,
|
||||||
palletCount: 4,
|
palletCount: 4,
|
||||||
priceUSD: 1500,
|
priceUSD: 1500,
|
||||||
priceEUR: 1350,
|
priceEUR: 1350,
|
||||||
primaryCurrency: 'USD',
|
primaryCurrency: 'USD',
|
||||||
transitDays: 15,
|
transitDays: 15,
|
||||||
containerType: '20FT',
|
containerType: '20FT',
|
||||||
documents: [
|
documents: [
|
||||||
{ type: 'Bill of Lading', fileName: 'bol.pdf' },
|
{ type: 'Bill of Lading', fileName: 'bol.pdf' },
|
||||||
{ type: 'Packing List', fileName: 'packing_list.pdf' },
|
{ type: 'Packing List', fileName: 'packing_list.pdf' },
|
||||||
],
|
],
|
||||||
acceptUrl: 'http://localhost:3000/carrier/booking/accept',
|
acceptUrl: 'http://localhost:3000/carrier/booking/accept',
|
||||||
rejectUrl: 'http://localhost:3000/carrier/booking/reject',
|
rejectUrl: 'http://localhost:3000/carrier/booking/reject',
|
||||||
};
|
};
|
||||||
|
|
||||||
const htmlTemplate = `
|
const htmlTemplate = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head><meta charset="UTF-8"></head>
|
<head><meta charset="UTF-8"></head>
|
||||||
<body style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;">
|
<body style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;">
|
||||||
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden;">
|
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden;">
|
||||||
<div style="background: linear-gradient(135deg, #045a8d, #00bcd4); color: white; padding: 30px; text-align: center;">
|
<div style="background: linear-gradient(135deg, #045a8d, #00bcd4); color: white; padding: 30px; text-align: center;">
|
||||||
<h1 style="margin: 0;">🚢 Nouvelle demande de réservation</h1>
|
<h1 style="margin: 0;">🚢 Nouvelle demande de réservation</h1>
|
||||||
<p style="margin: 5px 0 0;">Xpeditis</p>
|
<p style="margin: 5px 0 0;">Xpeditis</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding: 30px;">
|
<div style="padding: 30px;">
|
||||||
<p style="font-size: 16px;">Bonjour,</p>
|
<p style="font-size: 16px;">Bonjour,</p>
|
||||||
<p>Vous avez reçu une nouvelle demande de réservation via Xpeditis.</p>
|
<p>Vous avez reçu une nouvelle demande de réservation via Xpeditis.</p>
|
||||||
<h2 style="color: #045a8d; border-bottom: 2px solid #00bcd4; padding-bottom: 8px;">📋 Détails du transport</h2>
|
<h2 style="color: #045a8d; border-bottom: 2px solid #00bcd4; padding-bottom: 8px;">📋 Détails du transport</h2>
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
<tr style="border-bottom: 1px solid #e0e0e0;">
|
<tr style="border-bottom: 1px solid #e0e0e0;">
|
||||||
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Route</td>
|
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Route</td>
|
||||||
<td style="padding: 12px;">${bookingData.origin} → ${bookingData.destination}</td>
|
<td style="padding: 12px;">${bookingData.origin} → ${bookingData.destination}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style="border-bottom: 1px solid #e0e0e0;">
|
<tr style="border-bottom: 1px solid #e0e0e0;">
|
||||||
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Volume</td>
|
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Volume</td>
|
||||||
<td style="padding: 12px;">${bookingData.volumeCBM} CBM</td>
|
<td style="padding: 12px;">${bookingData.volumeCBM} CBM</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style="border-bottom: 1px solid #e0e0e0;">
|
<tr style="border-bottom: 1px solid #e0e0e0;">
|
||||||
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Prix</td>
|
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Prix</td>
|
||||||
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #00aa00;">
|
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #00aa00;">
|
||||||
${bookingData.priceUSD} USD
|
${bookingData.priceUSD} USD
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<div style="text-align: center; margin: 30px 0;">
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
<p style="font-weight: bold;">Veuillez confirmer votre décision :</p>
|
<p style="font-weight: bold;">Veuillez confirmer votre décision :</p>
|
||||||
<a href="${bookingData.acceptUrl}" style="display: inline-block; padding: 15px 30px; background: #00aa00; color: white; text-decoration: none; border-radius: 6px; margin: 0 5px;">✓ Accepter</a>
|
<a href="${bookingData.acceptUrl}" style="display: inline-block; padding: 15px 30px; background: #00aa00; color: white; text-decoration: none; border-radius: 6px; margin: 0 5px;">✓ Accepter</a>
|
||||||
<a href="${bookingData.rejectUrl}" style="display: inline-block; padding: 15px 30px; background: #cc0000; color: white; text-decoration: none; border-radius: 6px; margin: 0 5px;">✗ Refuser</a>
|
<a href="${bookingData.rejectUrl}" style="display: inline-block; padding: 15px 30px; background: #cc0000; color: white; text-decoration: none; border-radius: 6px; margin: 0 5px;">✗ Refuser</a>
|
||||||
</div>
|
</div>
|
||||||
<div style="background: #fff8e1; border-left: 4px solid #f57c00; padding: 15px; margin: 20px 0;">
|
<div style="background: #fff8e1; border-left: 4px solid #f57c00; padding: 15px; margin: 20px 0;">
|
||||||
<p style="margin: 0; font-size: 14px; color: #666;">
|
<p style="margin: 0; font-size: 14px; color: #666;">
|
||||||
<strong style="color: #f57c00;">⚠️ Important</strong><br>
|
<strong style="color: #f57c00;">⚠️ Important</strong><br>
|
||||||
Cette demande expire automatiquement dans 7 jours si aucune action n'est prise.
|
Cette demande expire automatiquement dans 7 jours si aucune action n'est prise.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="background: #f4f6f8; padding: 20px; text-align: center; font-size: 12px; color: #666;">
|
<div style="background: #f4f6f8; padding: 20px; text-align: center; font-size: 12px; color: #666;">
|
||||||
<p style="margin: 5px 0; font-weight: bold; color: #045a8d;">Référence : ${bookingData.bookingId}</p>
|
<p style="margin: 5px 0; font-weight: bold; color: #045a8d;">Référence : ${bookingData.bookingId}</p>
|
||||||
<p style="margin: 5px 0;">© 2025 Xpeditis. Tous droits réservés.</p>
|
<p style="margin: 5px 0;">© 2025 Xpeditis. Tous droits réservés.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await transporter3.sendMail({
|
const result = await transporter3.sendMail({
|
||||||
from: 'noreply@xpeditis.com',
|
from: 'noreply@xpeditis.com',
|
||||||
to: 'carrier@test.com',
|
to: 'carrier@test.com',
|
||||||
subject: `Nouvelle demande de réservation - ${bookingData.origin} → ${bookingData.destination}`,
|
subject: `Nouvelle demande de réservation - ${bookingData.origin} → ${bookingData.destination}`,
|
||||||
html: htmlTemplate,
|
html: htmlTemplate,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ Test 3 RÉUSSI - Email complet avec template envoyé');
|
console.log('✅ Test 3 RÉUSSI - Email complet avec template envoyé');
|
||||||
console.log(` Message ID: ${result.messageId}`);
|
console.log(` Message ID: ${result.messageId}`);
|
||||||
console.log(` Response: ${result.response}\n`);
|
console.log(` Response: ${result.response}\n`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Test 3 ÉCHOUÉ:', error.message);
|
console.error('❌ Test 3 ÉCHOUÉ:', error.message);
|
||||||
console.error(' Code:', error.code);
|
console.error(' Code:', error.code);
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📊 Résumé des tests:');
|
console.log('📊 Résumé des tests:');
|
||||||
console.log(' ✓ Vérifiez Mailtrap inbox: https://mailtrap.io/inboxes');
|
console.log(' ✓ Vérifiez Mailtrap inbox: https://mailtrap.io/inboxes');
|
||||||
console.log(' ✓ Recherchez les emails de test ci-dessus');
|
console.log(' ✓ Recherchez les emails de test ci-dessus');
|
||||||
console.log(' ✓ Si Test 2 et 3 réussissent, le backend doit être corrigé avec la configuration IP directe\n');
|
console.log(
|
||||||
}
|
' ✓ Si Test 2 et 3 réussissent, le backend doit être corrigé avec la configuration IP directe\n'
|
||||||
|
);
|
||||||
// Run test
|
}
|
||||||
testEmailConfig()
|
|
||||||
.then(() => {
|
// Run test
|
||||||
console.log('✅ Tests terminés avec succès');
|
testEmailConfig()
|
||||||
process.exit(0);
|
.then(() => {
|
||||||
})
|
console.log('✅ Tests terminés avec succès');
|
||||||
.catch((error) => {
|
process.exit(0);
|
||||||
console.error('❌ Erreur lors des tests:', error);
|
})
|
||||||
process.exit(1);
|
.catch(error => {
|
||||||
});
|
console.error('❌ Erreur lors des tests:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@ -5,25 +5,28 @@ const transporter = nodemailer.createTransport({
|
|||||||
port: 2525,
|
port: 2525,
|
||||||
auth: {
|
auth: {
|
||||||
user: '2597bd31d265eb',
|
user: '2597bd31d265eb',
|
||||||
pass: 'cd126234193c89'
|
pass: 'cd126234193c89',
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('🔄 Tentative d\'envoi d\'email...');
|
console.log("🔄 Tentative d'envoi d'email...");
|
||||||
|
|
||||||
transporter.sendMail({
|
transporter
|
||||||
from: 'noreply@xpeditis.com',
|
.sendMail({
|
||||||
to: 'test@example.com',
|
from: 'noreply@xpeditis.com',
|
||||||
subject: 'Test Email depuis Portail Transporteur',
|
to: 'test@example.com',
|
||||||
text: 'Email de test pour vérifier la configuration'
|
subject: 'Test Email depuis Portail Transporteur',
|
||||||
}).then(info => {
|
text: 'Email de test pour vérifier la configuration',
|
||||||
console.log('✅ Email envoyé:', info.messageId);
|
})
|
||||||
console.log('📧 Response:', info.response);
|
.then(info => {
|
||||||
process.exit(0);
|
console.log('✅ Email envoyé:', info.messageId);
|
||||||
}).catch(err => {
|
console.log('📧 Response:', info.response);
|
||||||
console.error('❌ Erreur:', err.message);
|
process.exit(0);
|
||||||
console.error('Code:', err.code);
|
})
|
||||||
console.error('Command:', err.command);
|
.catch(err => {
|
||||||
console.error('Stack:', err.stack);
|
console.error('❌ Erreur:', err.message);
|
||||||
process.exit(1);
|
console.error('Code:', err.code);
|
||||||
});
|
console.error('Command:', err.command);
|
||||||
|
console.error('Stack:', err.stack);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@ -31,7 +31,8 @@ const transporter = nodemailer.createTransport(config);
|
|||||||
|
|
||||||
console.log('\n1️⃣ Verifying SMTP connection...');
|
console.log('\n1️⃣ Verifying SMTP connection...');
|
||||||
|
|
||||||
transporter.verify()
|
transporter
|
||||||
|
.verify()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('✅ SMTP connection verified!');
|
console.log('✅ SMTP connection verified!');
|
||||||
console.log('\n2️⃣ Sending test email...');
|
console.log('\n2️⃣ Sending test email...');
|
||||||
@ -40,17 +41,17 @@ transporter.verify()
|
|||||||
from: 'noreply@xpeditis.com',
|
from: 'noreply@xpeditis.com',
|
||||||
to: 'test@example.com',
|
to: 'test@example.com',
|
||||||
subject: 'Test Xpeditis - Envoi Direct IP',
|
subject: 'Test Xpeditis - Envoi Direct IP',
|
||||||
html: '<h1>✅ Email envoyé avec succès!</h1><p>Ce test utilise l\'IP directe pour contourner le DNS.</p>',
|
html: "<h1>✅ Email envoyé avec succès!</h1><p>Ce test utilise l'IP directe pour contourner le DNS.</p>",
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then((info) => {
|
.then(info => {
|
||||||
console.log('✅ Email sent successfully!');
|
console.log('✅ Email sent successfully!');
|
||||||
console.log('📧 Message ID:', info.messageId);
|
console.log('📧 Message ID:', info.messageId);
|
||||||
console.log('📬 Response:', info.response);
|
console.log('📬 Response:', info.response);
|
||||||
console.log('\n🎉 SUCCESS! Email sending works with IP directly.');
|
console.log('\n🎉 SUCCESS! Email sending works with IP directly.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error('\n❌ ERROR:', error.message);
|
console.error('\n❌ ERROR:', error.message);
|
||||||
console.error('Code:', error.code);
|
console.error('Code:', error.code);
|
||||||
console.error('Command:', error.command);
|
console.error('Command:', error.command);
|
||||||
|
|||||||
@ -6,7 +6,8 @@ const axios = require('axios');
|
|||||||
const API_URL = 'http://localhost:4000/api/v1';
|
const API_URL = 'http://localhost:4000/api/v1';
|
||||||
|
|
||||||
// Token d'authentification (admin@xpeditis.com)
|
// Token d'authentification (admin@xpeditis.com)
|
||||||
const AUTH_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MTI3Y2M0Zi04Yzg4LTRjNGUtYmU1ZC1hNmY1ZTE2MWZlNDMiLCJlbWFpbCI6ImFkbWluQHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiMWZhOWE1NjUtZjNjOC00ZTExLTliMzAtMTIwZDEwNTJjZWYwIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2NDg3NDQ2MSwiZXhwIjoxNzY0ODc1MzYxfQ.l_-97_rikGj-DP8aA14CK-Ab-0Usy722MRe1lqi0u9I';
|
const AUTH_TOKEN =
|
||||||
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MTI3Y2M0Zi04Yzg4LTRjNGUtYmU1ZC1hNmY1ZTE2MWZlNDMiLCJlbWFpbCI6ImFkbWluQHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiMWZhOWE1NjUtZjNjOC00ZTExLTliMzAtMTIwZDEwNTJjZWYwIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2NDg3NDQ2MSwiZXhwIjoxNzY0ODc1MzYxfQ.l_-97_rikGj-DP8aA14CK-Ab-0Usy722MRe1lqi0u9I';
|
||||||
|
|
||||||
async function testCsvBookingEmail() {
|
async function testCsvBookingEmail() {
|
||||||
console.log('🧪 Test envoi email via CSV booking...\n');
|
console.log('🧪 Test envoi email via CSV booking...\n');
|
||||||
@ -19,7 +20,10 @@ async function testCsvBookingEmail() {
|
|||||||
|
|
||||||
// Créer un fichier de test temporaire
|
// Créer un fichier de test temporaire
|
||||||
const testFile = Buffer.from('Test document content');
|
const testFile = Buffer.from('Test document content');
|
||||||
form.append('documents', testFile, { filename: 'test-document.pdf', contentType: 'application/pdf' });
|
form.append('documents', testFile, {
|
||||||
|
filename: 'test-document.pdf',
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
});
|
||||||
|
|
||||||
// Ajouter les champs du formulaire
|
// Ajouter les champs du formulaire
|
||||||
form.append('carrierName', 'Test Carrier Email');
|
form.append('carrierName', 'Test Carrier Email');
|
||||||
@ -41,8 +45,8 @@ async function testCsvBookingEmail() {
|
|||||||
const response = await axios.post(`${API_URL}/csv-bookings`, form, {
|
const response = await axios.post(`${API_URL}/csv-bookings`, form, {
|
||||||
headers: {
|
headers: {
|
||||||
...form.getHeaders(),
|
...form.getHeaders(),
|
||||||
'Authorization': `Bearer ${AUTH_TOKEN}`
|
Authorization: `Bearer ${AUTH_TOKEN}`,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ Réponse reçue:', response.status);
|
console.log('✅ Réponse reçue:', response.status);
|
||||||
@ -51,11 +55,10 @@ async function testCsvBookingEmail() {
|
|||||||
console.log('1. Les logs du backend pour voir "Email sent to carrier:"');
|
console.log('1. Les logs du backend pour voir "Email sent to carrier:"');
|
||||||
console.log('2. Votre inbox Mailtrap: https://mailtrap.io/inboxes');
|
console.log('2. Votre inbox Mailtrap: https://mailtrap.io/inboxes');
|
||||||
console.log('3. Email destinataire: test-carrier@example.com');
|
console.log('3. Email destinataire: test-carrier@example.com');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur:', error.response?.data || error.message);
|
console.error('❌ Erreur:', error.response?.data || error.message);
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
console.error('\n⚠️ Token expiré. Connectez-vous d\'abord avec:');
|
console.error("\n⚠️ Token expiré. Connectez-vous d'abord avec:");
|
||||||
console.error('POST /api/v1/auth/login');
|
console.error('POST /api/v1/auth/login');
|
||||||
console.error('{ "email": "admin@xpeditis.com", "password": "..." }');
|
console.error('{ "email": "admin@xpeditis.com", "password": "..." }');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,7 +31,8 @@ const transporter = nodemailer.createTransport(config);
|
|||||||
|
|
||||||
console.log('\nVerifying SMTP connection...');
|
console.log('\nVerifying SMTP connection...');
|
||||||
|
|
||||||
transporter.verify()
|
transporter
|
||||||
|
.verify()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('✅ SMTP connection verified successfully!');
|
console.log('✅ SMTP connection verified successfully!');
|
||||||
console.log('\nSending test email...');
|
console.log('\nSending test email...');
|
||||||
@ -43,13 +44,13 @@ transporter.verify()
|
|||||||
html: '<h1>Test Email</h1><p>If you see this, email sending works!</p>',
|
html: '<h1>Test Email</h1><p>If you see this, email sending works!</p>',
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then((info) => {
|
.then(info => {
|
||||||
console.log('✅ Email sent successfully!');
|
console.log('✅ Email sent successfully!');
|
||||||
console.log('Message ID:', info.messageId);
|
console.log('Message ID:', info.messageId);
|
||||||
console.log('Response:', info.response);
|
console.log('Response:', info.response);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error('❌ Error:', error.message);
|
console.error('❌ Error:', error.message);
|
||||||
console.error('Full error:', error);
|
console.error('Full error:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@ -1,74 +1,74 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
// Test SMTP ultra-simple pour identifier le problème
|
// Test SMTP ultra-simple pour identifier le problème
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
console.log('🔍 Test SMTP Simple\n');
|
console.log('🔍 Test SMTP Simple\n');
|
||||||
console.log('Configuration:');
|
console.log('Configuration:');
|
||||||
console.log(' SMTP_HOST:', process.env.SMTP_HOST || 'NON DÉFINI');
|
console.log(' SMTP_HOST:', process.env.SMTP_HOST || 'NON DÉFINI');
|
||||||
console.log(' SMTP_PORT:', process.env.SMTP_PORT || 'NON DÉFINI');
|
console.log(' SMTP_PORT:', process.env.SMTP_PORT || 'NON DÉFINI');
|
||||||
console.log(' SMTP_USER:', process.env.SMTP_USER || 'NON DÉFINI');
|
console.log(' SMTP_USER:', process.env.SMTP_USER || 'NON DÉFINI');
|
||||||
console.log(' SMTP_PASS:', process.env.SMTP_PASS ? '***' : 'NON DÉFINI');
|
console.log(' SMTP_PASS:', process.env.SMTP_PASS ? '***' : 'NON DÉFINI');
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
const host = process.env.SMTP_HOST;
|
const host = process.env.SMTP_HOST;
|
||||||
const port = parseInt(process.env.SMTP_PORT || '2525');
|
const port = parseInt(process.env.SMTP_PORT || '2525');
|
||||||
const user = process.env.SMTP_USER;
|
const user = process.env.SMTP_USER;
|
||||||
const pass = process.env.SMTP_PASS;
|
const pass = process.env.SMTP_PASS;
|
||||||
|
|
||||||
// Appliquer le même fix DNS que dans email.adapter.ts
|
// Appliquer le même fix DNS que dans email.adapter.ts
|
||||||
const useDirectIP = host && host.includes('mailtrap.io');
|
const useDirectIP = host && host.includes('mailtrap.io');
|
||||||
const actualHost = useDirectIP ? '3.209.246.195' : host;
|
const actualHost = useDirectIP ? '3.209.246.195' : host;
|
||||||
const serverName = useDirectIP ? 'smtp.mailtrap.io' : host;
|
const serverName = useDirectIP ? 'smtp.mailtrap.io' : host;
|
||||||
|
|
||||||
console.log('Fix DNS:');
|
console.log('Fix DNS:');
|
||||||
console.log(' Utilise IP directe:', useDirectIP);
|
console.log(' Utilise IP directe:', useDirectIP);
|
||||||
console.log(' Host réel:', actualHost);
|
console.log(' Host réel:', actualHost);
|
||||||
console.log(' Server name:', serverName);
|
console.log(' Server name:', serverName);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host: actualHost,
|
host: actualHost,
|
||||||
port,
|
port,
|
||||||
secure: false,
|
secure: false,
|
||||||
auth: { user, pass },
|
auth: { user, pass },
|
||||||
tls: {
|
tls: {
|
||||||
rejectUnauthorized: false,
|
rejectUnauthorized: false,
|
||||||
servername: serverName,
|
servername: serverName,
|
||||||
},
|
},
|
||||||
connectionTimeout: 10000,
|
connectionTimeout: 10000,
|
||||||
greetingTimeout: 10000,
|
greetingTimeout: 10000,
|
||||||
socketTimeout: 30000,
|
socketTimeout: 30000,
|
||||||
dnsTimeout: 10000,
|
dnsTimeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function test() {
|
async function test() {
|
||||||
try {
|
try {
|
||||||
console.log('Test 1: Vérification de la connexion...');
|
console.log('Test 1: Vérification de la connexion...');
|
||||||
await transporter.verify();
|
await transporter.verify();
|
||||||
console.log('✅ Connexion SMTP OK\n');
|
console.log('✅ Connexion SMTP OK\n');
|
||||||
|
|
||||||
console.log('Test 2: Envoi d\'un email...');
|
console.log("Test 2: Envoi d'un email...");
|
||||||
const info = await transporter.sendMail({
|
const info = await transporter.sendMail({
|
||||||
from: 'noreply@xpeditis.com',
|
from: 'noreply@xpeditis.com',
|
||||||
to: 'test@example.com',
|
to: 'test@example.com',
|
||||||
subject: 'Test - ' + new Date().toISOString(),
|
subject: 'Test - ' + new Date().toISOString(),
|
||||||
html: '<h1>Test réussi!</h1><p>Ce message confirme que l\'envoi d\'email fonctionne.</p>',
|
html: "<h1>Test réussi!</h1><p>Ce message confirme que l'envoi d'email fonctionne.</p>",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ Email envoyé avec succès!');
|
console.log('✅ Email envoyé avec succès!');
|
||||||
console.log(' Message ID:', info.messageId);
|
console.log(' Message ID:', info.messageId);
|
||||||
console.log(' Response:', info.response);
|
console.log(' Response:', info.response);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('✅ TOUS LES TESTS RÉUSSIS - Le SMTP fonctionne!');
|
console.log('✅ TOUS LES TESTS RÉUSSIS - Le SMTP fonctionne!');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ ERREUR:', error.message);
|
console.error('❌ ERREUR:', error.message);
|
||||||
console.error(' Code:', error.code);
|
console.error(' Code:', error.code);
|
||||||
console.error(' Command:', error.command);
|
console.error(' Command:', error.command);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test();
|
test();
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,185 +1,185 @@
|
|||||||
/**
|
/**
|
||||||
* Script to upload test documents to MinIO
|
* Script to upload test documents to MinIO
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { S3Client, PutObjectCommand, CreateBucketCommand } = require('@aws-sdk/client-s3');
|
const { S3Client, PutObjectCommand, CreateBucketCommand } = require('@aws-sdk/client-s3');
|
||||||
const { Client: PgClient } = require('pg');
|
const { Client: PgClient } = require('pg');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
||||||
const BUCKET_NAME = 'xpeditis-documents';
|
const BUCKET_NAME = 'xpeditis-documents';
|
||||||
|
|
||||||
// Initialize MinIO client
|
// Initialize MinIO client
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
endpoint: MINIO_ENDPOINT,
|
endpoint: MINIO_ENDPOINT,
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
||||||
},
|
},
|
||||||
forcePathStyle: true,
|
forcePathStyle: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a simple PDF buffer (minimal valid PDF)
|
// Create a simple PDF buffer (minimal valid PDF)
|
||||||
function createTestPDF(title) {
|
function createTestPDF(title) {
|
||||||
return Buffer.from(
|
return Buffer.from(
|
||||||
`%PDF-1.4
|
`%PDF-1.4
|
||||||
1 0 obj
|
1 0 obj
|
||||||
<<
|
<<
|
||||||
/Type /Catalog
|
/Type /Catalog
|
||||||
/Pages 2 0 R
|
/Pages 2 0 R
|
||||||
>>
|
>>
|
||||||
endobj
|
endobj
|
||||||
2 0 obj
|
2 0 obj
|
||||||
<<
|
<<
|
||||||
/Type /Pages
|
/Type /Pages
|
||||||
/Kids [3 0 R]
|
/Kids [3 0 R]
|
||||||
/Count 1
|
/Count 1
|
||||||
>>
|
>>
|
||||||
endobj
|
endobj
|
||||||
3 0 obj
|
3 0 obj
|
||||||
<<
|
<<
|
||||||
/Type /Page
|
/Type /Page
|
||||||
/Parent 2 0 R
|
/Parent 2 0 R
|
||||||
/MediaBox [0 0 612 792]
|
/MediaBox [0 0 612 792]
|
||||||
/Contents 4 0 R
|
/Contents 4 0 R
|
||||||
/Resources <<
|
/Resources <<
|
||||||
/Font <<
|
/Font <<
|
||||||
/F1 <<
|
/F1 <<
|
||||||
/Type /Font
|
/Type /Font
|
||||||
/Subtype /Type1
|
/Subtype /Type1
|
||||||
/BaseFont /Helvetica
|
/BaseFont /Helvetica
|
||||||
>>
|
>>
|
||||||
>>
|
>>
|
||||||
>>
|
>>
|
||||||
>>
|
>>
|
||||||
endobj
|
endobj
|
||||||
4 0 obj
|
4 0 obj
|
||||||
<<
|
<<
|
||||||
/Length 100
|
/Length 100
|
||||||
>>
|
>>
|
||||||
stream
|
stream
|
||||||
BT
|
BT
|
||||||
/F1 24 Tf
|
/F1 24 Tf
|
||||||
100 700 Td
|
100 700 Td
|
||||||
(${title}) Tj
|
(${title}) Tj
|
||||||
ET
|
ET
|
||||||
endstream
|
endstream
|
||||||
endobj
|
endobj
|
||||||
xref
|
xref
|
||||||
0 5
|
0 5
|
||||||
0000000000 65535 f
|
0000000000 65535 f
|
||||||
0000000009 00000 n
|
0000000009 00000 n
|
||||||
0000000058 00000 n
|
0000000058 00000 n
|
||||||
0000000115 00000 n
|
0000000115 00000 n
|
||||||
0000000300 00000 n
|
0000000300 00000 n
|
||||||
trailer
|
trailer
|
||||||
<<
|
<<
|
||||||
/Size 5
|
/Size 5
|
||||||
/Root 1 0 R
|
/Root 1 0 R
|
||||||
>>
|
>>
|
||||||
startxref
|
startxref
|
||||||
450
|
450
|
||||||
%%EOF`,
|
%%EOF`,
|
||||||
'utf-8'
|
'utf-8'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadTestDocuments() {
|
async function uploadTestDocuments() {
|
||||||
const pgClient = new PgClient({
|
const pgClient = new PgClient({
|
||||||
host: process.env.DATABASE_HOST || 'localhost',
|
host: process.env.DATABASE_HOST || 'localhost',
|
||||||
port: process.env.DATABASE_PORT || 5432,
|
port: process.env.DATABASE_PORT || 5432,
|
||||||
user: process.env.DATABASE_USER || 'xpeditis',
|
user: process.env.DATABASE_USER || 'xpeditis',
|
||||||
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
||||||
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Connect to database
|
// Connect to database
|
||||||
await pgClient.connect();
|
await pgClient.connect();
|
||||||
console.log('✅ Connected to database');
|
console.log('✅ Connected to database');
|
||||||
|
|
||||||
// Create bucket if it doesn't exist
|
// Create bucket if it doesn't exist
|
||||||
try {
|
try {
|
||||||
await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME }));
|
await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME }));
|
||||||
console.log(`✅ Created bucket: ${BUCKET_NAME}`);
|
console.log(`✅ Created bucket: ${BUCKET_NAME}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'BucketAlreadyOwnedByYou' || error.Code === 'BucketAlreadyOwnedByYou') {
|
if (error.name === 'BucketAlreadyOwnedByYou' || error.Code === 'BucketAlreadyOwnedByYou') {
|
||||||
console.log(`✅ Bucket already exists: ${BUCKET_NAME}`);
|
console.log(`✅ Bucket already exists: ${BUCKET_NAME}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`⚠️ Could not create bucket (might already exist): ${error.message}`);
|
console.log(`⚠️ Could not create bucket (might already exist): ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all CSV bookings with documents
|
// Get all CSV bookings with documents
|
||||||
const result = await pgClient.query(
|
const result = await pgClient.query(
|
||||||
`SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL`
|
`SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL`
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`\n📄 Found ${result.rows.length} bookings with documents\n`);
|
console.log(`\n📄 Found ${result.rows.length} bookings with documents\n`);
|
||||||
|
|
||||||
let uploadedCount = 0;
|
let uploadedCount = 0;
|
||||||
|
|
||||||
for (const row of result.rows) {
|
for (const row of result.rows) {
|
||||||
const bookingId = row.id;
|
const bookingId = row.id;
|
||||||
const documents = row.documents;
|
const documents = row.documents;
|
||||||
|
|
||||||
console.log(`\n📦 Processing booking: ${bookingId}`);
|
console.log(`\n📦 Processing booking: ${bookingId}`);
|
||||||
|
|
||||||
for (const doc of documents) {
|
for (const doc of documents) {
|
||||||
if (!doc.filePath || !doc.filePath.includes(MINIO_ENDPOINT)) {
|
if (!doc.filePath || !doc.filePath.includes(MINIO_ENDPOINT)) {
|
||||||
console.log(` ⏭️ Skipping document (not a MinIO URL): ${doc.fileName}`);
|
console.log(` ⏭️ Skipping document (not a MinIO URL): ${doc.fileName}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the S3 key from the URL
|
// Extract the S3 key from the URL
|
||||||
const url = new URL(doc.filePath);
|
const url = new URL(doc.filePath);
|
||||||
const key = url.pathname.substring(1).replace(`${BUCKET_NAME}/`, '');
|
const key = url.pathname.substring(1).replace(`${BUCKET_NAME}/`, '');
|
||||||
|
|
||||||
// Create test PDF content
|
// Create test PDF content
|
||||||
const pdfContent = createTestPDF(doc.fileName || 'Test Document');
|
const pdfContent = createTestPDF(doc.fileName || 'Test Document');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload to MinIO
|
// Upload to MinIO
|
||||||
await s3Client.send(
|
await s3Client.send(
|
||||||
new PutObjectCommand({
|
new PutObjectCommand({
|
||||||
Bucket: BUCKET_NAME,
|
Bucket: BUCKET_NAME,
|
||||||
Key: key,
|
Key: key,
|
||||||
Body: pdfContent,
|
Body: pdfContent,
|
||||||
ContentType: doc.mimeType || 'application/pdf',
|
ContentType: doc.mimeType || 'application/pdf',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(` ✅ Uploaded: ${doc.fileName}`);
|
console.log(` ✅ Uploaded: ${doc.fileName}`);
|
||||||
console.log(` Path: ${key}`);
|
console.log(` Path: ${key}`);
|
||||||
uploadedCount++;
|
uploadedCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(` ❌ Failed to upload ${doc.fileName}:`, error.message);
|
console.error(` ❌ Failed to upload ${doc.fileName}:`, error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n🎉 Successfully uploaded ${uploadedCount} test documents to MinIO`);
|
console.log(`\n🎉 Successfully uploaded ${uploadedCount} test documents to MinIO`);
|
||||||
console.log(`\n📍 MinIO Console: http://localhost:9001`);
|
console.log(`\n📍 MinIO Console: http://localhost:9001`);
|
||||||
console.log(` Username: minioadmin`);
|
console.log(` Username: minioadmin`);
|
||||||
console.log(` Password: minioadmin`);
|
console.log(` Password: minioadmin`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error:', error);
|
console.error('❌ Error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await pgClient.end();
|
await pgClient.end();
|
||||||
console.log('\n👋 Disconnected from database');
|
console.log('\n👋 Disconnected from database');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadTestDocuments()
|
uploadTestDocuments()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('\n✅ Script completed successfully');
|
console.log('\n✅ Script completed successfully');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
console.error('\n❌ Script failed:', error);
|
console.error('\n❌ Script failed:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -90,7 +90,10 @@ export default function AboutPage() {
|
|||||||
<LandingHeader activePage="about" />
|
<LandingHeader activePage="about" />
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
<section
|
||||||
|
ref={heroRef}
|
||||||
|
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
|
||||||
|
>
|
||||||
<div className="absolute inset-0 opacity-10">
|
<div className="absolute inset-0 opacity-10">
|
||||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||||
@ -155,9 +158,7 @@ export default function AboutPage() {
|
|||||||
<Target className="w-8 h-8 text-white" />
|
<Target className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('mission.title')}</h2>
|
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('mission.title')}</h2>
|
||||||
<p className="text-gray-600 text-lg leading-relaxed">
|
<p className="text-gray-600 text-lg leading-relaxed">{t('mission.body')}</p>
|
||||||
{t('mission.body')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -168,9 +169,7 @@ export default function AboutPage() {
|
|||||||
<Eye className="w-8 h-8 text-white" />
|
<Eye className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('vision.title')}</h2>
|
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('vision.title')}</h2>
|
||||||
<p className="text-gray-600 text-lg leading-relaxed">
|
<p className="text-gray-600 text-lg leading-relaxed">{t('vision.body')}</p>
|
||||||
{t('vision.body')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@ -186,11 +185,7 @@ export default function AboutPage() {
|
|||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
{STATS.map((stat, index) => (
|
{STATS.map((stat, index) => (
|
||||||
<motion.div
|
<motion.div key={stat.key} variants={itemVariants} className="text-center">
|
||||||
key={stat.key}
|
|
||||||
variants={itemVariants}
|
|
||||||
className="text-center"
|
|
||||||
>
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0 }}
|
initial={{ scale: 0 }}
|
||||||
animate={isStatsInView ? { scale: 1 } : {}}
|
animate={isStatsInView ? { scale: 1 } : {}}
|
||||||
@ -215,10 +210,10 @@ export default function AboutPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="text-center mb-16"
|
className="text-center mb-16"
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('valuesTitle')}</h2>
|
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
{t('valuesTitle')}
|
||||||
{t('valuesSubtitle')}
|
</h2>
|
||||||
</p>
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('valuesSubtitle')}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -227,7 +222,7 @@ export default function AboutPage() {
|
|||||||
animate={isValuesInView ? 'visible' : 'hidden'}
|
animate={isValuesInView ? 'visible' : 'hidden'}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
||||||
>
|
>
|
||||||
{VALUES.map((value) => {
|
{VALUES.map(value => {
|
||||||
const IconComponent = value.icon;
|
const IconComponent = value.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -241,7 +236,9 @@ export default function AboutPage() {
|
|||||||
>
|
>
|
||||||
<IconComponent className="w-7 h-7 text-white" />
|
<IconComponent className="w-7 h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`values.${value.key}.title`)}</h3>
|
<h3 className="text-xl font-bold text-brand-navy mb-3">
|
||||||
|
{t(`values.${value.key}.title`)}
|
||||||
|
</h3>
|
||||||
<p className="text-gray-600">{t(`values.${value.key}.description`)}</p>
|
<p className="text-gray-600">{t(`values.${value.key}.description`)}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
@ -259,10 +256,10 @@ export default function AboutPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="text-center mb-16"
|
className="text-center mb-16"
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('timelineTitle')}</h2>
|
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
{t('timelineTitle')}
|
||||||
{t('timelineSubtitle')}
|
</h2>
|
||||||
</p>
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('timelineSubtitle')}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -286,13 +283,19 @@ export default function AboutPage() {
|
|||||||
transition={{ duration: 0.7, ease: 'easeOut' }}
|
transition={{ duration: 0.7, ease: 'easeOut' }}
|
||||||
className={`flex items-center ${index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'}`}
|
className={`flex items-center ${index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'}`}
|
||||||
>
|
>
|
||||||
<div className={`flex-1 ${index % 2 === 0 ? 'lg:pr-12 lg:text-right' : 'lg:pl-12'}`}>
|
<div
|
||||||
|
className={`flex-1 ${index % 2 === 0 ? 'lg:pr-12 lg:text-right' : 'lg:pl-12'}`}
|
||||||
|
>
|
||||||
<div className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block hover:shadow-xl transition-shadow">
|
<div className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block hover:shadow-xl transition-shadow">
|
||||||
<div className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}>
|
<div
|
||||||
|
className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}
|
||||||
|
>
|
||||||
<Calendar className="w-5 h-5 text-brand-turquoise" />
|
<Calendar className="w-5 h-5 text-brand-turquoise" />
|
||||||
<span className="text-2xl font-bold text-brand-turquoise">{year}</span>
|
<span className="text-2xl font-bold text-brand-turquoise">{year}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`timeline.${year}.title`)}</h3>
|
<h3 className="text-xl font-bold text-brand-navy mb-2">
|
||||||
|
{t(`timeline.${year}.title`)}
|
||||||
|
</h3>
|
||||||
<p className="text-gray-600">{t(`timeline.${year}.description`)}</p>
|
<p className="text-gray-600">{t(`timeline.${year}.description`)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -302,7 +305,13 @@ export default function AboutPage() {
|
|||||||
initial={{ scale: 0 }}
|
initial={{ scale: 0 }}
|
||||||
whileInView={{ scale: 1 }}
|
whileInView={{ scale: 1 }}
|
||||||
viewport={{ once: true, amount: 0.6 }}
|
viewport={{ once: true, amount: 0.6 }}
|
||||||
transition={{ duration: 0.4, delay: 0.15, type: 'spring', stiffness: 320, damping: 18 }}
|
transition={{
|
||||||
|
duration: 0.4,
|
||||||
|
delay: 0.15,
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 320,
|
||||||
|
damping: 18,
|
||||||
|
}}
|
||||||
className="w-5 h-5 bg-brand-turquoise rounded-full border-4 border-white shadow-lg ring-2 ring-brand-turquoise/30"
|
className="w-5 h-5 bg-brand-turquoise rounded-full border-4 border-white shadow-lg ring-2 ring-brand-turquoise/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -324,10 +333,10 @@ export default function AboutPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="text-center mb-16"
|
className="text-center mb-16"
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('teamTitle')}</h2>
|
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
{t('teamTitle')}
|
||||||
{t('teamSubtitle')}
|
</h2>
|
||||||
</p>
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('teamSubtitle')}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -336,7 +345,7 @@ export default function AboutPage() {
|
|||||||
animate={isTeamInView ? 'visible' : 'hidden'}
|
animate={isTeamInView ? 'visible' : 'hidden'}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||||
>
|
>
|
||||||
{TEAM.map((member) => (
|
{TEAM.map(member => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={member.key}
|
key={member.key}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
@ -358,7 +367,9 @@ export default function AboutPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h3 className="text-xl font-bold text-brand-navy mb-1">{member.name}</h3>
|
<h3 className="text-xl font-bold text-brand-navy mb-1">{member.name}</h3>
|
||||||
<p className="text-brand-turquoise font-medium mb-3">{t(`team.${member.key}.role`)}</p>
|
<p className="text-brand-turquoise font-medium mb-3">
|
||||||
|
{t(`team.${member.key}.role`)}
|
||||||
|
</p>
|
||||||
<p className="text-gray-600 text-sm">{t(`team.${member.key}.bio`)}</p>
|
<p className="text-gray-600 text-sm">{t(`team.${member.key}.bio`)}</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -376,12 +387,8 @@ export default function AboutPage() {
|
|||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">{t('cta.title')}</h2>
|
||||||
{t('cta.title')}
|
<p className="text-xl text-white/80 mb-10">{t('cta.body')}</p>
|
||||||
</h2>
|
|
||||||
<p className="text-xl text-white/80 mb-10">
|
|
||||||
{t('cta.body')}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
|
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
|
|||||||
230
apps/frontend/app/[locale]/blog/[slug]/page.tsx
Normal file
230
apps/frontend/app/[locale]/blog/[slug]/page.tsx
Normal 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'existe pas ou n'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { motion, useInView } from 'framer-motion';
|
import { motion, useInView } from 'framer-motion';
|
||||||
@ -19,9 +19,16 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||||
|
import { getBlogPosts, type BlogPost, type BlogPostCategory } from '@/lib/api/blog';
|
||||||
|
|
||||||
type CategoryKey = 'all' | 'industry' | 'technology' | 'guides' | 'news';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||||
type ArticleKey = 'incoterms' | 'costs' | 'ports' | 'funding' | 'green' | 'api' | 'documents';
|
function resolveUrl(url: string | null | undefined): string | undefined {
|
||||||
|
if (!url) return undefined;
|
||||||
|
if (url.startsWith('http')) return url;
|
||||||
|
return `${API_BASE_URL}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryKey = 'all' | BlogPostCategory;
|
||||||
|
|
||||||
const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [
|
const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [
|
||||||
{ key: 'all', icon: BookOpen },
|
{ key: 'all', icon: BookOpen },
|
||||||
@ -31,20 +38,36 @@ const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [
|
|||||||
{ key: 'news', icon: Globe },
|
{ key: 'news', icon: Globe },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ARTICLES: { id: number; key: ArticleKey; category: Exclude<CategoryKey, 'all'>; tags: string[] }[] = [
|
const containerVariants = {
|
||||||
{ id: 2, key: 'incoterms', category: 'guides', tags: ['Incoterms', 'Guide', 'Commerce'] },
|
hidden: { opacity: 0, y: 50 },
|
||||||
{ id: 3, key: 'costs', category: 'guides', tags: ['Optimisation', 'Costs', 'Strategy'] },
|
visible: {
|
||||||
{ id: 4, key: 'ports', category: 'industry', tags: ['Ports', 'Europe', 'Stats'] },
|
opacity: 1,
|
||||||
{ id: 5, key: 'funding', category: 'news', tags: ['Funding', 'Growth', 'Xpeditis'] },
|
y: 0,
|
||||||
{ id: 6, key: 'green', category: 'industry', tags: ['Environment', 'Decarbonization', 'Sustainability'] },
|
transition: { duration: 0.6, staggerChildren: 0.1 },
|
||||||
{ id: 7, key: 'api', category: 'technology', tags: ['API', 'Integration', 'Technical'] },
|
},
|
||||||
{ id: 8, key: 'documents', category: 'guides', tags: ['Documents', 'Export', 'Customs'] },
|
};
|
||||||
];
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0, transition: { duration: 0.5 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function BlogPage() {
|
export default function BlogPage() {
|
||||||
const t = useTranslations('marketing.blog');
|
const t = useTranslations('marketing.blog');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all');
|
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [posts, setPosts] = useState<BlogPost[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [featuredPost, setFeaturedPost] = useState<BlogPost | null>(null);
|
||||||
|
|
||||||
const heroRef = useRef(null);
|
const heroRef = useRef(null);
|
||||||
const articlesRef = useRef(null);
|
const articlesRef = useRef(null);
|
||||||
@ -54,44 +77,50 @@ export default function BlogPage() {
|
|||||||
const isArticlesInView = useInView(articlesRef, { once: true });
|
const isArticlesInView = useInView(articlesRef, { once: true });
|
||||||
const isCategoriesInView = useInView(categoriesRef, { once: true });
|
const isCategoriesInView = useInView(categoriesRef, { once: true });
|
||||||
|
|
||||||
const filteredArticles = ARTICLES.filter((article) => {
|
useEffect(() => {
|
||||||
const categoryMatch = selectedCategory === 'all' || article.category === selectedCategory;
|
let cancelled = false;
|
||||||
const title = t(`articles.${article.key}.title` as any);
|
|
||||||
const excerpt = t(`articles.${article.key}.excerpt` as any);
|
|
||||||
const searchMatch =
|
|
||||||
searchQuery === '' ||
|
|
||||||
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
excerpt.toLowerCase().includes(searchQuery.toLowerCase());
|
|
||||||
return categoryMatch && searchMatch;
|
|
||||||
});
|
|
||||||
|
|
||||||
const containerVariants = {
|
const load = async () => {
|
||||||
hidden: { opacity: 0, y: 50 },
|
setLoading(true);
|
||||||
visible: {
|
try {
|
||||||
opacity: 1,
|
const res = await getBlogPosts({
|
||||||
y: 0,
|
category: selectedCategory !== 'all' ? selectedCategory : undefined,
|
||||||
transition: {
|
search: searchQuery || undefined,
|
||||||
duration: 0.6,
|
limit: 50,
|
||||||
staggerChildren: 0.1,
|
});
|
||||||
},
|
if (!cancelled) {
|
||||||
},
|
const featured = res.posts.find(p => p.isFeatured) ?? res.posts[0] ?? null;
|
||||||
};
|
const rest = res.posts.filter(p => p !== featured);
|
||||||
|
setFeaturedPost(featured);
|
||||||
|
setPosts(rest);
|
||||||
|
setTotal(res.total);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPosts([]);
|
||||||
|
setFeaturedPost(null);
|
||||||
|
setTotal(0);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const itemVariants = {
|
load();
|
||||||
hidden: { opacity: 0, y: 20 },
|
return () => {
|
||||||
visible: {
|
cancelled = true;
|
||||||
opacity: 1,
|
};
|
||||||
y: 0,
|
}, [selectedCategory, searchQuery]);
|
||||||
transition: { duration: 0.5 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
<LandingHeader activePage="blog" />
|
<LandingHeader activePage="blog" />
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
<section
|
||||||
|
ref={heroRef}
|
||||||
|
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
|
||||||
|
>
|
||||||
<div className="absolute inset-0 opacity-10">
|
<div className="absolute inset-0 opacity-10">
|
||||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||||
@ -126,7 +155,6 @@ export default function BlogPage() {
|
|||||||
{t('intro')}
|
{t('intro')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Search Bar */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||||
@ -139,7 +167,7 @@ export default function BlogPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder={t('searchPlaceholder')}
|
placeholder={t('searchPlaceholder')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-12 pr-4 py-4 rounded-xl bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none"
|
className="w-full pl-12 pr-4 py-4 rounded-xl bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -147,7 +175,6 @@ export default function BlogPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wave */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0">
|
<div className="absolute bottom-0 left-0 right-0">
|
||||||
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
||||||
<path
|
<path
|
||||||
@ -167,7 +194,7 @@ export default function BlogPage() {
|
|||||||
className="max-w-7xl mx-auto px-6 lg:px-8"
|
className="max-w-7xl mx-auto px-6 lg:px-8"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||||
{CATEGORIES.map((category) => {
|
{CATEGORIES.map(category => {
|
||||||
const IconComponent = category.icon;
|
const IconComponent = category.icon;
|
||||||
const isActive = selectedCategory === category.key;
|
const isActive = selectedCategory === category.key;
|
||||||
return (
|
return (
|
||||||
@ -190,64 +217,71 @@ export default function BlogPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Featured Article */}
|
{/* Featured Article */}
|
||||||
<section className="py-16">
|
{!loading && featuredPost && (
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<section className="py-16">
|
||||||
<motion.div
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
initial={{ opacity: 0, y: 30 }}
|
<motion.div
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
viewport={{ once: true }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8 }}
|
viewport={{ once: true }}
|
||||||
>
|
transition={{ duration: 0.8 }}
|
||||||
<Link href="/blog/1">
|
>
|
||||||
<div className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden group cursor-pointer">
|
<Link href={`/blog/${featuredPost.slug}`}>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-brand-navy via-brand-navy/80 to-transparent z-10" />
|
<div className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden group cursor-pointer">
|
||||||
<div className="absolute right-0 top-0 bottom-0 w-1/2 bg-brand-turquoise/20 flex items-center justify-center">
|
<div className="absolute inset-0 bg-gradient-to-r from-brand-navy via-brand-navy/80 to-transparent z-10" />
|
||||||
<Anchor className="w-48 h-48 text-white/10" />
|
{featuredPost.coverImageUrl ? (
|
||||||
</div>
|
<div
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-1/2 bg-cover bg-center"
|
||||||
<div className="relative z-20 p-8 lg:p-12">
|
style={{ backgroundImage: `url(${resolveUrl(featuredPost.coverImageUrl)})` }}
|
||||||
<div className="max-w-2xl">
|
/>
|
||||||
<div className="flex items-center space-x-2 mb-4">
|
) : (
|
||||||
<span className="px-3 py-1 bg-brand-turquoise text-white text-sm font-medium rounded-full">
|
<div className="absolute right-0 top-0 bottom-0 w-1/2 bg-brand-turquoise/20 flex items-center justify-center">
|
||||||
{t('featuredBadge')}
|
<Anchor className="w-48 h-48 text-white/10" />
|
||||||
</span>
|
|
||||||
<span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full">
|
|
||||||
{t('categories.technology')}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors">
|
<div className="relative z-20 p-8 lg:p-12">
|
||||||
{t('featured.title')}
|
<div className="max-w-2xl">
|
||||||
</h2>
|
<div className="flex items-center space-x-2 mb-4">
|
||||||
|
<span className="px-3 py-1 bg-brand-turquoise text-white text-sm font-medium rounded-full">
|
||||||
<p className="text-lg text-white/80 mb-6">{t('featured.excerpt')}</p>
|
{t('featuredBadge')}
|
||||||
|
</span>
|
||||||
<div className="flex items-center space-x-6 text-white/60 text-sm">
|
<span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full">
|
||||||
<div className="flex items-center space-x-2">
|
{t(`categories.${featuredPost.category}`)}
|
||||||
<User className="w-4 h-4" />
|
</span>
|
||||||
<span>{t('featured.author')}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Calendar className="w-4 h-4" />
|
|
||||||
<span>{t('featured.date')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
<span>{t('featured.readTime')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 mt-6 text-brand-turquoise font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
<h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors">
|
||||||
<span>{t('readArticle')}</span>
|
{featuredPost.title}
|
||||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-lg text-white/80 mb-6">{featuredPost.excerpt}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-6 text-white/60 text-sm">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
<span>{featuredPost.authorName}</span>
|
||||||
|
</div>
|
||||||
|
{featuredPost.publishedAt && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
<span>{formatDate(featuredPost.publishedAt)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 mt-6 text-brand-turquoise font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<span>{t('readArticle')}</span>
|
||||||
|
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
</Link>
|
</motion.div>
|
||||||
</motion.div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
{/* Articles Grid */}
|
{/* Articles Grid */}
|
||||||
<section ref={articlesRef} className="py-16 bg-gray-50">
|
<section ref={articlesRef} className="py-16 bg-gray-50">
|
||||||
@ -259,10 +293,26 @@ export default function BlogPage() {
|
|||||||
className="flex items-center justify-between mb-12"
|
className="flex items-center justify-between mb-12"
|
||||||
>
|
>
|
||||||
<h2 className="text-3xl font-bold text-brand-navy">{t('allTitle')}</h2>
|
<h2 className="text-3xl font-bold text-brand-navy">{t('allTitle')}</h2>
|
||||||
<span className="text-gray-500">{t('articlesCount', { count: filteredArticles.length })}</span>
|
<span className="text-gray-500">{t('articlesCount', { count: posts.length })}</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{filteredArticles.length === 0 ? (
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-white rounded-2xl shadow-lg overflow-hidden animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="aspect-video bg-gray-200" />
|
||||||
|
<div className="p-6 space-y-3">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-3/4" />
|
||||||
|
<div className="h-3 bg-gray-200 rounded" />
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-5/6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : posts.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
<h3 className="text-xl font-medium text-gray-600">{t('noResults.title')}</h3>
|
<h3 className="text-xl font-medium text-gray-600">{t('noResults.title')}</h3>
|
||||||
@ -275,30 +325,36 @@ export default function BlogPage() {
|
|||||||
animate={isArticlesInView ? 'visible' : 'hidden'}
|
animate={isArticlesInView ? 'visible' : 'hidden'}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||||
>
|
>
|
||||||
{filteredArticles.map((article) => (
|
{posts.map(post => (
|
||||||
<motion.div key={article.id} variants={itemVariants}>
|
<motion.div key={post.id} variants={itemVariants}>
|
||||||
<Link href={`/blog/${article.id}`}>
|
<Link href={`/blog/${post.slug}`}>
|
||||||
<div className="bg-white rounded-2xl shadow-lg overflow-hidden group hover:shadow-xl transition-all h-full flex flex-col">
|
<div className="bg-white rounded-2xl shadow-lg overflow-hidden group hover:shadow-xl transition-all h-full flex flex-col">
|
||||||
<div className="aspect-video bg-gradient-to-br from-brand-navy/10 to-brand-turquoise/10 flex items-center justify-center relative">
|
<div className="aspect-video bg-gradient-to-br from-brand-navy/10 to-brand-turquoise/10 flex items-center justify-center relative overflow-hidden">
|
||||||
<Ship className="w-16 h-16 text-brand-navy/20" />
|
{post.coverImageUrl ? (
|
||||||
|
<img
|
||||||
|
src={resolveUrl(post.coverImageUrl)}
|
||||||
|
alt={post.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Ship className="w-16 h-16 text-brand-navy/20" />
|
||||||
|
)}
|
||||||
<div className="absolute top-4 left-4">
|
<div className="absolute top-4 left-4">
|
||||||
<span className="px-3 py-1 bg-white/90 text-brand-navy text-xs font-medium rounded-full">
|
<span className="px-3 py-1 bg-white/90 text-brand-navy text-xs font-medium rounded-full">
|
||||||
{t(`categories.${article.category}`)}
|
{t(`categories.${post.category}`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 flex-1 flex flex-col">
|
<div className="p-6 flex-1 flex flex-col">
|
||||||
<h3 className="text-xl font-bold text-brand-navy mb-3 group-hover:text-brand-turquoise transition-colors line-clamp-2">
|
<h3 className="text-xl font-bold text-brand-navy mb-3 group-hover:text-brand-turquoise transition-colors line-clamp-2">
|
||||||
{t(`articles.${article.key}.title` as any)}
|
{post.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="text-gray-600 mb-4 line-clamp-2 flex-1">
|
<p className="text-gray-600 mb-4 line-clamp-2 flex-1">{post.excerpt}</p>
|
||||||
{t(`articles.${article.key}.excerpt` as any)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
{article.tags.map((tag) => (
|
{post.tags.map(tag => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full"
|
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full"
|
||||||
@ -313,15 +369,14 @@ export default function BlogPage() {
|
|||||||
<div className="w-8 h-8 bg-brand-turquoise/10 rounded-full flex items-center justify-center">
|
<div className="w-8 h-8 bg-brand-turquoise/10 rounded-full flex items-center justify-center">
|
||||||
<User className="w-4 h-4 text-brand-turquoise" />
|
<User className="w-4 h-4 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<span>{t(`articles.${article.key}.author` as any)}</span>
|
<span>{post.authorName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
{post.publishedAt && (
|
||||||
<span>{t(`articles.${article.key}.date` as any)}</span>
|
<div className="flex items-center space-x-1">
|
||||||
<span className="flex items-center space-x-1">
|
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
<span>{t(`articles.${article.key}.readTime` as any)}</span>
|
<span>{formatDate(post.publishedAt)}</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -330,21 +385,6 @@ export default function BlogPage() {
|
|||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Load More */}
|
|
||||||
{filteredArticles.length > 0 && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
whileInView={{ opacity: 1 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.2 }}
|
|
||||||
className="text-center mt-12"
|
|
||||||
>
|
|
||||||
<button className="px-8 py-4 bg-white border-2 border-brand-turquoise text-brand-turquoise rounded-lg hover:bg-brand-turquoise hover:text-white transition-all font-semibold">
|
|
||||||
{t('loadMore')}
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -357,12 +397,8 @@ export default function BlogPage() {
|
|||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl font-bold text-white mb-6">
|
<h2 className="text-4xl font-bold text-white mb-6">{t('newsletter.title')}</h2>
|
||||||
{t('newsletter.title')}
|
<p className="text-xl text-white/80 mb-10">{t('newsletter.body')}</p>
|
||||||
</h2>
|
|
||||||
<p className="text-xl text-white/80 mb-10">
|
|
||||||
{t('newsletter.body')}
|
|
||||||
</p>
|
|
||||||
<form className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
<form className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@ -377,9 +413,7 @@ export default function BlogPage() {
|
|||||||
<ArrowRight className="w-5 h-5" />
|
<ArrowRight className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p className="text-white/50 text-sm mt-4">
|
<p className="text-white/50 text-sm mt-4">{t('newsletter.disclaimer')}</p>
|
||||||
{t('newsletter.disclaimer')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -81,9 +81,7 @@ export default function BookingConfirmPage() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('errorTitle')}</h1>
|
||||||
{t('errorTitle')}
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600">{error}</p>
|
<p className="text-gray-600">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -98,9 +96,7 @@ export default function BookingConfirmPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-gray-500 text-center">
|
<p className="text-sm text-gray-500 text-center">{t('errorContact')}</p>
|
||||||
{t('errorContact')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -134,22 +130,14 @@ export default function BookingConfirmPage() {
|
|||||||
<div className="absolute inset-0 rounded-full border-4 border-green-200 animate-ping opacity-20"></div>
|
<div className="absolute inset-0 rounded-full border-4 border-green-200 animate-ping opacity-20"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">
|
<h1 className="text-3xl font-bold text-gray-900 mb-3">{t('successTitle')}</h1>
|
||||||
{t('successTitle')}
|
<p className="text-lg text-gray-600 mb-2">{t('successHeadline')}</p>
|
||||||
</h1>
|
<p className="text-gray-500">{t('successBody')}</p>
|
||||||
<p className="text-lg text-gray-600 mb-2">
|
|
||||||
{t('successHeadline')}
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-500">
|
|
||||||
{t('successBody')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Booking Summary */}
|
{/* Booking Summary */}
|
||||||
<div className="bg-gray-50 rounded-xl p-6 mb-6">
|
<div className="bg-gray-50 rounded-xl p-6 mb-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('summaryTitle')}</h2>
|
||||||
{t('summaryTitle')}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
<div className="flex justify-between py-2 border-b border-gray-200">
|
||||||
@ -197,14 +185,12 @@ export default function BookingConfirmPage() {
|
|||||||
<div className="font-bold text-xl text-green-600">
|
<div className="font-bold text-xl text-green-600">
|
||||||
{booking.primaryCurrency === 'USD'
|
{booking.primaryCurrency === 'USD'
|
||||||
? `$${booking.priceUSD.toLocaleString()}`
|
? `$${booking.priceUSD.toLocaleString()}`
|
||||||
: `€${booking.priceEUR.toLocaleString()}`
|
: `€${booking.priceEUR.toLocaleString()}`}
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
{booking.primaryCurrency === 'USD'
|
{booking.primaryCurrency === 'USD'
|
||||||
? `(€${booking.priceEUR.toLocaleString()})`
|
? `(€${booking.priceEUR.toLocaleString()})`
|
||||||
: `($${booking.priceUSD.toLocaleString()})`
|
: `($${booking.priceUSD.toLocaleString()})`}
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -222,7 +208,12 @@ export default function BookingConfirmPage() {
|
|||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
<h3 className="font-semibold text-blue-900 mb-2 flex items-center">
|
<h3 className="font-semibold text-blue-900 mb-2 flex items-center">
|
||||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('nextStepsTitle')}
|
{t('nextStepsTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
@ -239,10 +230,23 @@ export default function BookingConfirmPage() {
|
|||||||
<h3 className="font-semibold text-gray-900 mb-3">{t('labels.documents')}</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">{t('labels.documents')}</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{booking.documents.map((doc, index) => (
|
{booking.documents.map((doc, index) => (
|
||||||
<div key={index} className="flex items-center justify-between p-3 bg-white rounded border border-gray-200">
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between p-3 bg-white rounded border border-gray-200"
|
||||||
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg className="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
className="w-5 h-5 text-gray-400 mr-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">{doc.fileName}</p>
|
<p className="text-sm font-medium text-gray-900">{doc.fileName}</p>
|
||||||
|
|||||||
@ -89,9 +89,7 @@ export default function BookingRejectPage() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('errorTitle')}</h1>
|
||||||
{t('errorTitle')}
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600">{error}</p>
|
<p className="text-gray-600">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -106,9 +104,7 @@ export default function BookingRejectPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-gray-500 text-center">
|
<p className="text-sm text-gray-500 text-center">{t('errorContact')}</p>
|
||||||
{t('errorContact')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -137,21 +133,13 @@ export default function BookingRejectPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">
|
<h1 className="text-3xl font-bold text-gray-900 mb-3">{t('rejectedTitle')}</h1>
|
||||||
{t('rejectedTitle')}
|
<p className="text-lg text-gray-600 mb-2">{t('rejectedHeadline')}</p>
|
||||||
</h1>
|
<p className="text-gray-500">{t('rejectedBody')}</p>
|
||||||
<p className="text-lg text-gray-600 mb-2">
|
|
||||||
{t('rejectedHeadline')}
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-500">
|
|
||||||
{t('rejectedBody')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 rounded-xl p-6 mb-6">
|
<div className="bg-gray-50 rounded-xl p-6 mb-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('summaryTitle')}</h2>
|
||||||
{t('summaryTitle')}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
<div className="flex justify-between py-2 border-b border-gray-200">
|
||||||
@ -181,8 +169,7 @@ export default function BookingRejectPage() {
|
|||||||
<span className="font-semibold text-gray-900">
|
<span className="font-semibold text-gray-900">
|
||||||
{booking.primaryCurrency === 'USD'
|
{booking.primaryCurrency === 'USD'
|
||||||
? `$${booking.priceUSD.toLocaleString()}`
|
? `$${booking.priceUSD.toLocaleString()}`
|
||||||
: `€${booking.priceEUR.toLocaleString()}`
|
: `€${booking.priceEUR.toLocaleString()}`}
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -200,13 +187,16 @@ export default function BookingRejectPage() {
|
|||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
<h3 className="font-semibold text-blue-900 mb-2 flex items-center">
|
<h3 className="font-semibold text-blue-900 mb-2 flex items-center">
|
||||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('infoTitle')}
|
{t('infoTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-blue-800">
|
<p className="text-sm text-blue-800">{t('infoBody')}</p>
|
||||||
{t('infoBody')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center text-sm text-gray-500">
|
<div className="text-center text-sm text-gray-500">
|
||||||
@ -259,12 +249,8 @@ export default function BookingRejectPage() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('formTitle')}</h1>
|
||||||
{t('formTitle')}
|
<p className="text-gray-600">{t('formIntro')}</p>
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{t('formIntro')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@ -275,8 +261,18 @@ export default function BookingRejectPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-gray-700">{t('addReason')}</span>
|
<span className="text-gray-700">{t('addReason')}</span>
|
||||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
className="w-5 h-5 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@ -289,18 +285,14 @@ export default function BookingRejectPage() {
|
|||||||
id="reason"
|
id="reason"
|
||||||
rows={4}
|
rows={4}
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={(e) => setReason(e.target.value)}
|
onChange={e => setReason(e.target.value)}
|
||||||
placeholder={t('reasonPlaceholder')}
|
placeholder={t('reasonPlaceholder')}
|
||||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent resize-none"
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent resize-none"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 flex items-center justify-between">
|
<div className="mt-1 flex items-center justify-between">
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">{t('reasonHint')}</p>
|
||||||
{t('reasonHint')}
|
<span className="text-xs text-gray-400">{reason.length}/500</span>
|
||||||
</p>
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
{reason.length}/500
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -320,16 +312,36 @@ export default function BookingRejectPage() {
|
|||||||
>
|
>
|
||||||
{isRejecting ? (
|
{isRejecting ? (
|
||||||
<>
|
<>
|
||||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
<svg
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{t('submitting')}
|
{t('submitting')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('submit')}
|
{t('submit')}
|
||||||
</>
|
</>
|
||||||
@ -344,9 +356,7 @@ export default function BookingRejectPage() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-6 text-xs text-center text-gray-500">
|
<p className="mt-6 text-xs text-center text-gray-500">{t('helpText')}</p>
|
||||||
{t('helpText')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -64,15 +64,76 @@ type JobRecord = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const JOBS: JobRecord[] = [
|
const JOBS: JobRecord[] = [
|
||||||
{ id: 1, key: 'frontend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '65K - 85K €', icon: Code },
|
{
|
||||||
{ id: 2, key: 'backend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '55K - 75K €', icon: Code },
|
id: 1,
|
||||||
{ id: 3, key: 'pm', department: 'Product', location: 'Paris', type: 'CDI', remote: true, salary: '60K - 80K €', icon: LineChart },
|
key: 'frontend',
|
||||||
{ id: 4, key: 'ae', department: 'Sales', location: 'Rotterdam', type: 'CDI', remote: false, salary: '50K - 70K € + variable', icon: Megaphone },
|
department: 'Engineering',
|
||||||
{ id: 5, key: 'csm', department: 'Customer Success', location: 'Paris', type: 'CDI', remote: true, salary: '45K - 60K €', icon: Headphones },
|
location: 'Paris',
|
||||||
{ id: 6, key: 'data', department: 'Data', location: 'Hambourg', type: 'CDI', remote: true, salary: '50K - 65K €', icon: LineChart },
|
type: 'CDI',
|
||||||
|
remote: true,
|
||||||
|
salary: '65K - 85K €',
|
||||||
|
icon: Code,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
key: 'backend',
|
||||||
|
department: 'Engineering',
|
||||||
|
location: 'Paris',
|
||||||
|
type: 'CDI',
|
||||||
|
remote: true,
|
||||||
|
salary: '55K - 75K €',
|
||||||
|
icon: Code,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
key: 'pm',
|
||||||
|
department: 'Product',
|
||||||
|
location: 'Paris',
|
||||||
|
type: 'CDI',
|
||||||
|
remote: true,
|
||||||
|
salary: '60K - 80K €',
|
||||||
|
icon: LineChart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
key: 'ae',
|
||||||
|
department: 'Sales',
|
||||||
|
location: 'Rotterdam',
|
||||||
|
type: 'CDI',
|
||||||
|
remote: false,
|
||||||
|
salary: '50K - 70K € + variable',
|
||||||
|
icon: Megaphone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
key: 'csm',
|
||||||
|
department: 'Customer Success',
|
||||||
|
location: 'Paris',
|
||||||
|
type: 'CDI',
|
||||||
|
remote: true,
|
||||||
|
salary: '45K - 60K €',
|
||||||
|
icon: Headphones,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
key: 'data',
|
||||||
|
department: 'Data',
|
||||||
|
location: 'Hambourg',
|
||||||
|
type: 'CDI',
|
||||||
|
remote: true,
|
||||||
|
salary: '50K - 65K €',
|
||||||
|
icon: LineChart,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEPARTMENT_VALUES: DepartmentValue[] = ['all', 'Engineering', 'Product', 'Sales', 'Customer Success', 'Data'];
|
const DEPARTMENT_VALUES: DepartmentValue[] = [
|
||||||
|
'all',
|
||||||
|
'Engineering',
|
||||||
|
'Product',
|
||||||
|
'Sales',
|
||||||
|
'Customer Success',
|
||||||
|
'Data',
|
||||||
|
];
|
||||||
const LOCATION_VALUES: LocationValue[] = ['all', 'Paris', 'Rotterdam', 'Hambourg'];
|
const LOCATION_VALUES: LocationValue[] = ['all', 'Paris', 'Rotterdam', 'Hambourg'];
|
||||||
const JOB_REQ_KEYS = ['req1', 'req2', 'req3', 'req4'] as const;
|
const JOB_REQ_KEYS = ['req1', 'req2', 'req3', 'req4'] as const;
|
||||||
|
|
||||||
@ -92,7 +153,7 @@ export default function CareersPage() {
|
|||||||
const isJobsInView = useInView(jobsRef, { once: true });
|
const isJobsInView = useInView(jobsRef, { once: true });
|
||||||
const isCultureInView = useInView(cultureRef, { once: true });
|
const isCultureInView = useInView(cultureRef, { once: true });
|
||||||
|
|
||||||
const filteredJobs = JOBS.filter((job) => {
|
const filteredJobs = JOBS.filter(job => {
|
||||||
const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment;
|
const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment;
|
||||||
const locationMatch = selectedLocation === 'all' || job.location === selectedLocation;
|
const locationMatch = selectedLocation === 'all' || job.location === selectedLocation;
|
||||||
return departmentMatch && locationMatch;
|
return departmentMatch && locationMatch;
|
||||||
@ -124,7 +185,10 @@ export default function CareersPage() {
|
|||||||
<LandingHeader activePage="careers" />
|
<LandingHeader activePage="careers" />
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
<section
|
||||||
|
ref={heroRef}
|
||||||
|
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
|
||||||
|
>
|
||||||
<div className="absolute inset-0 opacity-10">
|
<div className="absolute inset-0 opacity-10">
|
||||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||||
@ -221,9 +285,7 @@ export default function CareersPage() {
|
|||||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||||
{t('benefitsTitle')}
|
{t('benefitsTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('benefitsSubtitle')}</p>
|
||||||
{t('benefitsSubtitle')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -232,7 +294,7 @@ export default function CareersPage() {
|
|||||||
animate={isBenefitsInView ? 'visible' : 'hidden'}
|
animate={isBenefitsInView ? 'visible' : 'hidden'}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||||
>
|
>
|
||||||
{BENEFITS.map((benefit) => {
|
{BENEFITS.map(benefit => {
|
||||||
const IconComponent = benefit.icon;
|
const IconComponent = benefit.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -244,7 +306,9 @@ export default function CareersPage() {
|
|||||||
<div className="w-14 h-14 bg-brand-turquoise/10 rounded-xl flex items-center justify-center mb-4">
|
<div className="w-14 h-14 bg-brand-turquoise/10 rounded-xl flex items-center justify-center mb-4">
|
||||||
<IconComponent className="w-7 h-7 text-brand-turquoise" />
|
<IconComponent className="w-7 h-7 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`benefits.${benefit.key}.title`)}</h3>
|
<h3 className="text-xl font-bold text-brand-navy mb-2">
|
||||||
|
{t(`benefits.${benefit.key}.title`)}
|
||||||
|
</h3>
|
||||||
<p className="text-gray-600">{t(`benefits.${benefit.key}.description`)}</p>
|
<p className="text-gray-600">{t(`benefits.${benefit.key}.description`)}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
@ -254,7 +318,10 @@ export default function CareersPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Culture Section */}
|
{/* Culture Section */}
|
||||||
<section ref={cultureRef} className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95">
|
<section
|
||||||
|
ref={cultureRef}
|
||||||
|
className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95"
|
||||||
|
>
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -265,9 +332,7 @@ export default function CareersPage() {
|
|||||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||||
{t('cultureTitle')}
|
{t('cultureTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-white/80 mb-8">
|
<p className="text-xl text-white/80 mb-8">{t('cultureBody')}</p>
|
||||||
{t('cultureBody')}
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
{CULTURE_ITEMS.map((itemKey, index) => (
|
{CULTURE_ITEMS.map((itemKey, index) => (
|
||||||
<motion.li
|
<motion.li
|
||||||
@ -292,7 +357,7 @@ export default function CareersPage() {
|
|||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
transition={{ duration: 0.8, delay: 0.2 }}
|
||||||
className="grid grid-cols-2 gap-4"
|
className="grid grid-cols-2 gap-4"
|
||||||
>
|
>
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4].map(i => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="aspect-square bg-white/10 rounded-2xl flex items-center justify-center"
|
className="aspect-square bg-white/10 rounded-2xl flex items-center justify-center"
|
||||||
@ -317,9 +382,7 @@ export default function CareersPage() {
|
|||||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||||
{t('jobsTitle')}
|
{t('jobsTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('jobsSubtitle')}</p>
|
||||||
{t('jobsSubtitle')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@ -332,12 +395,14 @@ export default function CareersPage() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
value={selectedDepartment}
|
value={selectedDepartment}
|
||||||
onChange={(e) => setSelectedDepartment(e.target.value as DepartmentValue)}
|
onChange={e => setSelectedDepartment(e.target.value as DepartmentValue)}
|
||||||
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
|
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
|
||||||
>
|
>
|
||||||
{DEPARTMENT_VALUES.map((value) => (
|
{DEPARTMENT_VALUES.map(value => (
|
||||||
<option key={value} value={value}>
|
<option key={value} value={value}>
|
||||||
{value === 'all' ? t('filters.allDepartments') : t(`departments.${value}` as any)}
|
{value === 'all'
|
||||||
|
? t('filters.allDepartments')
|
||||||
|
: t(`departments.${value}` as any)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -346,10 +411,10 @@ export default function CareersPage() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
value={selectedLocation}
|
value={selectedLocation}
|
||||||
onChange={(e) => setSelectedLocation(e.target.value as LocationValue)}
|
onChange={e => setSelectedLocation(e.target.value as LocationValue)}
|
||||||
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
|
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
|
||||||
>
|
>
|
||||||
{LOCATION_VALUES.map((value) => (
|
{LOCATION_VALUES.map(value => (
|
||||||
<option key={value} value={value}>
|
<option key={value} value={value}>
|
||||||
{value === 'all' ? t('filters.allLocations') : t(`locations.${value}` as any)}
|
{value === 'all' ? t('filters.allLocations') : t(`locations.${value}` as any)}
|
||||||
</option>
|
</option>
|
||||||
@ -373,7 +438,7 @@ export default function CareersPage() {
|
|||||||
<p className="text-gray-500">{t('noJobs.body')}</p>
|
<p className="text-gray-500">{t('noJobs.body')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredJobs.map((job) => {
|
filteredJobs.map(job => {
|
||||||
const IconComponent = job.icon;
|
const IconComponent = job.icon;
|
||||||
const isExpanded = expandedJob === job.id;
|
const isExpanded = expandedJob === job.id;
|
||||||
|
|
||||||
@ -393,7 +458,9 @@ export default function CareersPage() {
|
|||||||
<IconComponent className="w-6 h-6 text-brand-turquoise" />
|
<IconComponent className="w-6 h-6 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-brand-navy">{t(`jobs.${job.key}.title`)}</h3>
|
<h3 className="text-xl font-bold text-brand-navy">
|
||||||
|
{t(`jobs.${job.key}.title`)}
|
||||||
|
</h3>
|
||||||
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
|
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
|
||||||
<span className="flex items-center space-x-1">
|
<span className="flex items-center space-x-1">
|
||||||
<Building2 className="w-4 h-4" />
|
<Building2 className="w-4 h-4" />
|
||||||
@ -441,10 +508,15 @@ export default function CareersPage() {
|
|||||||
>
|
>
|
||||||
<div className="p-6 bg-gray-50">
|
<div className="p-6 bg-gray-50">
|
||||||
<p className="text-gray-600 mb-6">{t(`jobs.${job.key}.description`)}</p>
|
<p className="text-gray-600 mb-6">{t(`jobs.${job.key}.description`)}</p>
|
||||||
<h4 className="font-bold text-brand-navy mb-3">{t('jobCard.profile')}</h4>
|
<h4 className="font-bold text-brand-navy mb-3">
|
||||||
|
{t('jobCard.profile')}
|
||||||
|
</h4>
|
||||||
<ul className="space-y-2 mb-6">
|
<ul className="space-y-2 mb-6">
|
||||||
{JOB_REQ_KEYS.map((reqKey) => (
|
{JOB_REQ_KEYS.map(reqKey => (
|
||||||
<li key={reqKey} className="flex items-start space-x-2 text-gray-600">
|
<li
|
||||||
|
key={reqKey}
|
||||||
|
className="flex items-start space-x-2 text-gray-600"
|
||||||
|
>
|
||||||
<ChevronRight className="w-5 h-5 text-brand-turquoise flex-shrink-0 mt-0.5" />
|
<ChevronRight className="w-5 h-5 text-brand-turquoise flex-shrink-0 mt-0.5" />
|
||||||
<span>{t(`jobs.${job.key}.${reqKey}` as any)}</span>
|
<span>{t(`jobs.${job.key}.${reqKey}` as any)}</span>
|
||||||
</li>
|
</li>
|
||||||
@ -483,12 +555,8 @@ export default function CareersPage() {
|
|||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl font-bold text-brand-navy mb-6">
|
<h2 className="text-4xl font-bold text-brand-navy mb-6">{t('cta.title')}</h2>
|
||||||
{t('cta.title')}
|
<p className="text-xl text-gray-600 mb-10">{t('cta.body')}</p>
|
||||||
</h2>
|
|
||||||
<p className="text-xl text-gray-600 mb-10">
|
|
||||||
{t('cta.body')}
|
|
||||||
</p>
|
|
||||||
<Link
|
<Link
|
||||||
href="/contact"
|
href="/contact"
|
||||||
className="inline-flex items-center space-x-2 px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold text-lg"
|
className="inline-flex items-center space-x-2 px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold text-lg"
|
||||||
|
|||||||
@ -55,7 +55,10 @@ export default function CarrierAcceptPage() {
|
|||||||
errorMessage = t('common.bookingAlreadyAccepted');
|
errorMessage = t('common.bookingAlreadyAccepted');
|
||||||
} else if (errorMessage.includes('status REJECTED')) {
|
} else if (errorMessage.includes('status REJECTED')) {
|
||||||
errorMessage = t('common.bookingAlreadyRejected');
|
errorMessage = t('common.bookingAlreadyRejected');
|
||||||
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) {
|
} else if (
|
||||||
|
errorMessage.includes('not found') ||
|
||||||
|
errorMessage.includes('Booking not found')
|
||||||
|
) {
|
||||||
errorMessage = t('common.bookingNotFound');
|
errorMessage = t('common.bookingNotFound');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,7 +68,7 @@ export default function CarrierAcceptPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setCountdown((prev) => {
|
setCountdown(prev => {
|
||||||
if (prev <= 1) {
|
if (prev <= 1) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
router.push('/');
|
router.push('/');
|
||||||
@ -91,12 +94,8 @@ export default function CarrierAcceptPage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50">
|
||||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
||||||
<Loader2 className="w-16 h-16 text-green-600 mx-auto mb-4 animate-spin" />
|
<Loader2 className="w-16 h-16 text-green-600 mx-auto mb-4 animate-spin" />
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('accept.loadingTitle')}</h1>
|
||||||
{t('accept.loadingTitle')}
|
<p className="text-gray-600">{t('accept.loadingMessage')}</p>
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{t('accept.loadingMessage')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -124,22 +123,14 @@ export default function CarrierAcceptPage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50">
|
||||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
||||||
<CheckCircle className="w-20 h-20 text-green-500 mx-auto mb-6" />
|
<CheckCircle className="w-20 h-20 text-green-500 mx-auto mb-6" />
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
<h1 className="text-3xl font-bold text-gray-900 mb-4">{t('accept.thanksTitle')}</h1>
|
||||||
{t('accept.thanksTitle')}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6 mb-6">
|
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6 mb-6">
|
||||||
<p className="text-green-800 font-medium text-lg mb-2">
|
<p className="text-green-800 font-medium text-lg mb-2">{t('accept.successHeadline')}</p>
|
||||||
{t('accept.successHeadline')}
|
<p className="text-green-700 text-sm">{t('accept.successBody')}</p>
|
||||||
</p>
|
|
||||||
<p className="text-green-700 text-sm">
|
|
||||||
{t('accept.successBody')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-500 text-sm mb-4">
|
<p className="text-gray-500 text-sm mb-4">{t('common.redirecting', { countdown })}</p>
|
||||||
{t('common.redirecting', { countdown })}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/')}
|
onClick={() => router.push('/')}
|
||||||
|
|||||||
@ -189,7 +189,10 @@ export default function CarrierDocumentsPage() {
|
|||||||
throw new Error(t('notAcceptedYet'));
|
throw new Error(t('notAcceptedYet'));
|
||||||
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
|
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
|
||||||
throw new Error(t('bookingNotFound'));
|
throw new Error(t('bookingNotFound'));
|
||||||
} else if (errorMessage.includes('Mot de passe requis') || errorMessage.includes('required')) {
|
} else if (
|
||||||
|
errorMessage.includes('Mot de passe requis') ||
|
||||||
|
errorMessage.includes('required')
|
||||||
|
) {
|
||||||
setRequirements({ requiresPassword: true, status: 'ACCEPTED' });
|
setRequirements({ requiresPassword: true, status: 'ACCEPTED' });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@ -336,12 +339,11 @@ export default function CarrierDocumentsPage() {
|
|||||||
<Lock className="w-8 h-8 text-brand-turquoise" />
|
<Lock className="w-8 h-8 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('password.title')}</h1>
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('password.title')}</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">{t('password.intro')}</p>
|
||||||
{t('password.intro')}
|
|
||||||
</p>
|
|
||||||
{requirements.bookingNumber && (
|
{requirements.bookingNumber && (
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
{t('password.bookingLabel')} <span className="font-mono font-bold">{requirements.bookingNumber}</span>
|
{t('password.bookingLabel')}{' '}
|
||||||
|
<span className="font-mono font-bold">{requirements.bookingNumber}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -467,7 +469,9 @@ export default function CarrierDocumentsPage() {
|
|||||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||||
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
||||||
<p className="text-xs text-gray-500">{t('summary.transit')}</p>
|
<p className="text-xs text-gray-500">{t('summary.transit')}</p>
|
||||||
<p className="font-semibold text-gray-900">{t('summary.transitDays', { count: booking.transitDays })}</p>
|
<p className="font-semibold text-gray-900">
|
||||||
|
{t('summary.transitDays', { count: booking.transitDays })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||||
<Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
<Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
||||||
@ -504,9 +508,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||||
<p className="text-gray-600">{t('list.empty')}</p>
|
<p className="text-gray-600">{t('list.empty')}</p>
|
||||||
<p className="text-gray-500 text-sm mt-1">
|
<p className="text-gray-500 text-sm mt-1">{t('list.emptyHint')}</p>
|
||||||
{t('list.emptyHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-100">
|
<div className="divide-y divide-gray-100">
|
||||||
@ -552,9 +554,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<p className="mt-6 text-center text-sm text-gray-500">
|
<p className="mt-6 text-center text-sm text-gray-500">{t('footerNote')}</p>
|
||||||
{t('footerNote')}
|
|
||||||
</p>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@ -55,7 +55,10 @@ export default function CarrierRejectPage() {
|
|||||||
errorMessage = t('common.bookingAlreadyRejected');
|
errorMessage = t('common.bookingAlreadyRejected');
|
||||||
} else if (errorMessage.includes('status ACCEPTED')) {
|
} else if (errorMessage.includes('status ACCEPTED')) {
|
||||||
errorMessage = t('common.bookingAlreadyAccepted');
|
errorMessage = t('common.bookingAlreadyAccepted');
|
||||||
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) {
|
} else if (
|
||||||
|
errorMessage.includes('not found') ||
|
||||||
|
errorMessage.includes('Booking not found')
|
||||||
|
) {
|
||||||
errorMessage = t('common.bookingNotFound');
|
errorMessage = t('common.bookingNotFound');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,7 +68,7 @@ export default function CarrierRejectPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setCountdown((prev) => {
|
setCountdown(prev => {
|
||||||
if (prev <= 1) {
|
if (prev <= 1) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
router.push('/');
|
router.push('/');
|
||||||
@ -91,12 +94,8 @@ export default function CarrierRejectPage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
|
||||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
||||||
<Loader2 className="w-16 h-16 text-orange-600 mx-auto mb-4 animate-spin" />
|
<Loader2 className="w-16 h-16 text-orange-600 mx-auto mb-4 animate-spin" />
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('reject.loadingTitle')}</h1>
|
||||||
{t('reject.loadingTitle')}
|
<p className="text-gray-600">{t('reject.loadingMessage')}</p>
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{t('reject.loadingMessage')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -124,22 +123,14 @@ export default function CarrierRejectPage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 to-red-50">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 to-red-50">
|
||||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
||||||
<CheckCircle className="w-20 h-20 text-orange-500 mx-auto mb-6" />
|
<CheckCircle className="w-20 h-20 text-orange-500 mx-auto mb-6" />
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
<h1 className="text-3xl font-bold text-gray-900 mb-4">{t('reject.thanksTitle')}</h1>
|
||||||
{t('reject.thanksTitle')}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-6 mb-6">
|
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-6 mb-6">
|
||||||
<p className="text-orange-800 font-medium text-lg mb-2">
|
<p className="text-orange-800 font-medium text-lg mb-2">{t('reject.successHeadline')}</p>
|
||||||
{t('reject.successHeadline')}
|
<p className="text-orange-700 text-sm">{t('reject.successBody')}</p>
|
||||||
</p>
|
|
||||||
<p className="text-orange-700 text-sm">
|
|
||||||
{t('reject.successBody')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-500 text-sm mb-4">
|
<p className="text-gray-500 text-sm mb-4">{t('common.redirecting', { countdown })}</p>
|
||||||
{t('common.redirecting', { countdown })}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/')}
|
onClick={() => router.push('/')}
|
||||||
|
|||||||
@ -77,7 +77,10 @@ export default function CompliancePage() {
|
|||||||
<LandingHeader />
|
<LandingHeader />
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
<section
|
||||||
|
ref={heroRef}
|
||||||
|
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
|
||||||
|
>
|
||||||
<div className="absolute inset-0 opacity-10">
|
<div className="absolute inset-0 opacity-10">
|
||||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||||
@ -148,9 +151,7 @@ export default function CompliancePage() {
|
|||||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||||
{t('rightsTitle')}
|
{t('rightsTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('rightsSubtitle')}</p>
|
||||||
{t('rightsSubtitle')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -159,7 +160,7 @@ export default function CompliancePage() {
|
|||||||
animate={isContentInView ? 'visible' : 'hidden'}
|
animate={isContentInView ? 'visible' : 'hidden'}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
||||||
>
|
>
|
||||||
{RIGHTS.map((right) => {
|
{RIGHTS.map(right => {
|
||||||
const IconComponent = right.icon;
|
const IconComponent = right.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -171,7 +172,9 @@ export default function CompliancePage() {
|
|||||||
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<IconComponent className="w-8 h-8 text-brand-turquoise" />
|
<IconComponent className="w-8 h-8 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`rights.${right.key}.title`)}</h3>
|
<h3 className="text-xl font-bold text-brand-navy mb-3">
|
||||||
|
{t(`rights.${right.key}.title`)}
|
||||||
|
</h3>
|
||||||
<p className="text-gray-600">{t(`rights.${right.key}.description`)}</p>
|
<p className="text-gray-600">{t(`rights.${right.key}.description`)}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
@ -185,9 +188,7 @@ export default function CompliancePage() {
|
|||||||
transition={{ duration: 0.8, delay: 0.4 }}
|
transition={{ duration: 0.8, delay: 0.4 }}
|
||||||
className="mt-12 text-center"
|
className="mt-12 text-center"
|
||||||
>
|
>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">{t('rightsCta.text')}</p>
|
||||||
{t('rightsCta.text')}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
@ -219,9 +220,7 @@ export default function CompliancePage() {
|
|||||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||||
{t('principlesTitle')}
|
{t('principlesTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('principlesSubtitle')}</p>
|
||||||
{t('principlesSubtitle')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
@ -239,8 +238,12 @@ export default function CompliancePage() {
|
|||||||
<div className="w-12 h-12 bg-brand-green/10 rounded-xl flex items-center justify-center mb-4">
|
<div className="w-12 h-12 bg-brand-green/10 rounded-xl flex items-center justify-center mb-4">
|
||||||
<IconComponent className="w-6 h-6 text-brand-green" />
|
<IconComponent className="w-6 h-6 text-brand-green" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-bold text-brand-navy mb-2">{t(`principles.${principle.key}.title`)}</h3>
|
<h3 className="text-lg font-bold text-brand-navy mb-2">
|
||||||
<p className="text-gray-600 text-sm">{t(`principles.${principle.key}.description`)}</p>
|
{t(`principles.${principle.key}.title`)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
{t(`principles.${principle.key}.description`)}
|
||||||
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -261,9 +264,7 @@ export default function CompliancePage() {
|
|||||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||||
{t('measuresTitle')}
|
{t('measuresTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('measuresSubtitle')}</p>
|
||||||
{t('measuresSubtitle')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
@ -278,7 +279,7 @@ export default function CompliancePage() {
|
|||||||
>
|
>
|
||||||
<h3 className="text-xl font-bold text-white mb-6">{t(`measures.${key}.title`)}</h3>
|
<h3 className="text-xl font-bold text-white mb-6">{t(`measures.${key}.title`)}</h3>
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
{MEASURE_ITEMS.map((itemKey) => (
|
{MEASURE_ITEMS.map(itemKey => (
|
||||||
<li key={itemKey} className="flex items-center space-x-3 text-white/80">
|
<li key={itemKey} className="flex items-center space-x-3 text-white/80">
|
||||||
<CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" />
|
<CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" />
|
||||||
<span>{t(`measures.${key}.${itemKey}` as any)}</span>
|
<span>{t(`measures.${key}.${itemKey}` as any)}</span>
|
||||||
@ -306,14 +307,10 @@ export default function CompliancePage() {
|
|||||||
<FileText className="w-6 h-6 text-brand-turquoise" />
|
<FileText className="w-6 h-6 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-bold text-brand-navy mb-4">
|
<h3 className="text-2xl font-bold text-brand-navy mb-4">{t('register.title')}</h3>
|
||||||
{t('register.title')}
|
<p className="text-gray-600 mb-6">{t('register.body')}</p>
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
{t('register.body')}
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-3 text-gray-600">
|
<ul className="space-y-3 text-gray-600">
|
||||||
{REGISTER_ITEMS.map((itemKey) => (
|
{REGISTER_ITEMS.map(itemKey => (
|
||||||
<li key={itemKey} className="flex items-center space-x-3">
|
<li key={itemKey} className="flex items-center space-x-3">
|
||||||
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
|
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
|
||||||
<span>{t(`register.${itemKey}` as any)}</span>
|
<span>{t(`register.${itemKey}` as any)}</span>
|
||||||
@ -337,12 +334,8 @@ export default function CompliancePage() {
|
|||||||
className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
|
className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
|
||||||
>
|
>
|
||||||
<UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
|
<UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
|
||||||
<h3 className="text-2xl font-bold text-white mb-4">
|
<h3 className="text-2xl font-bold text-white mb-4">{t('dpo.title')}</h3>
|
||||||
{t('dpo.title')}
|
<p className="text-white/80 mb-6 max-w-2xl mx-auto">{t('dpo.body')}</p>
|
||||||
</h3>
|
|
||||||
<p className="text-white/80 mb-6 max-w-2xl mx-auto">
|
|
||||||
{t('dpo.body')}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||||
<a
|
<a
|
||||||
href="mailto:dpo@xpeditis.com"
|
href="mailto:dpo@xpeditis.com"
|
||||||
|
|||||||
@ -34,7 +34,15 @@ const METHODS: { key: MethodKey; icon: LucideIcon; color: string }[] = [
|
|||||||
{ key: 'support', icon: Headphones, color: 'from-orange-500 to-red-500' },
|
{ key: 'support', icon: Headphones, color: 'from-orange-500 to-red-500' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SUBJECTS: SubjectKey[] = ['demo', 'pricing', 'partnership', 'support', 'press', 'careers', 'other'];
|
const SUBJECTS: SubjectKey[] = [
|
||||||
|
'demo',
|
||||||
|
'pricing',
|
||||||
|
'partnership',
|
||||||
|
'support',
|
||||||
|
'press',
|
||||||
|
'careers',
|
||||||
|
'other',
|
||||||
|
];
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
const t = useTranslations('marketing.contact');
|
const t = useTranslations('marketing.contact');
|
||||||
@ -89,7 +97,7 @@ export default function ContactPage() {
|
|||||||
const handleChange = (
|
const handleChange = (
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||||
) => {
|
) => {
|
||||||
setFormData((prev) => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[e.target.name]: e.target.value,
|
[e.target.name]: e.target.value,
|
||||||
}));
|
}));
|
||||||
@ -121,7 +129,10 @@ export default function ContactPage() {
|
|||||||
<LandingHeader activePage="contact" />
|
<LandingHeader activePage="contact" />
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
<section
|
||||||
|
ref={heroRef}
|
||||||
|
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
|
||||||
|
>
|
||||||
<div className="absolute inset-0 opacity-10">
|
<div className="absolute inset-0 opacity-10">
|
||||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||||
@ -178,7 +189,7 @@ export default function ContactPage() {
|
|||||||
className="max-w-7xl mx-auto px-6 lg:px-8"
|
className="max-w-7xl mx-auto px-6 lg:px-8"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{METHODS.map((method) => {
|
{METHODS.map(method => {
|
||||||
const IconComponent = method.icon;
|
const IconComponent = method.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -191,9 +202,15 @@ export default function ContactPage() {
|
|||||||
>
|
>
|
||||||
<IconComponent className="w-6 h-6 text-white" />
|
<IconComponent className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-bold text-brand-navy mb-1">{t(`methods.${method.key}.title`)}</h3>
|
<h3 className="text-lg font-bold text-brand-navy mb-1">
|
||||||
<p className="text-gray-500 text-sm mb-2">{t(`methods.${method.key}.description`)}</p>
|
{t(`methods.${method.key}.title`)}
|
||||||
<p className="text-brand-turquoise font-medium">{t(`methods.${method.key}.value`)}</p>
|
</h3>
|
||||||
|
<p className="text-gray-500 text-sm mb-2">
|
||||||
|
{t(`methods.${method.key}.description`)}
|
||||||
|
</p>
|
||||||
|
<p className="text-brand-turquoise font-medium">
|
||||||
|
{t(`methods.${method.key}.value`)}
|
||||||
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -212,9 +229,7 @@ export default function ContactPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('form.title')}</h2>
|
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('form.title')}</h2>
|
||||||
<p className="text-gray-600 mb-8">
|
<p className="text-gray-600 mb-8">{t('form.description')}</p>
|
||||||
{t('form.description')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{isSubmitted ? (
|
{isSubmitted ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -225,10 +240,10 @@ export default function ContactPage() {
|
|||||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-green-800 mb-2">{t('form.successTitle')}</h3>
|
<h3 className="text-2xl font-bold text-green-800 mb-2">
|
||||||
<p className="text-green-700 mb-6">
|
{t('form.successTitle')}
|
||||||
{t('form.successBody')}
|
</h3>
|
||||||
</p>
|
<p className="text-green-700 mb-6">{t('form.successBody')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsSubmitted(false);
|
setIsSubmitted(false);
|
||||||
@ -251,7 +266,10 @@ export default function ContactPage() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
htmlFor="firstName"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
{t('form.firstName')} *
|
{t('form.firstName')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -266,7 +284,10 @@ export default function ContactPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
htmlFor="lastName"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
{t('form.lastName')} *
|
{t('form.lastName')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -284,7 +305,10 @@ export default function ContactPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
{t('form.email')} *
|
{t('form.email')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -299,7 +323,10 @@ export default function ContactPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
htmlFor="phone"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
{t('form.phone')}
|
{t('form.phone')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -315,7 +342,10 @@ export default function ContactPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
htmlFor="company"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
{t('form.company')}
|
{t('form.company')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -330,7 +360,10 @@ export default function ContactPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
htmlFor="subject"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
{t('form.subject')} *
|
{t('form.subject')} *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@ -342,7 +375,7 @@ export default function ContactPage() {
|
|||||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
|
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
|
||||||
>
|
>
|
||||||
<option value="">{t('subjects.placeholder')}</option>
|
<option value="">{t('subjects.placeholder')}</option>
|
||||||
{SUBJECTS.map((key) => (
|
{SUBJECTS.map(key => (
|
||||||
<option key={key} value={key}>
|
<option key={key} value={key}>
|
||||||
{t(`subjects.${key}`)}
|
{t(`subjects.${key}`)}
|
||||||
</option>
|
</option>
|
||||||
@ -351,7 +384,10 @@ export default function ContactPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
htmlFor="message"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
{t('form.message')} *
|
{t('form.message')} *
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -400,9 +436,7 @@ export default function ContactPage() {
|
|||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
transition={{ duration: 0.8, delay: 0.2 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('office.title')}</h2>
|
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('office.title')}</h2>
|
||||||
<p className="text-gray-600 mb-8">
|
<p className="text-gray-600 mb-8">{t('office.subtitle')}</p>
|
||||||
{t('office.subtitle')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-white p-6 rounded-2xl border-2 border-brand-turquoise shadow-lg">
|
<div className="bg-white p-6 rounded-2xl border-2 border-brand-turquoise shadow-lg">
|
||||||
@ -427,13 +461,19 @@ export default function ContactPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Phone className="w-4 h-4 text-gray-400" />
|
<Phone className="w-4 h-4 text-gray-400" />
|
||||||
<a href={`tel:${t('office.phone').replace(/\s/g, '')}`} className="hover:text-brand-turquoise">
|
<a
|
||||||
|
href={`tel:${t('office.phone').replace(/\s/g, '')}`}
|
||||||
|
className="hover:text-brand-turquoise"
|
||||||
|
>
|
||||||
{t('office.phone')}
|
{t('office.phone')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Mail className="w-4 h-4 text-gray-400" />
|
<Mail className="w-4 h-4 text-gray-400" />
|
||||||
<a href={`mailto:${t('office.email')}`} className="hover:text-brand-turquoise">
|
<a
|
||||||
|
href={`mailto:${t('office.email')}`}
|
||||||
|
className="hover:text-brand-turquoise"
|
||||||
|
>
|
||||||
{t('office.email')}
|
{t('office.email')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -463,9 +503,7 @@ export default function ContactPage() {
|
|||||||
<span className="font-medium text-gray-400">{t('hours.closed')}</span>
|
<span className="font-medium text-gray-400">{t('hours.closed')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm text-gray-500">
|
<p className="mt-4 text-sm text-gray-500">{t('hours.supportNote')}</p>
|
||||||
{t('hours.supportNote')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@ -511,7 +549,9 @@ export default function ContactPage() {
|
|||||||
<div className="w-10 h-10 bg-brand-turquoise/30 rounded-xl flex items-center justify-center flex-shrink-0">
|
<div className="w-10 h-10 bg-brand-turquoise/30 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||||
<CheckCircle2 className="w-5 h-5 text-brand-turquoise" />
|
<CheckCircle2 className="w-5 h-5 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-bold text-white">{t('afterSubmit.commitmentTitle')}</h3>
|
<h3 className="text-lg font-bold text-white">
|
||||||
|
{t('afterSubmit.commitmentTitle')}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/80 leading-relaxed">
|
<p className="text-white/80 leading-relaxed">
|
||||||
{t('afterSubmit.commitmentBody1')}
|
{t('afterSubmit.commitmentBody1')}
|
||||||
@ -532,11 +572,16 @@ export default function ContactPage() {
|
|||||||
<div className="w-10 h-10 bg-brand-green/30 rounded-xl flex items-center justify-center flex-shrink-0">
|
<div className="w-10 h-10 bg-brand-green/30 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||||
<Shield className="w-5 h-5 text-brand-green" />
|
<Shield className="w-5 h-5 text-brand-green" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-bold text-white">{t('afterSubmit.securityTitle')}</h3>
|
<h3 className="text-lg font-bold text-white">
|
||||||
|
{t('afterSubmit.securityTitle')}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/80 leading-relaxed">
|
<p className="text-white/80 leading-relaxed">
|
||||||
{t('afterSubmit.securityBody1')}
|
{t('afterSubmit.securityBody1')}
|
||||||
<Link href="/privacy" className="text-brand-turquoise font-semibold hover:underline">
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="text-brand-turquoise font-semibold hover:underline"
|
||||||
|
>
|
||||||
{t('afterSubmit.privacyLink')}
|
{t('afterSubmit.privacyLink')}
|
||||||
</Link>
|
</Link>
|
||||||
{t('afterSubmit.securityBody2')}
|
{t('afterSubmit.securityBody2')}
|
||||||
@ -577,10 +622,14 @@ export default function ContactPage() {
|
|||||||
<div className="w-14 h-14 bg-gradient-to-br from-brand-turquoise to-cyan-400 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
|
<div className="w-14 h-14 bg-gradient-to-br from-brand-turquoise to-cyan-400 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
|
||||||
<Zap className="w-7 h-7 text-white" />
|
<Zap className="w-7 h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-brand-navy mb-3">{t('quickAccess.pricingTitle')}</h3>
|
<h3 className="text-xl font-bold text-brand-navy mb-3">
|
||||||
|
{t('quickAccess.pricingTitle')}
|
||||||
|
</h3>
|
||||||
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
|
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
|
||||||
{t('quickAccess.pricingBody1')}
|
{t('quickAccess.pricingBody1')}
|
||||||
<span className="font-semibold text-brand-navy">{t('quickAccess.pricingHighlight')}</span>
|
<span className="font-semibold text-brand-navy">
|
||||||
|
{t('quickAccess.pricingHighlight')}
|
||||||
|
</span>
|
||||||
{t('quickAccess.pricingBody2')}
|
{t('quickAccess.pricingBody2')}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
@ -603,10 +652,14 @@ export default function ContactPage() {
|
|||||||
<div className="w-14 h-14 bg-gradient-to-br from-brand-navy to-brand-navy/80 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
|
<div className="w-14 h-14 bg-gradient-to-br from-brand-navy to-brand-navy/80 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
|
||||||
<BookOpen className="w-7 h-7 text-brand-turquoise" />
|
<BookOpen className="w-7 h-7 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-brand-navy mb-3">{t('quickAccess.wikiTitle')}</h3>
|
<h3 className="text-xl font-bold text-brand-navy mb-3">
|
||||||
|
{t('quickAccess.wikiTitle')}
|
||||||
|
</h3>
|
||||||
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
|
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
|
||||||
{t('quickAccess.wikiBody1')}
|
{t('quickAccess.wikiBody1')}
|
||||||
<span className="font-semibold text-brand-navy">{t('quickAccess.wikiHighlight')}</span>
|
<span className="font-semibold text-brand-navy">
|
||||||
|
{t('quickAccess.wikiHighlight')}
|
||||||
|
</span>
|
||||||
{t('quickAccess.wikiBody2')}
|
{t('quickAccess.wikiBody2')}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -3,7 +3,16 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { motion, useInView } from 'framer-motion';
|
import { motion, useInView } from 'framer-motion';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Cookie, Settings, BarChart3, Target, Shield, ToggleLeft, Mail, type LucideIcon } from 'lucide-react';
|
import {
|
||||||
|
Cookie,
|
||||||
|
Settings,
|
||||||
|
BarChart3,
|
||||||
|
Target,
|
||||||
|
Shield,
|
||||||
|
ToggleLeft,
|
||||||
|
Mail,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||||
|
|
||||||
type CookieTypeKey = 'essential' | 'analytics' | 'marketing' | 'functional';
|
type CookieTypeKey = 'essential' | 'analytics' | 'marketing' | 'functional';
|
||||||
@ -99,7 +108,10 @@ export default function CookiesPage() {
|
|||||||
<LandingHeader />
|
<LandingHeader />
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
<section
|
||||||
|
ref={heroRef}
|
||||||
|
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
|
||||||
|
>
|
||||||
<div className="absolute inset-0 opacity-10">
|
<div className="absolute inset-0 opacity-10">
|
||||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||||
@ -184,7 +196,7 @@ export default function CookiesPage() {
|
|||||||
animate={isContentInView ? 'visible' : 'hidden'}
|
animate={isContentInView ? 'visible' : 'hidden'}
|
||||||
className="space-y-8"
|
className="space-y-8"
|
||||||
>
|
>
|
||||||
{COOKIE_TYPES.map((type) => {
|
{COOKIE_TYPES.map(type => {
|
||||||
const IconComponent = type.icon;
|
const IconComponent = type.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -234,9 +246,11 @@ export default function CookiesPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{type.cookies.map((cookie) => (
|
{type.cookies.map(cookie => (
|
||||||
<tr key={cookie.name} className="border-b border-gray-100 last:border-0">
|
<tr key={cookie.name} className="border-b border-gray-100 last:border-0">
|
||||||
<td className="py-3 px-4 font-mono text-brand-turquoise">{cookie.name}</td>
|
<td className="py-3 px-4 font-mono text-brand-turquoise">
|
||||||
|
{cookie.name}
|
||||||
|
</td>
|
||||||
<td className="py-3 px-4 text-gray-600">
|
<td className="py-3 px-4 text-gray-600">
|
||||||
{t(`purposes.${cookie.purposeKey}` as any)}
|
{t(`purposes.${cookie.purposeKey}` as any)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
678
apps/frontend/app/[locale]/dashboard/admin/blog/page.tsx
Normal file
678
apps/frontend/app/[locale]/dashboard/admin/blog/page.tsx
Normal 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'article</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Êtes-vous sûr de vouloir supprimer <strong>« {selectedPost.title} »</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -100,7 +100,14 @@ export default function AdminBookingsPage() {
|
|||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
const getStatusLabel = (status: string) => {
|
||||||
const key = status.toUpperCase();
|
const key = status.toUpperCase();
|
||||||
const allowed = ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'];
|
const allowed = [
|
||||||
|
'PENDING_PAYMENT',
|
||||||
|
'PENDING_BANK_TRANSFER',
|
||||||
|
'PENDING',
|
||||||
|
'ACCEPTED',
|
||||||
|
'REJECTED',
|
||||||
|
'CANCELLED',
|
||||||
|
];
|
||||||
if (allowed.includes(key)) {
|
if (allowed.includes(key)) {
|
||||||
return t(`status.${key}` as any);
|
return t(`status.${key}` as any);
|
||||||
}
|
}
|
||||||
@ -143,9 +150,7 @@ export default function AdminBookingsPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{t('title')}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">{t('title')}</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">{t('subtitle')}</p>
|
||||||
{t('subtitle')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
@ -155,13 +160,17 @@ export default function AdminBookingsPage() {
|
|||||||
<div className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div>
|
<div className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4">
|
<div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4">
|
||||||
<div className="text-xs text-amber-700 uppercase tracking-wide">{t('stats.pendingBankTransfer')}</div>
|
<div className="text-xs text-amber-700 uppercase tracking-wide">
|
||||||
|
{t('stats.pendingBankTransfer')}
|
||||||
|
</div>
|
||||||
<div className="text-2xl font-bold text-amber-700 mt-1">
|
<div className="text-2xl font-bold text-amber-700 mt-1">
|
||||||
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
|
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.pendingCarrier')}</div>
|
<div className="text-xs text-gray-500 uppercase tracking-wide">
|
||||||
|
{t('stats.pendingCarrier')}
|
||||||
|
</div>
|
||||||
<div className="text-2xl font-bold text-yellow-600 mt-1">
|
<div className="text-2xl font-bold text-yellow-600 mt-1">
|
||||||
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
|
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
|
||||||
</div>
|
</div>
|
||||||
@ -184,7 +193,9 @@ export default function AdminBookingsPage() {
|
|||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('search.label')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('search.label')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('search.placeholder')}
|
placeholder={t('search.placeholder')}
|
||||||
@ -194,7 +205,9 @@ export default function AdminBookingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('filter.label')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('filter.label')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={e => setFilterStatus(e.target.value)}
|
onChange={e => setFilterStatus(e.target.value)}
|
||||||
@ -261,7 +274,9 @@ export default function AdminBookingsPage() {
|
|||||||
{/* N° Booking */}
|
{/* N° Booking */}
|
||||||
<td className="px-4 py-4 whitespace-nowrap">
|
<td className="px-4 py-4 whitespace-nowrap">
|
||||||
{booking.bookingNumber && (
|
{booking.bookingNumber && (
|
||||||
<div className="text-sm font-semibold text-gray-900">{booking.bookingNumber}</div>
|
<div className="text-sm font-semibold text-gray-900">
|
||||||
|
{booking.bookingNumber}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs text-gray-400 font-mono">{getShortId(booking)}</div>
|
<div className="text-xs text-gray-400 font-mono">{getShortId(booking)}</div>
|
||||||
</td>
|
</td>
|
||||||
@ -278,11 +293,15 @@ export default function AdminBookingsPage() {
|
|||||||
<div className="text-sm text-gray-900">
|
<div className="text-sm text-gray-900">
|
||||||
{booking.containerType}
|
{booking.containerType}
|
||||||
{booking.palletCount != null && (
|
{booking.palletCount != null && (
|
||||||
<span className="ml-1 text-gray-500">· {booking.palletCount} {t('table.pallets')}</span>
|
<span className="ml-1 text-gray-500">
|
||||||
|
· {booking.palletCount} {t('table.pallets')}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 space-x-2">
|
<div className="text-xs text-gray-500 space-x-2">
|
||||||
{booking.weightKG != null && <span>{booking.weightKG.toLocaleString(dateLocale)} kg</span>}
|
{booking.weightKG != null && (
|
||||||
|
<span>{booking.weightKG.toLocaleString(dateLocale)} kg</span>
|
||||||
|
)}
|
||||||
{booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>}
|
{booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -294,20 +313,24 @@ export default function AdminBookingsPage() {
|
|||||||
|
|
||||||
{/* Statut */}
|
{/* Statut */}
|
||||||
<td className="px-4 py-4 whitespace-nowrap">
|
<td className="px-4 py-4 whitespace-nowrap">
|
||||||
<span className={`px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}>
|
<span
|
||||||
|
className={`px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}
|
||||||
|
>
|
||||||
{getStatusLabel(booking.status)}
|
{getStatusLabel(booking.status)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Date */}
|
{/* Date */}
|
||||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString(dateLocale)}
|
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString(
|
||||||
|
dateLocale
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<td className="px-4 py-4 whitespace-nowrap text-right text-sm">
|
<td className="px-4 py-4 whitespace-nowrap text-right text-sm">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={e => {
|
||||||
if (openMenuId === booking.id) {
|
if (openMenuId === booking.id) {
|
||||||
setOpenMenuId(null);
|
setOpenMenuId(null);
|
||||||
setMenuPosition(null);
|
setMenuPosition(null);
|
||||||
@ -319,7 +342,11 @@ export default function AdminBookingsPage() {
|
|||||||
}}
|
}}
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-600"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@ -336,7 +363,10 @@ export default function AdminBookingsPage() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[998]"
|
className="fixed inset-0 z-[998]"
|
||||||
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
|
onClick={() => {
|
||||||
|
setOpenMenuId(null);
|
||||||
|
setMenuPosition(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
|
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
|
||||||
@ -355,9 +385,24 @@ export default function AdminBookingsPage() {
|
|||||||
}}
|
}}
|
||||||
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
|
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
className="w-5 h-5 text-blue-600"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-medium text-gray-700">{t('menu.viewDetails')}</span>
|
<span className="text-sm font-medium text-gray-700">{t('menu.viewDetails')}</span>
|
||||||
</button>
|
</button>
|
||||||
@ -374,10 +419,22 @@ export default function AdminBookingsPage() {
|
|||||||
disabled={validatingId === openMenuId}
|
disabled={validatingId === openMenuId}
|
||||||
className="w-full px-4 py-3 text-left hover:bg-green-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-gray-200"
|
className="w-full px-4 py-3 text-left hover:bg-green-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-gray-200"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="w-5 h-5 text-green-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-medium text-green-700">{t('menu.validateTransfer')}</span>
|
<span className="text-sm font-medium text-green-700">
|
||||||
|
{t('menu.validateTransfer')}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
@ -391,8 +448,18 @@ export default function AdminBookingsPage() {
|
|||||||
disabled={deletingId === openMenuId}
|
disabled={deletingId === openMenuId}
|
||||||
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
|
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
className="w-5 h-5 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
|
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
|
||||||
</button>
|
</button>
|
||||||
@ -408,11 +475,19 @@ export default function AdminBookingsPage() {
|
|||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-xl font-bold text-gray-900">{t('modal.title')}</h2>
|
<h2 className="text-xl font-bold text-gray-900">{t('modal.title')}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
|
onClick={() => {
|
||||||
|
setShowDetailsModal(false);
|
||||||
|
setSelectedBooking(null);
|
||||||
|
}}
|
||||||
className="text-gray-400 hover:text-gray-600"
|
className="text-gray-400 hover:text-gray-600"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -420,60 +495,98 @@ export default function AdminBookingsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">{t('modal.bookingNumber')}</label>
|
<label className="block text-sm font-medium text-gray-500">
|
||||||
|
{t('modal.bookingNumber')}
|
||||||
|
</label>
|
||||||
<div className="mt-1 text-lg font-semibold text-gray-900">
|
<div className="mt-1 text-lg font-semibold text-gray-900">
|
||||||
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
|
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">{t('modal.status')}</label>
|
<label className="block text-sm font-medium text-gray-500">
|
||||||
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
|
{t('modal.status')}
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}
|
||||||
|
>
|
||||||
{getStatusLabel(selectedBooking.status)}
|
{getStatusLabel(selectedBooking.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.routeSection')}</h3>
|
<h3 className="text-sm font-medium text-gray-900 mb-3">
|
||||||
|
{t('modal.routeSection')}
|
||||||
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">{t('modal.origin')}</label>
|
<label className="block text-sm font-medium text-gray-500">
|
||||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || t('modal.none')}</div>
|
{t('modal.origin')}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 font-semibold text-gray-900">
|
||||||
|
{selectedBooking.origin || t('modal.none')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">{t('modal.destination')}</label>
|
<label className="block text-sm font-medium text-gray-500">
|
||||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || t('modal.none')}</div>
|
{t('modal.destination')}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 font-semibold text-gray-900">
|
||||||
|
{selectedBooking.destination || t('modal.none')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.cargoSection')}</h3>
|
<h3 className="text-sm font-medium text-gray-900 mb-3">
|
||||||
|
{t('modal.cargoSection')}
|
||||||
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">{t('modal.carrier')}</label>
|
<label className="block text-sm font-medium text-gray-500">
|
||||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || t('modal.none')}</div>
|
{t('modal.carrier')}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 font-semibold text-gray-900">
|
||||||
|
{selectedBooking.carrierName || t('modal.none')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">{t('modal.containerType')}</label>
|
<label className="block text-sm font-medium text-gray-500">
|
||||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.containerType}</div>
|
{t('modal.containerType')}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 font-semibold text-gray-900">
|
||||||
|
{selectedBooking.containerType}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedBooking.palletCount != null && (
|
{selectedBooking.palletCount != null && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">{t('modal.pallets')}</label>
|
<label className="block text-sm font-medium text-gray-500">
|
||||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.palletCount}</div>
|
{t('modal.pallets')}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 font-semibold text-gray-900">
|
||||||
|
{selectedBooking.palletCount}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedBooking.weightKG != null && (
|
{selectedBooking.weightKG != null && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">{t('modal.weight')}</label>
|
<label className="block text-sm font-medium text-gray-500">
|
||||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString(dateLocale)} kg</div>
|
{t('modal.weight')}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 font-semibold text-gray-900">
|
||||||
|
{selectedBooking.weightKG.toLocaleString(dateLocale)} kg
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedBooking.volumeCBM != null && (
|
{selectedBooking.volumeCBM != null && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">{t('modal.volume')}</label>
|
<label className="block text-sm font-medium text-gray-500">
|
||||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.volumeCBM} CBM</div>
|
{t('modal.volume')}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 font-semibold text-gray-900">
|
||||||
|
{selectedBooking.volumeCBM} CBM
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -481,18 +594,24 @@ export default function AdminBookingsPage() {
|
|||||||
|
|
||||||
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
|
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.priceSection')}</h3>
|
<h3 className="text-sm font-medium text-gray-900 mb-3">
|
||||||
|
{t('modal.priceSection')}
|
||||||
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{selectedBooking.priceEUR != null && (
|
{selectedBooking.priceEUR != null && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">EUR</label>
|
<label className="block text-sm font-medium text-gray-500">EUR</label>
|
||||||
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceEUR.toLocaleString(dateLocale)} €</div>
|
<div className="mt-1 text-xl font-bold text-blue-600">
|
||||||
|
{selectedBooking.priceEUR.toLocaleString(dateLocale)} €
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedBooking.priceUSD != null && (
|
{selectedBooking.priceUSD != null && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">USD</label>
|
<label className="block text-sm font-medium text-gray-500">USD</label>
|
||||||
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceUSD.toLocaleString(dateLocale)} $</div>
|
<div className="mt-1 text-xl font-bold text-blue-600">
|
||||||
|
{selectedBooking.priceUSD.toLocaleString(dateLocale)} $
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -500,18 +619,24 @@ export default function AdminBookingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.datesSection')}</h3>
|
<h3 className="text-sm font-medium text-gray-900 mb-3">
|
||||||
|
{t('modal.datesSection')}
|
||||||
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-gray-500">{t('modal.createdAt')}</label>
|
<label className="block text-gray-500">{t('modal.createdAt')}</label>
|
||||||
<div className="mt-1 text-gray-900">
|
<div className="mt-1 text-gray-900">
|
||||||
{new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString(dateLocale)}
|
{new Date(
|
||||||
|
selectedBooking.requestedAt || selectedBooking.createdAt || ''
|
||||||
|
).toLocaleString(dateLocale)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedBooking.updatedAt && (
|
{selectedBooking.updatedAt && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-gray-500">{t('modal.updatedAt')}</label>
|
<label className="block text-gray-500">{t('modal.updatedAt')}</label>
|
||||||
<div className="mt-1 text-gray-900">{new Date(selectedBooking.updatedAt).toLocaleString(dateLocale)}</div>
|
<div className="mt-1 text-gray-900">
|
||||||
|
{new Date(selectedBooking.updatedAt).toLocaleString(dateLocale)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -528,7 +653,9 @@ export default function AdminBookingsPage() {
|
|||||||
disabled={validatingId === selectedBooking.id}
|
disabled={validatingId === selectedBooking.id}
|
||||||
className="w-full px-4 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full px-4 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{validatingId === selectedBooking.id ? t('modal.validating') : t('modal.validateButton')}
|
{validatingId === selectedBooking.id
|
||||||
|
? t('modal.validating')
|
||||||
|
: t('modal.validateButton')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -536,7 +663,10 @@ export default function AdminBookingsPage() {
|
|||||||
|
|
||||||
<div className="flex justify-end mt-6 pt-4 border-t">
|
<div className="flex justify-end mt-6 pt-4 border-t">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
|
onClick={() => {
|
||||||
|
setShowDetailsModal(false);
|
||||||
|
setSelectedBooking(null);
|
||||||
|
}}
|
||||||
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
{t('modal.close')}
|
{t('modal.close')}
|
||||||
|
|||||||
@ -73,9 +73,7 @@ export default function AdminCsvRatesPage() {
|
|||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
|
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
|
||||||
<p className="text-muted-foreground mt-2">
|
<p className="text-muted-foreground mt-2">{t('subtitle')}</p>
|
||||||
{t('subtitle')}
|
|
||||||
</p>
|
|
||||||
<Badge variant="destructive" className="mt-2">
|
<Badge variant="destructive" className="mt-2">
|
||||||
{t('adminBadge')}
|
{t('adminBadge')}
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -90,9 +88,7 @@ export default function AdminCsvRatesPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>{t('cardTitle')}</CardTitle>
|
<CardTitle>{t('cardTitle')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t('cardDescription')}</CardDescription>
|
||||||
{t('cardDescription')}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}>
|
<Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@ -115,9 +111,7 @@ export default function AdminCsvRatesPage() {
|
|||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : files.length === 0 ? (
|
) : files.length === 0 ? (
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<div className="text-center py-12 text-muted-foreground">{t('empty')}</div>
|
||||||
{t('empty')}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
@ -132,15 +126,17 @@ export default function AdminCsvRatesPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{files.map((file) => (
|
{files.map(file => (
|
||||||
<TableRow key={file.filename}>
|
<TableRow key={file.filename}>
|
||||||
<TableCell className="font-medium font-mono text-xs">{file.filename}</TableCell>
|
<TableCell className="font-medium font-mono text-xs">
|
||||||
<TableCell>
|
{file.filename}
|
||||||
{(file.size / 1024).toFixed(2)} KB
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>{(file.size / 1024).toFixed(2)} KB</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{file.rowCount ? (
|
{file.rowCount ? (
|
||||||
<span className="font-semibold">{t('table.rowCount', { count: file.rowCount })}</span>
|
<span className="font-semibold">
|
||||||
|
{t('table.rowCount', { count: file.rowCount })}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">-</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -171,12 +171,16 @@ export default function AdminDocumentsPage() {
|
|||||||
|
|
||||||
const uniqueUsers = Array.from(
|
const uniqueUsers = Array.from(
|
||||||
new Map(
|
new Map(
|
||||||
documents.map(doc => [doc.userId, { id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' }])
|
documents.map(doc => [
|
||||||
|
doc.userId,
|
||||||
|
{ id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' },
|
||||||
|
])
|
||||||
).values()
|
).values()
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredDocuments = documents.filter(doc => {
|
const filteredDocuments = documents.filter(doc => {
|
||||||
const matchesSearch = searchTerm === '' ||
|
const matchesSearch =
|
||||||
|
searchTerm === '' ||
|
||||||
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
(doc.name && doc.name.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
(doc.name && doc.name.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
(doc.type && doc.type.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
(doc.type && doc.type.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
@ -184,7 +188,8 @@ export default function AdminDocumentsPage() {
|
|||||||
|
|
||||||
const matchesUser = filterUserId === 'all' || doc.userId === filterUserId;
|
const matchesUser = filterUserId === 'all' || doc.userId === filterUserId;
|
||||||
|
|
||||||
const matchesQuote = filterQuoteNumber === '' ||
|
const matchesQuote =
|
||||||
|
filterQuoteNumber === '' ||
|
||||||
doc.quoteNumber.toLowerCase().includes(filterQuoteNumber.toLowerCase());
|
doc.quoteNumber.toLowerCase().includes(filterQuoteNumber.toLowerCase());
|
||||||
|
|
||||||
return matchesSearch && matchesUser && matchesQuote;
|
return matchesSearch && matchesUser && matchesQuote;
|
||||||
@ -201,7 +206,7 @@ export default function AdminDocumentsPage() {
|
|||||||
|
|
||||||
const getDocumentIcon = (type: string): ReactNode => {
|
const getDocumentIcon = (type: string): ReactNode => {
|
||||||
const typeLower = type.toLowerCase();
|
const typeLower = type.toLowerCase();
|
||||||
const cls = "h-6 w-6";
|
const cls = 'h-6 w-6';
|
||||||
const iconMap: Record<string, ReactNode> = {
|
const iconMap: Record<string, ReactNode> = {
|
||||||
'application/pdf': <FileText className={`${cls} text-red-500`} />,
|
'application/pdf': <FileText className={`${cls} text-red-500`} />,
|
||||||
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
|
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
|
||||||
@ -304,10 +309,7 @@ export default function AdminDocumentsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader title={t('title')} description={t('subtitle')} />
|
||||||
title={t('title')}
|
|
||||||
description={t('subtitle')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
@ -437,7 +439,9 @@ export default function AdminDocumentsPage() {
|
|||||||
<div className="text-sm text-gray-900">{doc.route}</div>
|
<div className="text-sm text-gray-900">{doc.route}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}>
|
<span
|
||||||
|
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}
|
||||||
|
>
|
||||||
{doc.status}
|
{doc.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@ -448,7 +452,7 @@ export default function AdminDocumentsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={e => {
|
||||||
const menuKey = `${doc.bookingId}::${doc.id}`;
|
const menuKey = `${doc.bookingId}::${doc.id}`;
|
||||||
if (openMenuId === menuKey) {
|
if (openMenuId === menuKey) {
|
||||||
setOpenMenuId(null);
|
setOpenMenuId(null);
|
||||||
@ -461,7 +465,11 @@ export default function AdminDocumentsPage() {
|
|||||||
}}
|
}}
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-600"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@ -494,9 +502,14 @@ export default function AdminDocumentsPage() {
|
|||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-gray-700">
|
||||||
{t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span> {t('pagination.to')}{' '}
|
{t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span>{' '}
|
||||||
<span className="font-medium">{Math.min(endIndex, filteredDocuments.length)}</span> {t('pagination.on')}{' '}
|
{t('pagination.to')}{' '}
|
||||||
<span className="font-medium">{filteredDocuments.length}</span> {t('pagination.results')}
|
<span className="font-medium">
|
||||||
|
{Math.min(endIndex, filteredDocuments.length)}
|
||||||
|
</span>{' '}
|
||||||
|
{t('pagination.on')}{' '}
|
||||||
|
<span className="font-medium">{filteredDocuments.length}</span>{' '}
|
||||||
|
{t('pagination.results')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@ -504,7 +517,7 @@ export default function AdminDocumentsPage() {
|
|||||||
<label className="text-sm text-gray-700">{t('pagination.perPage')}</label>
|
<label className="text-sm text-gray-700">{t('pagination.perPage')}</label>
|
||||||
<select
|
<select
|
||||||
value={itemsPerPage}
|
value={itemsPerPage}
|
||||||
onChange={(e) => {
|
onChange={e => {
|
||||||
setItemsPerPage(Number(e.target.value));
|
setItemsPerPage(Number(e.target.value));
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
@ -517,7 +530,10 @@ export default function AdminDocumentsPage() {
|
|||||||
<option value={100}>100</option>
|
<option value={100}>100</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
<nav
|
||||||
|
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
||||||
|
aria-label="Pagination"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
@ -525,7 +541,11 @@ export default function AdminDocumentsPage() {
|
|||||||
>
|
>
|
||||||
<span className="sr-only">{t('pagination.previous')}</span>
|
<span className="sr-only">{t('pagination.previous')}</span>
|
||||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -564,7 +584,11 @@ export default function AdminDocumentsPage() {
|
|||||||
>
|
>
|
||||||
<span className="sr-only">{t('pagination.next')}</span>
|
<span className="sr-only">{t('pagination.next')}</span>
|
||||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
@ -578,7 +602,10 @@ export default function AdminDocumentsPage() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[998]"
|
className="fixed inset-0 z-[998]"
|
||||||
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
|
onClick={() => {
|
||||||
|
setOpenMenuId(null);
|
||||||
|
setMenuPosition(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
|
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
|
||||||
@ -595,14 +622,29 @@ export default function AdminDocumentsPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpenMenuId(null);
|
setOpenMenuId(null);
|
||||||
setMenuPosition(null);
|
setMenuPosition(null);
|
||||||
handleDownload(doc.filePath || doc.url || '', doc.fileName || doc.name || 'document');
|
handleDownload(
|
||||||
|
doc.filePath || doc.url || '',
|
||||||
|
doc.fileName || doc.name || 'document'
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
|
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
className="w-5 h-5 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-medium text-gray-700">{t('menu.download')}</span>
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
{t('menu.download')}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -615,8 +657,18 @@ export default function AdminDocumentsPage() {
|
|||||||
disabled={deletingId === doc.id}
|
disabled={deletingId === doc.id}
|
||||||
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
|
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
className="w-5 h-5 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
|
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -72,7 +72,9 @@ const LEVEL_ROW_BG: Record<string, string> = {
|
|||||||
function LevelBadge({ level }: { level: string }) {
|
function LevelBadge({ level }: { level: string }) {
|
||||||
const style = LEVEL_STYLES[level] || 'bg-gray-100 text-gray-600';
|
const style = LEVEL_STYLES[level] || 'bg-gray-100 text-gray-600';
|
||||||
return (
|
return (
|
||||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-mono font-semibold uppercase ${style}`}>
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded text-xs font-mono font-semibold uppercase ${style}`}
|
||||||
|
>
|
||||||
{level}
|
{level}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@ -148,16 +150,14 @@ export default function AdminLogsPage() {
|
|||||||
if (fmt) params.set('format', fmt);
|
if (fmt) params.set('format', fmt);
|
||||||
return params.toString();
|
return params.toString();
|
||||||
},
|
},
|
||||||
[filters],
|
[filters]
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
const fetchLogs = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await get<LogsResponse>(
|
const data = await get<LogsResponse>(`${LOGS_PREFIX}/export?${buildQueryString('json')}`);
|
||||||
`${LOGS_PREFIX}/export?${buildQueryString('json')}`,
|
|
||||||
);
|
|
||||||
setLogs(data.logs || []);
|
setLogs(data.logs || []);
|
||||||
setTotal(data.total || 0);
|
setTotal(data.total || 0);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -175,10 +175,7 @@ export default function AdminLogsPage() {
|
|||||||
setExportLoading(true);
|
setExportLoading(true);
|
||||||
try {
|
try {
|
||||||
const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`;
|
const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`;
|
||||||
await download(
|
await download(`${LOGS_PREFIX}/export?${buildQueryString(format)}`, filename);
|
||||||
`${LOGS_PREFIX}/export?${buildQueryString(format)}`,
|
|
||||||
filename,
|
|
||||||
);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@ -187,8 +184,7 @@ export default function AdminLogsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
const countByLevel = (level: string) =>
|
const countByLevel = (level: string) => logs.filter(l => l.level === level).length;
|
||||||
logs.filter(l => l.level === level).length;
|
|
||||||
|
|
||||||
const setFilter = (key: keyof Filters, value: string) =>
|
const setFilter = (key: keyof Filters, value: string) =>
|
||||||
setFilters(prev => ({ ...prev, [key]: value }));
|
setFilters(prev => ({ ...prev, [key]: value }));
|
||||||
@ -214,7 +210,9 @@ export default function AdminLogsPage() {
|
|||||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-white bg-[#10183A] rounded-lg hover:bg-[#1a2550] transition-colors disabled:opacity-50"
|
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-white bg-[#10183A] rounded-lg hover:bg-[#1a2550] transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">{exportLoading ? t('exporting') : t('export')}</span>
|
<span className="hidden sm:inline">
|
||||||
|
{exportLoading ? t('exporting') : t('export')}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute right-0 mt-1 w-36 bg-white rounded-lg shadow-lg border border-gray-200 z-10 hidden group-hover:block">
|
<div className="absolute right-0 mt-1 w-36 bg-white rounded-lg shadow-lg border border-gray-200 z-10 hidden group-hover:block">
|
||||||
<button
|
<button
|
||||||
@ -272,7 +270,9 @@ export default function AdminLogsPage() {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||||
{/* Service */}
|
{/* Service */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.service')}</label>
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
|
{t('filters.service')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filters.service}
|
value={filters.service}
|
||||||
onChange={e => setFilter('service', e.target.value)}
|
onChange={e => setFilter('service', e.target.value)}
|
||||||
@ -289,7 +289,9 @@ export default function AdminLogsPage() {
|
|||||||
|
|
||||||
{/* Level */}
|
{/* Level */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.level')}</label>
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
|
{t('filters.level')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filters.level}
|
value={filters.level}
|
||||||
onChange={e => setFilter('level', e.target.value)}
|
onChange={e => setFilter('level', e.target.value)}
|
||||||
@ -306,7 +308,9 @@ export default function AdminLogsPage() {
|
|||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.search')}</label>
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
|
{t('filters.search')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('filters.searchPlaceholder')}
|
placeholder={t('filters.searchPlaceholder')}
|
||||||
@ -319,7 +323,9 @@ export default function AdminLogsPage() {
|
|||||||
|
|
||||||
{/* Start */}
|
{/* Start */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.start')}</label>
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
|
{t('filters.start')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={filters.startDate}
|
value={filters.startDate}
|
||||||
@ -330,7 +336,9 @@ export default function AdminLogsPage() {
|
|||||||
|
|
||||||
{/* End */}
|
{/* End */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.end')}</label>
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
|
{t('filters.end')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={filters.endDate}
|
value={filters.endDate}
|
||||||
@ -372,9 +380,7 @@ export default function AdminLogsPage() {
|
|||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{t('errorBanner')} <strong>{error}</strong>
|
{t('errorBanner')} <strong>{error}</strong>
|
||||||
<br />
|
<br />
|
||||||
<span className="text-xs text-red-500">
|
<span className="text-xs text-red-500">{t('errorHint')}</span>
|
||||||
{t('errorHint')}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -389,9 +395,7 @@ export default function AdminLogsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!loading && logs.length > 0 && (
|
{!loading && logs.length > 0 && (
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">{t('clickHint')}</span>
|
||||||
{t('clickHint')}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -469,8 +473,7 @@ export default function AdminLogsPage() {
|
|||||||
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
|
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
|
||||||
{log.req_method && (
|
{log.req_method && (
|
||||||
<span>
|
<span>
|
||||||
<span className="font-semibold">{log.req_method}</span>{' '}
|
<span className="font-semibold">{log.req_method}</span> {log.req_url}{' '}
|
||||||
{log.req_url}{' '}
|
|
||||||
{log.res_status && (
|
{log.res_status && (
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
@ -495,29 +498,37 @@ export default function AdminLogsPage() {
|
|||||||
<td colSpan={6} className="px-4 py-3">
|
<td colSpan={6} className="px-4 py-3">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold text-gray-600">{t('detail.timestamp')}</span>
|
<span className="font-semibold text-gray-600">
|
||||||
|
{t('detail.timestamp')}
|
||||||
|
</span>
|
||||||
<p className="font-mono text-gray-800 mt-0.5">{log.timestamp}</p>
|
<p className="font-mono text-gray-800 mt-0.5">{log.timestamp}</p>
|
||||||
</div>
|
</div>
|
||||||
{log.reqId && (
|
{log.reqId && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold text-gray-600">{t('detail.requestId')}</span>
|
<span className="font-semibold text-gray-600">
|
||||||
<p className="font-mono text-gray-800 mt-0.5 truncate">{log.reqId}</p>
|
{t('detail.requestId')}
|
||||||
|
</span>
|
||||||
|
<p className="font-mono text-gray-800 mt-0.5 truncate">
|
||||||
|
{log.reqId}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{log.response_time_ms && (
|
{log.response_time_ms && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold text-gray-600">{t('detail.duration')}</span>
|
<span className="font-semibold text-gray-600">
|
||||||
|
{t('detail.duration')}
|
||||||
|
</span>
|
||||||
<p className="font-mono text-gray-800 mt-0.5">
|
<p className="font-mono text-gray-800 mt-0.5">
|
||||||
{log.response_time_ms} ms
|
{log.response_time_ms} ms
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="col-span-2 md:col-span-4">
|
<div className="col-span-2 md:col-span-4">
|
||||||
<span className="font-semibold text-gray-600">{t('detail.fullMessage')}</span>
|
<span className="font-semibold text-gray-600">
|
||||||
|
{t('detail.fullMessage')}
|
||||||
|
</span>
|
||||||
<pre className="mt-0.5 p-2 bg-white rounded border font-mono text-gray-800 overflow-x-auto whitespace-pre-wrap break-all">
|
<pre className="mt-0.5 p-2 bg-white rounded border font-mono text-gray-800 overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
{log.error
|
{log.error ? `[ERROR] ${log.error}\n\n${log.message}` : log.message}
|
||||||
? `[ERROR] ${log.error}\n\n${log.message}`
|
|
||||||
: log.message}
|
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -159,7 +159,12 @@ export default function AdminOrganizationsPage() {
|
|||||||
setVerifyingId(orgId);
|
setVerifyingId(orgId);
|
||||||
const result = await verifySiret(orgId);
|
const result = await verifySiret(orgId);
|
||||||
if (result.verified) {
|
if (result.verified) {
|
||||||
alert(t('siretVerified', { companyName: result.companyName || 'N/A', address: result.address || 'N/A' }));
|
alert(
|
||||||
|
t('siretVerified', {
|
||||||
|
companyName: result.companyName || 'N/A',
|
||||||
|
address: result.address || 'N/A',
|
||||||
|
})
|
||||||
|
);
|
||||||
await fetchOrganizations();
|
await fetchOrganizations();
|
||||||
} else {
|
} else {
|
||||||
alert(result.message || t('siretInvalid'));
|
alert(result.message || t('siretInvalid'));
|
||||||
@ -264,17 +269,23 @@ export default function AdminOrganizationsPage() {
|
|||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{org.name}</h3>
|
<h3 className="text-lg font-semibold text-gray-900">{org.name}</h3>
|
||||||
<span className={`inline-block mt-2 px-2 py-1 text-xs font-semibold rounded-full ${
|
<span
|
||||||
org.type === 'FREIGHT_FORWARDER' ? 'bg-blue-100 text-blue-800' :
|
className={`inline-block mt-2 px-2 py-1 text-xs font-semibold rounded-full ${
|
||||||
org.type === 'CARRIER' ? 'bg-green-100 text-green-800' :
|
org.type === 'FREIGHT_FORWARDER'
|
||||||
'bg-purple-100 text-purple-800'
|
? 'bg-blue-100 text-blue-800'
|
||||||
}`}>
|
: org.type === 'CARRIER'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-purple-100 text-purple-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{getTypeLabel(org.type)}
|
{getTypeLabel(org.type)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
<span
|
||||||
org.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||||
}`}>
|
org.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{org.isActive ? t('active') : t('inactive')}
|
{org.isActive ? t('active') : t('inactive')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -315,7 +326,8 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">{t('location')}:</span> {org.address.city}, {org.address.country}
|
<span className="font-medium">{t('location')}:</span> {org.address.city},{' '}
|
||||||
|
{org.address.country}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -373,7 +385,9 @@ export default function AdminOrganizationsPage() {
|
|||||||
<form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4">
|
<form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.name')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.name')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -384,7 +398,9 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.type')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.type')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.type}
|
value={formData.type}
|
||||||
onChange={e => setFormData({ ...formData, type: e.target.value })}
|
onChange={e => setFormData({ ...formData, type: e.target.value })}
|
||||||
@ -398,20 +414,26 @@ export default function AdminOrganizationsPage() {
|
|||||||
|
|
||||||
{formData.type === 'CARRIER' && (
|
{formData.type === 'CARRIER' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.scacLabel')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.scacLabel')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required={formData.type === 'CARRIER'}
|
required={formData.type === 'CARRIER'}
|
||||||
maxLength={4}
|
maxLength={4}
|
||||||
value={formData.scac}
|
value={formData.scac}
|
||||||
onChange={e => setFormData({ ...formData, scac: e.target.value.toUpperCase() })}
|
onChange={e =>
|
||||||
|
setFormData({ ...formData, scac: e.target.value.toUpperCase() })
|
||||||
|
}
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.sirenLabel')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.sirenLabel')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={9}
|
maxLength={9}
|
||||||
@ -422,19 +444,25 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.siretLabel')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.siretLabel')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={14}
|
maxLength={14}
|
||||||
value={formData.siret}
|
value={formData.siret}
|
||||||
onChange={e => setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })}
|
onChange={e =>
|
||||||
|
setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })
|
||||||
|
}
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
placeholder={t('modal.siretPlaceholder')}
|
placeholder={t('modal.siretPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.eoriLabel')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.eoriLabel')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.eori}
|
value={formData.eori}
|
||||||
@ -444,7 +472,9 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.contactPhone')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.contactPhone')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={formData.contact_phone}
|
value={formData.contact_phone}
|
||||||
@ -454,7 +484,9 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.contactEmail')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.contactEmail')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.contact_email}
|
value={formData.contact_email}
|
||||||
@ -464,77 +496,99 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.street')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.street')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.address.street}
|
value={formData.address.street}
|
||||||
onChange={e => setFormData({
|
onChange={e =>
|
||||||
...formData,
|
setFormData({
|
||||||
address: { ...formData.address, street: e.target.value }
|
...formData,
|
||||||
})}
|
address: { ...formData.address, street: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.city')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.city')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.address.city}
|
value={formData.address.city}
|
||||||
onChange={e => setFormData({
|
onChange={e =>
|
||||||
...formData,
|
setFormData({
|
||||||
address: { ...formData.address, city: e.target.value }
|
...formData,
|
||||||
})}
|
address: { ...formData.address, city: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.postalCode')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.postalCode')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.address.postalCode}
|
value={formData.address.postalCode}
|
||||||
onChange={e => setFormData({
|
onChange={e =>
|
||||||
...formData,
|
setFormData({
|
||||||
address: { ...formData.address, postalCode: e.target.value }
|
...formData,
|
||||||
})}
|
address: { ...formData.address, postalCode: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.state')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.state')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.address.state}
|
value={formData.address.state}
|
||||||
onChange={e => setFormData({
|
onChange={e =>
|
||||||
...formData,
|
setFormData({
|
||||||
address: { ...formData.address, state: e.target.value }
|
...formData,
|
||||||
})}
|
address: { ...formData.address, state: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.country')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.country')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
maxLength={2}
|
maxLength={2}
|
||||||
value={formData.address.country}
|
value={formData.address.country}
|
||||||
onChange={e => setFormData({
|
onChange={e =>
|
||||||
...formData,
|
setFormData({
|
||||||
address: { ...formData.address, country: e.target.value.toUpperCase() }
|
...formData,
|
||||||
})}
|
address: { ...formData.address, country: e.target.value.toUpperCase() },
|
||||||
|
})
|
||||||
|
}
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.logoUrl')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.logoUrl')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={formData.logoUrl}
|
value={formData.logoUrl}
|
||||||
|
|||||||
@ -228,11 +228,15 @@ export default function AdminUsersPage() {
|
|||||||
<div className="text-sm text-gray-500">{user.email}</div>
|
<div className="text-sm text-gray-500">{user.email}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
<span
|
||||||
user.role === 'ADMIN' ? 'bg-purple-100 text-purple-800' :
|
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||||
user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' :
|
user.role === 'ADMIN'
|
||||||
'bg-gray-100 text-gray-800'
|
? 'bg-purple-100 text-purple-800'
|
||||||
}`}>
|
: user.role === 'MANAGER'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{getRoleLabel(user.role)}
|
{getRoleLabel(user.role)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@ -240,9 +244,11 @@ export default function AdminUsersPage() {
|
|||||||
{user.organizationName || user.organizationId}
|
{user.organizationName || user.organizationId}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
<span
|
||||||
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||||
}`}>
|
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{user.isActive ? t('active') : t('inactive')}
|
{user.isActive ? t('active') : t('inactive')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@ -273,7 +279,9 @@ export default function AdminUsersPage() {
|
|||||||
<h2 className="text-xl font-bold mb-4">{t('modal.createTitle')}</h2>
|
<h2 className="text-xl font-bold mb-4">{t('modal.createTitle')}</h2>
|
||||||
<form onSubmit={handleCreate} className="space-y-4">
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.email')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.email')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
@ -283,7 +291,9 @@ export default function AdminUsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.firstName')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -293,7 +303,9 @@ export default function AdminUsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.lastName')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -316,7 +328,9 @@ export default function AdminUsersPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.organization')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.organization')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
value={formData.organizationId}
|
value={formData.organizationId}
|
||||||
@ -372,7 +386,9 @@ export default function AdminUsersPage() {
|
|||||||
<h2 className="text-xl font-bold mb-4">{t('modal.editTitle')}</h2>
|
<h2 className="text-xl font-bold mb-4">{t('modal.editTitle')}</h2>
|
||||||
<form onSubmit={handleUpdate} className="space-y-4">
|
<form onSubmit={handleUpdate} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.emailReadOnly')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.emailReadOnly')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
disabled
|
disabled
|
||||||
@ -381,7 +397,9 @@ export default function AdminUsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.firstName')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -391,7 +409,9 @@ export default function AdminUsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.lastName')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -443,7 +463,10 @@ export default function AdminUsersPage() {
|
|||||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||||
<h2 className="text-xl font-bold mb-4 text-red-600">{t('deleteConfirm.title')}</h2>
|
<h2 className="text-xl font-bold mb-4 text-red-600">{t('deleteConfirm.title')}</h2>
|
||||||
<p className="text-gray-700 mb-6">
|
<p className="text-gray-700 mb-6">
|
||||||
{t('deleteConfirm.message', { firstName: selectedUser.firstName, lastName: selectedUser.lastName })}
|
{t('deleteConfirm.message', {
|
||||||
|
firstName: selectedUser.firstName,
|
||||||
|
lastName: selectedUser.lastName,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -210,9 +210,7 @@ export default function PayCommissionPage() {
|
|||||||
selectedMethod === 'card' ? 'border-blue-500 bg-blue-500' : 'border-gray-300'
|
selectedMethod === 'card' ? 'border-blue-500 bg-blue-500' : 'border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{selectedMethod === 'card' && (
|
{selectedMethod === 'card' && <div className="w-2 h-2 rounded-full bg-white" />}
|
||||||
<div className="w-2 h-2 rounded-full bg-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -32,9 +32,7 @@ export default function PaymentSuccessPage() {
|
|||||||
setStatus('success');
|
setStatus('success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Payment confirmation error:', err);
|
console.error('Payment confirmation error:', err);
|
||||||
setError(
|
setError(err instanceof Error ? err.message : 'Erreur lors de la confirmation du paiement');
|
||||||
err instanceof Error ? err.message : 'Erreur lors de la confirmation du paiement'
|
|
||||||
);
|
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -68,7 +66,8 @@ export default function PaymentSuccessPage() {
|
|||||||
<Loader2 className="h-16 w-16 animate-spin text-blue-600 mx-auto mb-6" />
|
<Loader2 className="h-16 w-16 animate-spin text-blue-600 mx-auto mb-6" />
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Confirmation du paiement...</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Confirmation du paiement...</h2>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Veuillez patienter pendant que nous verifions votre paiement et activons votre booking.
|
Veuillez patienter pendant que nous verifions votre paiement et activons votre
|
||||||
|
booking.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -82,15 +81,14 @@ export default function PaymentSuccessPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">Paiement confirme !</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-3">Paiement confirme !</h2>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
Votre commission a ete payee avec succes. Un email a ete envoye au transporteur avec votre demande de booking.
|
Votre commission a ete payee avec succes. Un email a ete envoye au transporteur avec
|
||||||
|
votre demande de booking.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
<div className="flex items-center justify-center space-x-2 text-blue-700">
|
<div className="flex items-center justify-center space-x-2 text-blue-700">
|
||||||
<Mail className="h-5 w-5" />
|
<Mail className="h-5 w-5" />
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">Email envoye au transporteur</span>
|
||||||
Email envoye au transporteur
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-blue-600 mt-1">
|
<p className="text-xs text-blue-600 mt-1">
|
||||||
Vous recevrez une notification des que le transporteur repond (sous 7 jours max)
|
Vous recevrez une notification des que le transporteur repond (sous 7 jours max)
|
||||||
@ -113,7 +111,8 @@ export default function PaymentSuccessPage() {
|
|||||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Erreur de confirmation</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Erreur de confirmation</h2>
|
||||||
<p className="text-gray-600 mb-2">{error}</p>
|
<p className="text-gray-600 mb-2">{error}</p>
|
||||||
<p className="text-sm text-gray-500 mb-6">
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
Si votre paiement a ete debite, contactez le support. Votre booking sera active manuellement.
|
Si votre paiement a ete debite, contactez le support. Votre booking sera active
|
||||||
|
manuellement.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -41,7 +41,7 @@ const DOCUMENT_TYPES = [
|
|||||||
{ value: 'BILL_OF_LADING', label: 'Bill of Lading (Connaissement)' },
|
{ value: 'BILL_OF_LADING', label: 'Bill of Lading (Connaissement)' },
|
||||||
{ value: 'PACKING_LIST', label: 'Packing List (Liste de colisage)' },
|
{ value: 'PACKING_LIST', label: 'Packing List (Liste de colisage)' },
|
||||||
{ value: 'COMMERCIAL_INVOICE', label: 'Commercial Invoice (Facture commerciale)' },
|
{ value: 'COMMERCIAL_INVOICE', label: 'Commercial Invoice (Facture commerciale)' },
|
||||||
{ value: 'CERTIFICATE_OF_ORIGIN', label: 'Certificate of Origin (Certificat d\'origine)' },
|
{ value: 'CERTIFICATE_OF_ORIGIN', label: "Certificate of Origin (Certificat d'origine)" },
|
||||||
{ value: 'OTHER', label: 'Autre document' },
|
{ value: 'OTHER', label: 'Autre document' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -187,7 +187,7 @@ function NewBookingPageContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Append documents
|
// Append documents
|
||||||
formData.documents.forEach((file) => {
|
formData.documents.forEach(file => {
|
||||||
formDataToSend.append('documents', file);
|
formDataToSend.append('documents', file);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -228,7 +228,9 @@ function NewBookingPageContent() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Nouvelle demande de réservation</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
Nouvelle demande de réservation
|
||||||
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Envoyez une demande de réservation directement au transporteur
|
Envoyez une demande de réservation directement au transporteur
|
||||||
</p>
|
</p>
|
||||||
@ -243,9 +245,7 @@ function NewBookingPageContent() {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div
|
<div
|
||||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
|
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
|
||||||
currentStep >= step
|
currentStep >= step ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'bg-gray-200 text-gray-600'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{step}
|
{step}
|
||||||
@ -292,16 +292,12 @@ function NewBookingPageContent() {
|
|||||||
{/* Step 1: Transport Details (Read-only) */}
|
{/* Step 1: Transport Details (Read-only) */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">Détails du transport</h2>
|
||||||
Détails du transport
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Carrier Info */}
|
{/* Carrier Info */}
|
||||||
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
|
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Transporteur</h3>
|
||||||
Transporteur
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">Nom</p>
|
<p className="text-sm text-gray-600">Nom</p>
|
||||||
@ -316,9 +312,7 @@ function NewBookingPageContent() {
|
|||||||
|
|
||||||
{/* Route Info */}
|
{/* Route Info */}
|
||||||
<div className="bg-gray-50 rounded-lg p-6">
|
<div className="bg-gray-50 rounded-lg p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Trajet</h3>
|
||||||
Trajet
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm text-gray-600 mb-1">Origine</p>
|
<p className="text-sm text-gray-600 mb-1">Origine</p>
|
||||||
@ -327,7 +321,9 @@ function NewBookingPageContent() {
|
|||||||
<div className="flex-1 mx-8">
|
<div className="flex-1 mx-8">
|
||||||
<div className="border-t-2 border-dashed border-gray-300 relative">
|
<div className="border-t-2 border-dashed border-gray-300 relative">
|
||||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white px-3">
|
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white px-3">
|
||||||
<span className="text-sm text-gray-600">{formData.transitDays} jours</span>
|
<span className="text-sm text-gray-600">
|
||||||
|
{formData.transitDays} jours
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -350,7 +346,9 @@ function NewBookingPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<p className="text-xs text-gray-600 mb-1">Palettes</p>
|
<p className="text-xs text-gray-600 mb-1">Palettes</p>
|
||||||
<p className="text-lg font-bold text-gray-900">{formData.palletCount || 'N/A'}</p>
|
<p className="text-lg font-bold text-gray-900">
|
||||||
|
{formData.palletCount || 'N/A'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<p className="text-xs text-gray-600 mb-1">Type</p>
|
<p className="text-xs text-gray-600 mb-1">Type</p>
|
||||||
@ -360,9 +358,7 @@ function NewBookingPageContent() {
|
|||||||
|
|
||||||
{/* Pricing */}
|
{/* Pricing */}
|
||||||
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6">
|
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Prix estimé</h3>
|
||||||
Prix estimé
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">Fret ({formData.freightCurrency})</p>
|
<p className="text-sm text-gray-600">Fret ({formData.freightCurrency})</p>
|
||||||
@ -382,7 +378,10 @@ function NewBookingPageContent() {
|
|||||||
<div className="border-t border-green-200 pt-4 flex items-center justify-between">
|
<div className="border-t border-green-200 pt-4 flex items-center justify-between">
|
||||||
<p className="text-sm font-semibold text-gray-700">Prix total</p>
|
<p className="text-sm font-semibold text-gray-700">Prix total</p>
|
||||||
<p className="text-3xl font-bold text-green-600">
|
<p className="text-3xl font-bold text-green-600">
|
||||||
{formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')}
|
{formatPrice(
|
||||||
|
formData.totalPriceForSorting,
|
||||||
|
formData.primaryCurrency || 'EUR'
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -403,9 +402,7 @@ function NewBookingPageContent() {
|
|||||||
{/* Step 2: Document Upload */}
|
{/* Step 2: Document Upload */}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">Documents requis</h2>
|
||||||
Documents requis
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mb-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg p-4">
|
<div className="mb-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg p-4">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
@ -428,7 +425,7 @@ function NewBookingPageContent() {
|
|||||||
id="file-upload"
|
id="file-upload"
|
||||||
multiple
|
multiple
|
||||||
accept={ACCEPTED_FILE_TYPES.join(',')}
|
accept={ACCEPTED_FILE_TYPES.join(',')}
|
||||||
onChange={(e) => handleFileChange(e.target.files)}
|
onChange={e => handleFileChange(e.target.files)}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
@ -451,9 +448,7 @@ function NewBookingPageContent() {
|
|||||||
<p className="text-lg font-semibold text-gray-700 mb-2">
|
<p className="text-lg font-semibold text-gray-700 mb-2">
|
||||||
Cliquez pour sélectionner des fichiers
|
Cliquez pour sélectionner des fichiers
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">ou glissez-déposez vos documents ici</p>
|
||||||
ou glissez-déposez vos documents ici
|
|
||||||
</p>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -522,7 +517,7 @@ function NewBookingPageContent() {
|
|||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.notes || ''}
|
value={formData.notes || ''}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
|
onChange={e => setFormData(prev => ({ ...prev, notes: e.target.value }))}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
placeholder="Ajoutez des instructions spéciales pour le transporteur..."
|
placeholder="Ajoutez des instructions spéciales pour le transporteur..."
|
||||||
@ -550,16 +545,12 @@ function NewBookingPageContent() {
|
|||||||
{/* Step 3: Review & Submit */}
|
{/* Step 3: Review & Submit */}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">Révision et envoi</h2>
|
||||||
Révision et envoi
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-6 mb-8">
|
<div className="space-y-6 mb-8">
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
|
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Récapitulatif</h3>
|
||||||
Récapitulatif
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Transporteur :</span>
|
<span className="text-gray-600">Transporteur :</span>
|
||||||
@ -602,7 +593,10 @@ function NewBookingPageContent() {
|
|||||||
<div className="flex justify-between border-t pt-3 mt-3">
|
<div className="flex justify-between border-t pt-3 mt-3">
|
||||||
<span className="text-gray-900 font-semibold">Prix total :</span>
|
<span className="text-gray-900 font-semibold">Prix total :</span>
|
||||||
<span className="text-2xl font-bold text-green-600">
|
<span className="text-2xl font-bold text-green-600">
|
||||||
{formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')}
|
{formatPrice(
|
||||||
|
formData.totalPriceForSorting,
|
||||||
|
formData.primaryCurrency || 'EUR'
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -617,7 +611,8 @@ function NewBookingPageContent() {
|
|||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="mr-2">1.</span>
|
<span className="mr-2">1.</span>
|
||||||
<span>
|
<span>
|
||||||
Votre demande sera <strong>envoyée par email</strong> au transporteur ({formData.carrierEmail})
|
Votre demande sera <strong>envoyée par email</strong> au transporteur (
|
||||||
|
{formData.carrierEmail})
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
@ -629,13 +624,15 @@ function NewBookingPageContent() {
|
|||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="mr-2">3.</span>
|
<span className="mr-2">3.</span>
|
||||||
<span>
|
<span>
|
||||||
Il pourra <strong>accepter ou refuser</strong> la demande directement depuis son email
|
Il pourra <strong>accepter ou refuser</strong> la demande directement depuis
|
||||||
|
son email
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="mr-2">4.</span>
|
<span className="mr-2">4.</span>
|
||||||
<span>
|
<span>
|
||||||
Vous recevrez une <strong>notification</strong> dès que le transporteur répond (sous 7 jours maximum)
|
Vous recevrez une <strong>notification</strong> dès que le transporteur
|
||||||
|
répond (sous 7 jours maximum)
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -647,7 +644,7 @@ function NewBookingPageContent() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={termsAccepted}
|
checked={termsAccepted}
|
||||||
onChange={(e) => setTermsAccepted(e.target.checked)}
|
onChange={e => setTermsAccepted(e.target.checked)}
|
||||||
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<span className="ml-3 text-sm text-gray-700">
|
<span className="ml-3 text-sm text-gray-700">
|
||||||
@ -693,7 +690,9 @@ function NewBookingPageContent() {
|
|||||||
|
|
||||||
export default function NewBookingPage() {
|
export default function NewBookingPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Chargement...</div>}>
|
<Suspense
|
||||||
|
fallback={<div className="min-h-screen flex items-center justify-center">Chargement...</div>}
|
||||||
|
>
|
||||||
<NewBookingPageContent />
|
<NewBookingPageContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -124,7 +124,9 @@ export default function BookingDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{booking.specialInstructions && (
|
{booking.specialInstructions && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500">{t('cargo.specialInstructions')}</dt>
|
<dt className="text-sm font-medium text-gray-500">
|
||||||
|
{t('cargo.specialInstructions')}
|
||||||
|
</dt>
|
||||||
<dd className="mt-1 text-sm text-gray-900">{booking.specialInstructions}</dd>
|
<dd className="mt-1 text-sm text-gray-900">{booking.specialInstructions}</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -145,7 +147,9 @@ export default function BookingDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{container.containerNumber && (
|
{container.containerNumber && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-500">{t('containers.number')}</p>
|
<p className="text-sm font-medium text-gray-500">
|
||||||
|
{t('containers.number')}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-gray-900">{container.containerNumber}</p>
|
<p className="text-sm text-gray-900">{container.containerNumber}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -243,7 +247,9 @@ export default function BookingDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 pt-1.5">
|
<div className="min-w-0 flex-1 pt-1.5">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-900 font-medium">{t('timeline.created')}</p>
|
<p className="text-sm text-gray-900 font-medium">
|
||||||
|
{t('timeline.created')}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{new Date(booking.createdAt).toLocaleString(dateLocale)}
|
{new Date(booking.createdAt).toLocaleString(dateLocale)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -30,7 +30,11 @@ export default function BookingsListPage() {
|
|||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const { data: csvData, isLoading, error: csvError } = useQuery({
|
const {
|
||||||
|
data: csvData,
|
||||||
|
isLoading,
|
||||||
|
error: csvError,
|
||||||
|
} = useQuery({
|
||||||
queryKey: ['csv-bookings'],
|
queryKey: ['csv-bookings'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
listCsvBookings({
|
listCsvBookings({
|
||||||
@ -64,10 +68,15 @@ export default function BookingsListPage() {
|
|||||||
case 'status':
|
case 'status':
|
||||||
return booking.status?.toLowerCase().includes(term);
|
return booking.status?.toLowerCase().includes(term);
|
||||||
case 'date':
|
case 'date':
|
||||||
const date = new Date(booking.requestedPickupDate || booking.requestedAt).toLocaleDateString(dateLocale);
|
const date = new Date(
|
||||||
|
booking.requestedPickupDate || booking.requestedAt
|
||||||
|
).toLocaleDateString(dateLocale);
|
||||||
return date.includes(term);
|
return date.includes(term);
|
||||||
case 'quote':
|
case 'quote':
|
||||||
return booking.id?.toLowerCase().includes(term) || booking.quoteNumber?.toLowerCase().includes(term);
|
return (
|
||||||
|
booking.id?.toLowerCase().includes(term) ||
|
||||||
|
booking.quoteNumber?.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -77,7 +86,9 @@ export default function BookingsListPage() {
|
|||||||
return filtered;
|
return filtered;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredBookings = filterBookings((csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const })));
|
const filteredBookings = filterBookings(
|
||||||
|
(csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const }))
|
||||||
|
);
|
||||||
|
|
||||||
const totalBookings = filteredBookings.length;
|
const totalBookings = filteredBookings.length;
|
||||||
const totalPages = Math.ceil(totalBookings / ITEMS_PER_PAGE);
|
const totalPages = Math.ceil(totalBookings / ITEMS_PER_PAGE);
|
||||||
@ -141,12 +152,15 @@ export default function BookingsListPage() {
|
|||||||
<Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
<Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-amber-800">{t('transferBanner.title')}</p>
|
<p className="font-medium text-amber-800">{t('transferBanner.title')}</p>
|
||||||
<p className="text-sm text-amber-700 mt-0.5">
|
<p className="text-sm text-amber-700 mt-0.5">{t('transferBanner.message')}</p>
|
||||||
{t('transferBanner.message')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowTransferBanner(false)} className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0">✕</button>
|
<button
|
||||||
|
onClick={() => setShowTransferBanner(false)}
|
||||||
|
className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@ -159,14 +173,18 @@ export default function BookingsListPage() {
|
|||||||
filename={t('exportFilename')}
|
filename={t('exportFilename')}
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'id', label: t('export.id') },
|
{ key: 'id', label: t('export.id') },
|
||||||
{ key: 'palletCount', label: t('export.pallets'), format: (v) => `${v || 0}` },
|
{ key: 'palletCount', label: t('export.pallets'), format: v => `${v || 0}` },
|
||||||
{ key: 'weightKG', label: t('export.weight'), format: (v) => `${v || 0}` },
|
{ key: 'weightKG', label: t('export.weight'), format: v => `${v || 0}` },
|
||||||
{ key: 'volumeCBM', label: t('export.volume'), format: (v) => `${v || 0}` },
|
{ key: 'volumeCBM', label: t('export.volume'), format: v => `${v || 0}` },
|
||||||
{ key: 'origin', label: t('export.origin') },
|
{ key: 'origin', label: t('export.origin') },
|
||||||
{ key: 'destination', label: t('export.destination') },
|
{ key: 'destination', label: t('export.destination') },
|
||||||
{ key: 'carrierName', label: t('export.carrier') },
|
{ key: 'carrierName', label: t('export.carrier') },
|
||||||
{ key: 'status', label: t('export.status'), format: (v) => getStatusLabel(v) },
|
{ key: 'status', label: t('export.status'), format: v => getStatusLabel(v) },
|
||||||
{ key: 'createdAt', label: t('export.createdAt'), format: (v) => v ? new Date(v).toLocaleDateString(dateLocale) : '' },
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
label: t('export.createdAt'),
|
||||||
|
format: v => (v ? new Date(v).toLocaleDateString(dateLocale) : ''),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
@ -280,13 +298,17 @@ export default function BookingsListPage() {
|
|||||||
{booking.type === 'csv' ? booking.carrierName : booking.carrier || ''}
|
{booking.type === 'csv' ? booking.carrierName : booking.carrier || ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-2 py-1 text-xs font-semibold rounded-full flex-shrink-0 ${getStatusColor(booking.status)}`}>
|
<span
|
||||||
|
className={`px-2 py-1 text-xs font-semibold rounded-full flex-shrink-0 ${getStatusColor(booking.status)}`}
|
||||||
|
>
|
||||||
{getStatusLabel(booking.status)}
|
{getStatusLabel(booking.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.pallets')}</div>
|
<div className="text-gray-400 uppercase tracking-wide">
|
||||||
|
{t('mobile.pallets')}
|
||||||
|
</div>
|
||||||
<div className="font-medium text-gray-900 mt-0.5">
|
<div className="font-medium text-gray-900 mt-0.5">
|
||||||
{booking.type === 'csv'
|
{booking.type === 'csv'
|
||||||
? t('units.palletsShort', { count: booking.palletCount })
|
? t('units.palletsShort', { count: booking.palletCount })
|
||||||
@ -294,25 +316,36 @@ export default function BookingsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.weight')}</div>
|
<div className="text-gray-400 uppercase tracking-wide">
|
||||||
|
{t('mobile.weight')}
|
||||||
|
</div>
|
||||||
<div className="font-medium text-gray-900 mt-0.5">
|
<div className="font-medium text-gray-900 mt-0.5">
|
||||||
{booking.type === 'csv'
|
{booking.type === 'csv'
|
||||||
? t('units.kg', { value: booking.weightKG })
|
? t('units.kg', { value: booking.weightKG })
|
||||||
: booking.totalWeight ? t('units.kg', { value: booking.totalWeight }) : 'N/A'}
|
: booking.totalWeight
|
||||||
|
? t('units.kg', { value: booking.totalWeight })
|
||||||
|
: 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.date')}</div>
|
<div className="text-gray-400 uppercase tracking-wide">
|
||||||
|
{t('mobile.date')}
|
||||||
|
</div>
|
||||||
<div className="font-medium text-gray-900 mt-0.5">
|
<div className="font-medium text-gray-900 mt-0.5">
|
||||||
{(booking.createdAt || booking.requestedAt)
|
{booking.createdAt || booking.requestedAt
|
||||||
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(dateLocale, { day: '2-digit', month: '2-digit', year: '2-digit' })
|
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(
|
||||||
|
dateLocale,
|
||||||
|
{ day: '2-digit', month: '2-digit', year: '2-digit' }
|
||||||
|
)
|
||||||
: 'N/A'}
|
: 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400">
|
<div className="text-xs text-gray-400">
|
||||||
{booking.type === 'csv'
|
{booking.type === 'csv'
|
||||||
? t('mobile.ref', { id: booking.bookingId || booking.id.slice(0, 8).toUpperCase() })
|
? t('mobile.ref', {
|
||||||
|
id: booking.bookingId || booking.id.slice(0, 8).toUpperCase(),
|
||||||
|
})
|
||||||
: t('mobile.booking', { number: booking.bookingNumber || '-' })}
|
: t('mobile.booking', { number: booking.bookingNumber || '-' })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -353,7 +386,9 @@ export default function BookingsListPage() {
|
|||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-medium text-gray-900">
|
||||||
{booking.type === 'csv'
|
{booking.type === 'csv'
|
||||||
? t('units.palletsCount', { count: booking.palletCount })
|
? t('units.palletsCount', { count: booking.palletCount })
|
||||||
: t('units.containersCount', { count: booking.containers?.length || 0 })}
|
: t('units.containersCount', {
|
||||||
|
count: booking.containers?.length || 0,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
|
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
|
||||||
@ -364,15 +399,15 @@ export default function BookingsListPage() {
|
|||||||
{booking.type === 'csv'
|
{booking.type === 'csv'
|
||||||
? t('units.kg', { value: booking.weightKG })
|
? t('units.kg', { value: booking.weightKG })
|
||||||
: booking.totalWeight
|
: booking.totalWeight
|
||||||
? t('units.kg', { value: booking.totalWeight })
|
? t('units.kg', { value: booking.totalWeight })
|
||||||
: 'N/A'}
|
: 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
{booking.type === 'csv'
|
{booking.type === 'csv'
|
||||||
? t('units.cbm', { value: booking.volumeCBM })
|
? t('units.cbm', { value: booking.volumeCBM })
|
||||||
: booking.totalVolume
|
: booking.totalVolume
|
||||||
? t('units.cbm', { value: booking.totalVolume })
|
? t('units.cbm', { value: booking.totalVolume })
|
||||||
: ''}
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
@ -397,21 +432,24 @@ export default function BookingsListPage() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{(booking.createdAt || booking.requestedAt)
|
{booking.createdAt || booking.requestedAt
|
||||||
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(dateLocale, {
|
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(
|
||||||
day: '2-digit',
|
dateLocale,
|
||||||
month: '2-digit',
|
{
|
||||||
year: 'numeric',
|
day: '2-digit',
|
||||||
})
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
}
|
||||||
|
)
|
||||||
: 'N/A'}
|
: 'N/A'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-left text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-left text-sm font-medium">
|
||||||
{booking.type === 'csv'
|
{booking.type === 'csv'
|
||||||
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
|
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
|
||||||
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
|
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy">
|
||||||
{booking.bookingNumber || '-'}
|
{booking.bookingNumber || '-'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -444,12 +482,15 @@ export default function BookingsListPage() {
|
|||||||
start: startIndex + 1,
|
start: startIndex + 1,
|
||||||
end: Math.min(endIndex, totalBookings),
|
end: Math.min(endIndex, totalBookings),
|
||||||
total: totalBookings,
|
total: totalBookings,
|
||||||
b: (chunks) => <span className="font-medium">{chunks}</span>,
|
b: chunks => <span className="font-medium">{chunks}</span>,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
<nav
|
||||||
|
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
||||||
|
aria-label="Pagination"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage(page - 1)}
|
onClick={() => setPage(page - 1)}
|
||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
@ -457,21 +498,40 @@ export default function BookingsListPage() {
|
|||||||
>
|
>
|
||||||
<span className="sr-only">{t('pagination.previous')}</span>
|
<span className="sr-only">{t('pagination.previous')}</span>
|
||||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{[...Array(totalPages)].map((_, idx) => {
|
{[...Array(totalPages)].map((_, idx) => {
|
||||||
const pageNum = idx + 1;
|
const pageNum = idx + 1;
|
||||||
const showPage = pageNum === 1 ||
|
const showPage =
|
||||||
pageNum === totalPages ||
|
pageNum === 1 ||
|
||||||
(pageNum >= page - 1 && pageNum <= page + 1);
|
pageNum === totalPages ||
|
||||||
|
(pageNum >= page - 1 && pageNum <= page + 1);
|
||||||
|
|
||||||
if (!showPage && pageNum === 2) {
|
if (!showPage && pageNum === 2) {
|
||||||
return <span key={pageNum} className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">...</span>;
|
return (
|
||||||
|
<span
|
||||||
|
key={pageNum}
|
||||||
|
className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!showPage && pageNum === totalPages - 1) {
|
if (!showPage && pageNum === totalPages - 1) {
|
||||||
return <span key={pageNum} className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">...</span>;
|
return (
|
||||||
|
<span
|
||||||
|
key={pageNum}
|
||||||
|
className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!showPage) return null;
|
if (!showPage) return null;
|
||||||
|
|
||||||
@ -497,7 +557,11 @@ export default function BookingsListPage() {
|
|||||||
>
|
>
|
||||||
<span className="sr-only">{t('pagination.next')}</span>
|
<span className="sr-only">{t('pagination.next')}</span>
|
||||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
@ -523,9 +587,7 @@ export default function BookingsListPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('empty.title')}</h3>
|
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('empty.title')}</h3>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
{searchTerm || statusFilter
|
{searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings')}
|
||||||
? t('empty.hasFilters')
|
|
||||||
: t('empty.noBookings')}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -61,8 +61,17 @@ export default function UserDocumentsPage() {
|
|||||||
const getFileType = (fileName: string): string => {
|
const getFileType = (fileName: string): string => {
|
||||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
pdf: 'PDF', doc: 'Word', docx: 'Word', xls: 'Excel', xlsx: 'Excel',
|
pdf: 'PDF',
|
||||||
jpg: 'Image', jpeg: 'Image', png: 'Image', gif: 'Image', txt: 'Text', csv: 'CSV',
|
doc: 'Word',
|
||||||
|
docx: 'Word',
|
||||||
|
xls: 'Excel',
|
||||||
|
xlsx: 'Excel',
|
||||||
|
jpg: 'Image',
|
||||||
|
jpeg: 'Image',
|
||||||
|
png: 'Image',
|
||||||
|
gif: 'Image',
|
||||||
|
txt: 'Text',
|
||||||
|
csv: 'CSV',
|
||||||
};
|
};
|
||||||
return typeMap[ext] || ext.toUpperCase();
|
return typeMap[ext] || ext.toUpperCase();
|
||||||
};
|
};
|
||||||
@ -151,7 +160,7 @@ export default function UserDocumentsPage() {
|
|||||||
|
|
||||||
const getDocumentIcon = (type: string): ReactNode => {
|
const getDocumentIcon = (type: string): ReactNode => {
|
||||||
const typeLower = type.toLowerCase();
|
const typeLower = type.toLowerCase();
|
||||||
const cls = "h-6 w-6";
|
const cls = 'h-6 w-6';
|
||||||
const iconMap: Record<string, ReactNode> = {
|
const iconMap: Record<string, ReactNode> = {
|
||||||
'application/pdf': <FileText className={`${cls} text-red-500`} />,
|
'application/pdf': <FileText className={`${cls} text-red-500`} />,
|
||||||
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
|
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
|
||||||
@ -356,8 +365,12 @@ export default function UserDocumentsPage() {
|
|||||||
{ key: 'quoteNumber', label: t('export.quoteNumber') },
|
{ key: 'quoteNumber', label: t('export.quoteNumber') },
|
||||||
{ key: 'route', label: t('export.route') },
|
{ key: 'route', label: t('export.route') },
|
||||||
{ key: 'carrierName', label: t('export.carrier') },
|
{ key: 'carrierName', label: t('export.carrier') },
|
||||||
{ key: 'status', label: t('export.status'), format: (v) => getStatusLabel(v) },
|
{ key: 'status', label: t('export.status'), format: v => getStatusLabel(v) },
|
||||||
{ key: 'uploadedAt', label: t('export.uploadedAt'), format: (v) => v ? new Date(v).toLocaleDateString(locale) : '' },
|
{
|
||||||
|
key: 'uploadedAt',
|
||||||
|
label: t('export.uploadedAt'),
|
||||||
|
format: v => (v ? new Date(v).toLocaleDateString(locale) : ''),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -365,8 +378,18 @@ export default function UserDocumentsPage() {
|
|||||||
disabled={bookingsAvailableForDocuments.length === 0}
|
disabled={bookingsAvailableForDocuments.length === 0}
|
||||||
className="inline-flex items-center px-3 sm:px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="inline-flex items-center px-3 sm:px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 sm:mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
className="w-4 h-4 sm:mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="hidden sm:inline">{t('addDocument.buttonLabel')}</span>
|
<span className="hidden sm:inline">{t('addDocument.buttonLabel')}</span>
|
||||||
</button>
|
</button>
|
||||||
@ -396,7 +419,9 @@ export default function UserDocumentsPage() {
|
|||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.search')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('filters.search')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('filters.searchPlaceholder')}
|
placeholder={t('filters.searchPlaceholder')}
|
||||||
@ -406,7 +431,9 @@ export default function UserDocumentsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.quoteNumber')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('filters.quoteNumber')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: #F2CAD5E1"
|
placeholder="Ex: #F2CAD5E1"
|
||||||
@ -416,7 +443,9 @@ export default function UserDocumentsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.status')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('filters.status')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={e => setFilterStatus(e.target.value)}
|
onChange={e => setFilterStatus(e.target.value)}
|
||||||
@ -443,13 +472,27 @@ export default function UserDocumentsPage() {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.documentName')}</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.type')}</th>
|
{t('table.documentName')}
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.quoteNumber')}</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.route')}</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.carrier')}</th>
|
{t('table.type')}
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.status')}</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.actions')}</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('table.quoteNumber')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('table.route')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('table.carrier')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('table.status')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('table.actions')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
@ -481,14 +524,16 @@ export default function UserDocumentsPage() {
|
|||||||
<div className="text-sm text-gray-900">{doc.carrierName}</div>
|
<div className="text-sm text-gray-900">{doc.carrierName}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}>
|
<span
|
||||||
|
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}
|
||||||
|
>
|
||||||
{getStatusLabel(doc.status)}
|
{getStatusLabel(doc.status)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||||
<div className="relative inline-block text-left">
|
<div className="relative inline-block text-left">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleDropdown(`${doc.bookingId}-${doc.id}`);
|
toggleDropdown(`${doc.bookingId}-${doc.id}`);
|
||||||
}}
|
}}
|
||||||
@ -505,7 +550,7 @@ export default function UserDocumentsPage() {
|
|||||||
{openDropdownId === `${doc.bookingId}-${doc.id}` && (
|
{openDropdownId === `${doc.bookingId}-${doc.id}` && (
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"
|
className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<button
|
<button
|
||||||
@ -515,8 +560,18 @@ export default function UserDocumentsPage() {
|
|||||||
}}
|
}}
|
||||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-3 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
className="w-4 h-4 mr-3 text-green-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('actions.download')}
|
{t('actions.download')}
|
||||||
</button>
|
</button>
|
||||||
@ -524,8 +579,18 @@ export default function UserDocumentsPage() {
|
|||||||
onClick={() => handleReplaceClick(doc)}
|
onClick={() => handleReplaceClick(doc)}
|
||||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-3 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
className="w-4 h-4 mr-3 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('actions.replace')}
|
{t('actions.replace')}
|
||||||
</button>
|
</button>
|
||||||
@ -587,7 +652,10 @@ export default function UserDocumentsPage() {
|
|||||||
<option value={100}>100</option>
|
<option value={100}>100</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
<nav
|
||||||
|
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
||||||
|
aria-label="Pagination"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
@ -595,7 +663,11 @@ export default function UserDocumentsPage() {
|
|||||||
>
|
>
|
||||||
<span className="sr-only">{t('pagination.previous')}</span>
|
<span className="sr-only">{t('pagination.previous')}</span>
|
||||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -633,7 +705,11 @@ export default function UserDocumentsPage() {
|
|||||||
>
|
>
|
||||||
<span className="sr-only">{t('pagination.next')}</span>
|
<span className="sr-only">{t('pagination.next')}</span>
|
||||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
@ -647,20 +723,37 @@ export default function UserDocumentsPage() {
|
|||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={handleCloseModal} />
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
/>
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
<div className="sm:flex sm:items-start">
|
<div className="sm:flex sm:items-start">
|
||||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||||
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
className="h-6 w-6 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">{t('addDocument.modalTitle')}</h3>
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
{t('addDocument.modalTitle')}
|
||||||
|
</h3>
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('addDocument.selectBooking')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('addDocument.selectBooking')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={selectedBookingId || ''}
|
value={selectedBookingId || ''}
|
||||||
onChange={e => setSelectedBookingId(e.target.value)}
|
onChange={e => setSelectedBookingId(e.target.value)}
|
||||||
@ -669,13 +762,19 @@ export default function UserDocumentsPage() {
|
|||||||
<option value="">{t('addDocument.selectBookingPlaceholder')}</option>
|
<option value="">{t('addDocument.selectBookingPlaceholder')}</option>
|
||||||
{bookingsAvailableForDocuments.map(booking => (
|
{bookingsAvailableForDocuments.map(booking => (
|
||||||
<option key={booking.id} value={booking.id}>
|
<option key={booking.id} value={booking.id}>
|
||||||
{getQuoteNumber(booking)} - {booking.origin} → {booking.destination} ({booking.status === 'PENDING' ? t('statuses.PENDING') : t('statuses.ACCEPTED')})
|
{getQuoteNumber(booking)} - {booking.origin} → {booking.destination} (
|
||||||
|
{booking.status === 'PENDING'
|
||||||
|
? t('statuses.PENDING')
|
||||||
|
: t('statuses.ACCEPTED')}
|
||||||
|
)
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('addDocument.filesToAdd')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('addDocument.filesToAdd')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@ -683,7 +782,9 @@ export default function UserDocumentsPage() {
|
|||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
||||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">{t('addDocument.acceptedFormats')}</p>
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{t('addDocument.acceptedFormats')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -698,13 +799,30 @@ export default function UserDocumentsPage() {
|
|||||||
>
|
>
|
||||||
{uploadingFiles ? (
|
{uploadingFiles ? (
|
||||||
<>
|
<>
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
<svg
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('addDocument.uploading')}
|
{t('addDocument.uploading')}
|
||||||
</>
|
</>
|
||||||
) : t('addDocument.add')}
|
) : (
|
||||||
|
t('addDocument.add')
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -723,34 +841,58 @@ export default function UserDocumentsPage() {
|
|||||||
{showReplaceModal && documentToReplace && (
|
{showReplaceModal && documentToReplace && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={handleCloseReplaceModal} />
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||||
|
onClick={handleCloseReplaceModal}
|
||||||
|
/>
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
<div className="sm:flex sm:items-start">
|
<div className="sm:flex sm:items-start">
|
||||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||||
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
className="h-6 w-6 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">{t('replaceDocument.modalTitle')}</h3>
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
{t('replaceDocument.modalTitle')}
|
||||||
|
</h3>
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
<p className="text-sm text-gray-500">{t('replaceDocument.currentDocument')}</p>
|
<p className="text-sm text-gray-500">
|
||||||
<p className="text-sm font-medium text-gray-900 mt-1">{documentToReplace.fileName}</p>
|
{t('replaceDocument.currentDocument')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 mt-1">
|
||||||
|
{documentToReplace.fileName}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
{t('replaceDocument.booking')}: {documentToReplace.quoteNumber} - {documentToReplace.route}
|
{t('replaceDocument.booking')}: {documentToReplace.quoteNumber} -{' '}
|
||||||
|
{documentToReplace.route}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('replaceDocument.newFile')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('replaceDocument.newFile')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
ref={replaceFileInputRef}
|
ref={replaceFileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
||||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">{t('replaceDocument.acceptedFormats')}</p>
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{t('replaceDocument.acceptedFormats')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -765,13 +907,30 @@ export default function UserDocumentsPage() {
|
|||||||
>
|
>
|
||||||
{replacingFile ? (
|
{replacingFile ? (
|
||||||
<>
|
<>
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
<svg
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('replaceDocument.replacing')}
|
{t('replaceDocument.replacing')}
|
||||||
</>
|
</>
|
||||||
) : t('replaceDocument.replace')}
|
) : (
|
||||||
|
t('replaceDocument.replace')
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -52,17 +52,39 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: PlanFeature }> = [
|
const navigation: Array<{
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
icon: any;
|
||||||
|
requiredFeature?: PlanFeature;
|
||||||
|
}> = [
|
||||||
{ name: t('nav.dashboard'), href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
|
{ name: t('nav.dashboard'), href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
|
||||||
{ name: t('nav.bookings'), href: '/dashboard/bookings', icon: Package },
|
{ name: t('nav.bookings'), href: '/dashboard/bookings', icon: Package },
|
||||||
{ name: t('nav.documents'), href: '/dashboard/documents', icon: FileText },
|
{ name: t('nav.documents'), href: '/dashboard/documents', icon: FileText },
|
||||||
{ name: t('nav.tracking'), href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' },
|
{
|
||||||
|
name: t('nav.tracking'),
|
||||||
|
href: '/dashboard/track-trace',
|
||||||
|
icon: Search,
|
||||||
|
requiredFeature: 'dashboard',
|
||||||
|
},
|
||||||
{ name: t('nav.wiki'), href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
|
{ name: t('nav.wiki'), href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
|
||||||
{ name: t('nav.organization'), href: '/dashboard/settings/organization', icon: Building2 },
|
{ name: t('nav.organization'), href: '/dashboard/settings/organization', icon: Building2 },
|
||||||
{ name: t('nav.apiKeys'), href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature },
|
{
|
||||||
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
|
name: t('nav.apiKeys'),
|
||||||
{ name: t('nav.users'), href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature },
|
href: '/dashboard/settings/api-keys',
|
||||||
] : []),
|
icon: Key,
|
||||||
|
requiredFeature: 'api_access' as PlanFeature,
|
||||||
|
},
|
||||||
|
...(user?.role === 'ADMIN' || user?.role === 'MANAGER'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: t('nav.users'),
|
||||||
|
href: '/dashboard/settings/users',
|
||||||
|
icon: Users,
|
||||||
|
requiredFeature: 'user_management' as PlanFeature,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const isActive = (href: string) => {
|
const isActive = (href: string) => {
|
||||||
@ -89,14 +111,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex items-center justify-between h-16 px-6 border-b">
|
<div className="flex items-center justify-between h-16 px-6 border-b">
|
||||||
<Link href="/dashboard" className="text-2xl font-bold text-blue-600">
|
<Link href="/dashboard" className="text-2xl font-bold text-blue-600">
|
||||||
<Image
|
<Image
|
||||||
src="/assets/logos/logo-black.svg"
|
src="/assets/logos/logo-black.svg"
|
||||||
alt="Xpeditis"
|
alt="Xpeditis"
|
||||||
width={50}
|
width={50}
|
||||||
height={60}
|
height={60}
|
||||||
priority
|
priority
|
||||||
className="h-auto"
|
className="h-auto"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
className="lg:hidden text-gray-500 hover:text-gray-700"
|
className="lg:hidden text-gray-500 hover:text-gray-700"
|
||||||
@ -153,9 +175,10 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
<p className="text-sm font-medium text-gray-900 truncate">
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
{user?.firstName} {user?.lastName}
|
{user?.firstName} {user?.lastName}
|
||||||
</p>
|
</p>
|
||||||
{subscription?.planDetails?.statusBadge && subscription.planDetails.statusBadge !== 'none' && (
|
{subscription?.planDetails?.statusBadge &&
|
||||||
<StatusBadge badge={subscription.planDetails.statusBadge} size="sm" />
|
subscription.planDetails.statusBadge !== 'none' && (
|
||||||
)}
|
<StatusBadge badge={subscription.planDetails.statusBadge} size="sm" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
|
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -195,8 +218,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
<LanguageSwitcher variant="light" />
|
<LanguageSwitcher variant="light" />
|
||||||
<NotificationDropdown />
|
<NotificationDropdown />
|
||||||
|
|
||||||
<Link href="/dashboard/profile" className="w-8 h-8 lg:w-9 lg:h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors">
|
<Link
|
||||||
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
href="/dashboard/profile"
|
||||||
|
className="w-8 h-8 lg:w-9 lg:h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
{user?.firstName?.[0]}
|
||||||
|
{user?.lastName?.[0]}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -212,8 +239,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
{ href: '/dashboard/documents', icon: FileText, label: t('bottomNav.documents') },
|
{ href: '/dashboard/documents', icon: FileText, label: t('bottomNav.documents') },
|
||||||
{ href: '/dashboard/track-trace', icon: Search, label: t('bottomNav.tracking') },
|
{ href: '/dashboard/track-trace', icon: Search, label: t('bottomNav.tracking') },
|
||||||
{ href: '/dashboard/profile', icon: User, label: t('bottomNav.profile') },
|
{ href: '/dashboard/profile', icon: User, label: t('bottomNav.profile') },
|
||||||
].map((item) => {
|
].map(item => {
|
||||||
const active = item.href === '/dashboard' ? pathname === item.href : pathname.startsWith(item.href);
|
const active =
|
||||||
|
item.href === '/dashboard' ? pathname === item.href : pathname.startsWith(item.href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
|
|||||||
@ -11,7 +11,24 @@ import {
|
|||||||
deleteNotification,
|
deleteNotification,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import type { NotificationResponse } from '@/types/api';
|
import type { NotificationResponse } from '@/types/api';
|
||||||
import { Trash2, CheckCheck, Filter, Bell, ChevronLeft, ChevronRight, Package, RefreshCw, XCircle, CheckCircle, Mail, Clock, FileText, Megaphone, User, Building2 } from 'lucide-react';
|
import {
|
||||||
|
Trash2,
|
||||||
|
CheckCheck,
|
||||||
|
Filter,
|
||||||
|
Bell,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Package,
|
||||||
|
RefreshCw,
|
||||||
|
XCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Mail,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
Megaphone,
|
||||||
|
User,
|
||||||
|
Building2,
|
||||||
|
} from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
export default function NotificationsPage() {
|
||||||
@ -83,7 +100,9 @@ export default function NotificationsPage() {
|
|||||||
medium: 'border-l-4 border-yellow-500 bg-yellow-50 hover:bg-yellow-100',
|
medium: 'border-l-4 border-yellow-500 bg-yellow-50 hover:bg-yellow-100',
|
||||||
low: 'border-l-4 border-blue-500 bg-blue-50 hover:bg-blue-100',
|
low: 'border-l-4 border-blue-500 bg-blue-50 hover:bg-blue-100',
|
||||||
};
|
};
|
||||||
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-100';
|
return (
|
||||||
|
colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-100'
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPriorityLabel = (priority: string) => {
|
const getPriorityLabel = (priority: string) => {
|
||||||
@ -97,7 +116,7 @@ export default function NotificationsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getNotificationIcon = (type: string): ReactNode => {
|
const getNotificationIcon = (type: string): ReactNode => {
|
||||||
const iconClass = "h-8 w-8";
|
const iconClass = 'h-8 w-8';
|
||||||
const icons: Record<string, ReactNode> = {
|
const icons: Record<string, ReactNode> = {
|
||||||
booking_created: <Package className={`${iconClass} text-blue-600`} />,
|
booking_created: <Package className={`${iconClass} text-blue-600`} />,
|
||||||
booking_updated: <RefreshCw className={`${iconClass} text-orange-500`} />,
|
booking_updated: <RefreshCw className={`${iconClass} text-orange-500`} />,
|
||||||
@ -173,7 +192,7 @@ export default function NotificationsPage() {
|
|||||||
<Filter className="w-5 h-5 text-gray-500" />
|
<Filter className="w-5 h-5 text-gray-500" />
|
||||||
<span className="text-sm font-medium text-gray-700">{t('filter.label')}</span>
|
<span className="text-sm font-medium text-gray-700">{t('filter.label')}</span>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
{(['all', 'unread', 'read'] as const).map((filter) => (
|
{(['all', 'unread', 'read'] as const).map(filter => (
|
||||||
<button
|
<button
|
||||||
key={filter}
|
key={filter}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -209,7 +228,9 @@ export default function NotificationsPage() {
|
|||||||
) : notifications.length === 0 ? (
|
) : notifications.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-4 flex justify-center"><Bell className="h-16 w-16 text-gray-300" /></div>
|
<div className="mb-4 flex justify-center">
|
||||||
|
<Bell className="h-16 w-16 text-gray-300" />
|
||||||
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">{t('empty.title')}</h3>
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">{t('empty.title')}</h3>
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
{selectedFilter === 'unread' ? t('empty.upToDate') : t('empty.none')}
|
{selectedFilter === 'unread' ? t('empty.upToDate') : t('empty.none')}
|
||||||
@ -245,7 +266,7 @@ export default function NotificationsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDelete(e, notification.id)}
|
onClick={e => handleDelete(e, notification.id)}
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg"
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg"
|
||||||
title={t('deleteTitle')}
|
title={t('deleteTitle')}
|
||||||
>
|
>
|
||||||
@ -273,7 +294,9 @@ export default function NotificationsPage() {
|
|||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">{formatTime(notification.createdAt)}</span>
|
<span className="font-medium">
|
||||||
|
{formatTime(notification.createdAt)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="px-3 py-1 bg-gray-100 rounded-full text-gray-700 font-medium">
|
<span className="px-3 py-1 bg-gray-100 rounded-full text-gray-700 font-medium">
|
||||||
{notification.type.replace(/_/g, ' ').toUpperCase()}
|
{notification.type.replace(/_/g, ' ').toUpperCase()}
|
||||||
@ -284,10 +307,10 @@ export default function NotificationsPage() {
|
|||||||
notification.priority === 'urgent'
|
notification.priority === 'urgent'
|
||||||
? 'bg-red-100 text-red-700'
|
? 'bg-red-100 text-red-700'
|
||||||
: notification.priority === 'high'
|
: notification.priority === 'high'
|
||||||
? 'bg-orange-100 text-orange-700'
|
? 'bg-orange-100 text-orange-700'
|
||||||
: notification.priority === 'medium'
|
: notification.priority === 'medium'
|
||||||
? 'bg-yellow-100 text-yellow-700'
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
: 'bg-blue-100 text-blue-700'
|
: 'bg-blue-100 text-blue-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{getPriorityLabel(notification.priority)}
|
{getPriorityLabel(notification.priority)}
|
||||||
@ -329,12 +352,12 @@ export default function NotificationsPage() {
|
|||||||
current: currentPage,
|
current: currentPage,
|
||||||
total: totalPages,
|
total: totalPages,
|
||||||
items: total,
|
items: total,
|
||||||
b: (chunks) => <span className="font-semibold">{chunks}</span>,
|
b: chunks => <span className="font-semibold">{chunks}</span>,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
@ -370,7 +393,7 @@ export default function NotificationsPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -80,9 +80,7 @@ export default function DashboardPage() {
|
|||||||
<div className="flex items-center justify-between pb-3 sm:pb-4 border-b border-gray-200">
|
<div className="flex items-center justify-between pb-3 sm:pb-4 border-b border-gray-200">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl sm:text-3xl font-semibold text-gray-900">{t('title')}</h1>
|
<h1 className="text-xl sm:text-3xl font-semibold text-gray-900">{t('title')}</h1>
|
||||||
<p className="text-gray-600 mt-0.5 sm:mt-1 text-xs sm:text-sm">
|
<p className="text-gray-600 mt-0.5 sm:mt-1 text-xs sm:text-sm">{t('subtitle')}</p>
|
||||||
{t('subtitle')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 sm:space-x-3">
|
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||||
@ -94,10 +92,26 @@ export default function DashboardPage() {
|
|||||||
{ key: 'totalBookings', label: t('export.totalBookings') },
|
{ key: 'totalBookings', label: t('export.totalBookings') },
|
||||||
{ key: 'acceptedBookings', label: t('export.accepted') },
|
{ key: 'acceptedBookings', label: t('export.accepted') },
|
||||||
{ key: 'rejectedBookings', label: t('export.rejected') },
|
{ key: 'rejectedBookings', label: t('export.rejected') },
|
||||||
{ key: 'totalWeightKG', label: t('export.totalWeight'), format: (v) => v?.toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-US') || '0' },
|
{
|
||||||
{ key: 'totalVolumeCBM', label: t('export.totalVolume'), format: (v) => v?.toFixed(2) || '0' },
|
key: 'totalWeightKG',
|
||||||
{ key: 'acceptanceRate', label: t('export.acceptanceRate'), format: (v) => v?.toFixed(1) || '0' },
|
label: t('export.totalWeight'),
|
||||||
{ key: 'avgPriceUSD', label: t('export.avgPrice'), format: (v) => v?.toFixed(2) || '0' },
|
format: v => v?.toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-US') || '0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'totalVolumeCBM',
|
||||||
|
label: t('export.totalVolume'),
|
||||||
|
format: v => v?.toFixed(2) || '0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'acceptanceRate',
|
||||||
|
label: t('export.acceptanceRate'),
|
||||||
|
format: v => v?.toFixed(1) || '0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avgPriceUSD',
|
||||||
|
label: t('export.avgPrice'),
|
||||||
|
format: v => v?.toFixed(2) || '0',
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Link href="/dashboard/search-advanced">
|
<Link href="/dashboard/search-advanced">
|
||||||
@ -167,9 +181,7 @@ export default function DashboardPage() {
|
|||||||
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
|
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">{csvKpis?.totalPending || 0}</p>
|
||||||
{csvKpis?.totalPending || 0}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
{t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })}
|
{t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })}
|
||||||
</p>
|
</p>
|
||||||
@ -282,7 +294,9 @@ export default function DashboardPage() {
|
|||||||
<div className="h-10 w-10 rounded-lg bg-green-100 flex items-center justify-center mb-2">
|
<div className="h-10 w-10 rounded-lg bg-green-100 flex items-center justify-center mb-2">
|
||||||
<TrendingUp className="h-5 w-5 text-green-600" />
|
<TrendingUp className="h-5 w-5 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.acceptanceRate')}</p>
|
<p className="text-xs font-medium text-gray-600 mb-1">
|
||||||
|
{t('performance.acceptanceRate')}
|
||||||
|
</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{csvKpisLoading ? '--' : `${(csvKpis?.acceptanceRate ?? 0).toFixed(1)}%`}
|
{csvKpisLoading ? '--' : `${(csvKpis?.acceptanceRate ?? 0).toFixed(1)}%`}
|
||||||
</p>
|
</p>
|
||||||
@ -296,7 +310,9 @@ export default function DashboardPage() {
|
|||||||
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
|
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
|
||||||
<Package className="h-5 w-5 text-blue-600" />
|
<Package className="h-5 w-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.totalBookings')}</p>
|
<p className="text-xs font-medium text-gray-600 mb-1">
|
||||||
|
{t('performance.totalBookings')}
|
||||||
|
</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{csvKpisLoading
|
{csvKpisLoading
|
||||||
? '--'
|
? '--'
|
||||||
@ -314,7 +330,9 @@ export default function DashboardPage() {
|
|||||||
<div className="h-10 w-10 rounded-lg bg-purple-100 flex items-center justify-center mb-2">
|
<div className="h-10 w-10 rounded-lg bg-purple-100 flex items-center justify-center mb-2">
|
||||||
<Weight className="h-5 w-5 text-purple-600" />
|
<Weight className="h-5 w-5 text-purple-600" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.totalVolume')}</p>
|
<p className="text-xs font-medium text-gray-600 mb-1">
|
||||||
|
{t('performance.totalVolume')}
|
||||||
|
</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`}
|
{csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`}
|
||||||
<span className="text-sm font-normal text-gray-500 ml-1">CBM</span>
|
<span className="text-sm font-normal text-gray-500 ml-1">CBM</span>
|
||||||
@ -373,7 +391,9 @@ export default function DashboardPage() {
|
|||||||
{carrier.carrierName}
|
{carrier.carrierName}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
|
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
|
||||||
<span>{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}</span>
|
<span>
|
||||||
|
{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}
|
||||||
|
</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{numberFormat.format(carrier.totalWeightKG)} KG</span>
|
<span>{numberFormat.format(carrier.totalWeightKG)} KG</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -251,7 +251,10 @@ export default function ProfilePage() {
|
|||||||
<form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6">
|
<form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
htmlFor="firstName"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
{t('profileForm.firstName')}
|
{t('profileForm.firstName')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -268,7 +271,10 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
htmlFor="lastName"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
{t('profileForm.lastName')}
|
{t('profileForm.lastName')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -305,14 +311,19 @@ export default function ProfilePage() {
|
|||||||
disabled={updateProfileMutation.isPending}
|
disabled={updateProfileMutation.isPending}
|
||||||
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{updateProfileMutation.isPending ? t('profileForm.saving') : t('profileForm.save')}
|
{updateProfileMutation.isPending
|
||||||
|
? t('profileForm.saving')
|
||||||
|
: t('profileForm.save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)} className="space-y-6">
|
<form onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)} className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
htmlFor="currentPassword"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
{t('passwordForm.current')}
|
{t('passwordForm.current')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -330,7 +341,10 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
htmlFor="newPassword"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
{t('passwordForm.new')}
|
{t('passwordForm.new')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -349,7 +363,10 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
htmlFor="confirmPassword"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
{t('passwordForm.confirm')}
|
{t('passwordForm.confirm')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -372,7 +389,9 @@ export default function ProfilePage() {
|
|||||||
disabled={updatePasswordMutation.isPending}
|
disabled={updatePasswordMutation.isPending}
|
||||||
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{updatePasswordMutation.isPending ? t('passwordForm.submitting') : t('passwordForm.submit')}
|
{updatePasswordMutation.isPending
|
||||||
|
? t('passwordForm.submitting')
|
||||||
|
: t('passwordForm.submit')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -5,16 +5,16 @@ import { useRouter } from '@/i18n/navigation';
|
|||||||
import { Search, Loader2 } from 'lucide-react';
|
import { Search, Loader2 } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import {
|
import { getAvailableOrigins, getAvailableDestinations, RoutePortInfo } from '@/lib/api/rates';
|
||||||
getAvailableOrigins,
|
|
||||||
getAvailableDestinations,
|
|
||||||
RoutePortInfo,
|
|
||||||
} from '@/lib/api/rates';
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
const PortRouteMapLoader = () => {
|
const PortRouteMapLoader = () => {
|
||||||
const t = useTranslations('dashboard.rateSearch');
|
const t = useTranslations('dashboard.rateSearch');
|
||||||
return <div className="h-80 bg-gray-100 rounded-lg flex items-center justify-center">{t('mapLoading')}</div>;
|
return (
|
||||||
|
<div className="h-80 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||||
|
{t('mapLoading')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PortRouteMap = dynamic(() => import('@/components/PortRouteMap'), {
|
const PortRouteMap = dynamic(() => import('@/components/PortRouteMap'), {
|
||||||
@ -87,7 +87,9 @@ export default function AdvancedSearchPage() {
|
|||||||
const [showOriginDropdown, setShowOriginDropdown] = useState(false);
|
const [showOriginDropdown, setShowOriginDropdown] = useState(false);
|
||||||
const [showDestinationDropdown, setShowDestinationDropdown] = useState(false);
|
const [showDestinationDropdown, setShowDestinationDropdown] = useState(false);
|
||||||
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
|
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
|
||||||
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(null);
|
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
|
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
|
||||||
queryKey: ['available-origins'],
|
queryKey: ['available-origins'],
|
||||||
@ -211,7 +213,10 @@ export default function AdvancedSearchPage() {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
{t('step1.originLabel')} {searchForm.origin && <span className="text-green-600 text-xs">{t('step1.selected')}</span>}
|
{t('step1.originLabel')}{' '}
|
||||||
|
{searchForm.origin && (
|
||||||
|
<span className="text-green-600 text-xs">{t('step1.selected')}</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@ -269,16 +274,24 @@ export default function AdvancedSearchPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showOriginDropdown && filteredOrigins.length === 0 && !isLoadingOrigins && originsData && (
|
{showOriginDropdown &&
|
||||||
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
|
filteredOrigins.length === 0 &&
|
||||||
<p className="text-sm text-gray-500">{t('step1.noOrigin', { query: originSearch })}</p>
|
!isLoadingOrigins &&
|
||||||
</div>
|
originsData && (
|
||||||
)}
|
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{t('step1.noOrigin', { query: originSearch })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
{t('step1.destinationLabel')} {searchForm.destination && <span className="text-green-600 text-xs">{t('step1.selected')}</span>}
|
{t('step1.destinationLabel')}{' '}
|
||||||
|
{searchForm.destination && (
|
||||||
|
<span className="text-green-600 text-xs">{t('step1.selected')}</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@ -287,7 +300,10 @@ export default function AdvancedSearchPage() {
|
|||||||
onChange={e => {
|
onChange={e => {
|
||||||
setDestinationSearch(e.target.value);
|
setDestinationSearch(e.target.value);
|
||||||
setShowDestinationDropdown(true);
|
setShowDestinationDropdown(true);
|
||||||
if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) {
|
if (
|
||||||
|
selectedDestinationPort &&
|
||||||
|
e.target.value !== selectedDestinationPort.displayName
|
||||||
|
) {
|
||||||
setSearchForm({ ...searchForm, destination: '' });
|
setSearchForm({ ...searchForm, destination: '' });
|
||||||
setSelectedDestinationPort(null);
|
setSelectedDestinationPort(null);
|
||||||
}
|
}
|
||||||
@ -295,7 +311,11 @@ export default function AdvancedSearchPage() {
|
|||||||
onFocus={() => setShowDestinationDropdown(true)}
|
onFocus={() => setShowDestinationDropdown(true)}
|
||||||
onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
|
onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
|
||||||
disabled={!searchForm.origin}
|
disabled={!searchForm.origin}
|
||||||
placeholder={searchForm.origin ? t('step1.destinationPlaceholder') : t('step1.destinationDisabled')}
|
placeholder={
|
||||||
|
searchForm.origin
|
||||||
|
? t('step1.destinationPlaceholder')
|
||||||
|
: t('step1.destinationDisabled')
|
||||||
|
}
|
||||||
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
|
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
|
||||||
searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300'
|
searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300'
|
||||||
} ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
} ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||||||
@ -308,7 +328,10 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
{searchForm.origin && destinationsData?.total !== undefined && (
|
{searchForm.origin && destinationsData?.total !== undefined && (
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
{t('step1.availableDestinations', { count: destinationsData.total, port: selectedOriginPort?.name || searchForm.origin })}
|
{t('step1.availableDestinations', {
|
||||||
|
count: destinationsData.total,
|
||||||
|
port: selectedOriginPort?.name || searchForm.origin,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{showDestinationDropdown && filteredDestinations.length > 0 && (
|
{showDestinationDropdown && filteredDestinations.length > 0 && (
|
||||||
@ -338,37 +361,47 @@ export default function AdvancedSearchPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showDestinationDropdown && filteredDestinations.length === 0 && !isLoadingDestinations && searchForm.origin && destinationsData && (
|
{showDestinationDropdown &&
|
||||||
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
|
filteredDestinations.length === 0 &&
|
||||||
<p className="text-sm text-gray-500">{t('step1.noDestination', { query: destinationSearch })}</p>
|
!isLoadingDestinations &&
|
||||||
</div>
|
searchForm.origin &&
|
||||||
)}
|
destinationsData && (
|
||||||
|
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{t('step1.noDestination', { query: destinationSearch })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedOriginPort && selectedDestinationPort && selectedOriginPort.latitude && selectedDestinationPort.latitude && (
|
{selectedOriginPort &&
|
||||||
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden">
|
selectedDestinationPort &&
|
||||||
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
|
selectedOriginPort.latitude &&
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
selectedDestinationPort.latitude && (
|
||||||
{t('step1.routeTitle', { origin: selectedOriginPort.name, destination: selectedDestinationPort.name })}
|
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden">
|
||||||
</h3>
|
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
{t('step1.routeDescription')}
|
{t('step1.routeTitle', {
|
||||||
</p>
|
origin: selectedOriginPort.name,
|
||||||
|
destination: selectedDestinationPort.name,
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{t('step1.routeDescription')}</p>
|
||||||
|
</div>
|
||||||
|
<PortRouteMap
|
||||||
|
portA={{
|
||||||
|
lat: selectedOriginPort.latitude,
|
||||||
|
lng: selectedOriginPort.longitude!,
|
||||||
|
}}
|
||||||
|
portB={{
|
||||||
|
lat: selectedDestinationPort.latitude,
|
||||||
|
lng: selectedDestinationPort.longitude!,
|
||||||
|
}}
|
||||||
|
height="400px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PortRouteMap
|
)}
|
||||||
portA={{
|
|
||||||
lat: selectedOriginPort.latitude,
|
|
||||||
lng: selectedOriginPort.longitude!,
|
|
||||||
}}
|
|
||||||
portB={{
|
|
||||||
lat: selectedDestinationPort.latitude,
|
|
||||||
lng: selectedDestinationPort.longitude!,
|
|
||||||
}}
|
|
||||||
height="400px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -390,7 +423,9 @@ export default function AdvancedSearchPage() {
|
|||||||
{searchForm.packages.map((pkg, index) => (
|
{searchForm.packages.map((pkg, index) => (
|
||||||
<div key={index} className="border border-gray-200 rounded-lg p-4 space-y-4">
|
<div key={index} className="border border-gray-200 rounded-lg p-4 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="font-medium text-gray-900">{t('step2.packageNumber', { number: index + 1 })}</h3>
|
<h3 className="font-medium text-gray-900">
|
||||||
|
{t('step2.packageNumber', { number: index + 1 })}
|
||||||
|
</h3>
|
||||||
{searchForm.packages.length > 1 && (
|
{searchForm.packages.length > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -404,7 +439,9 @@ export default function AdvancedSearchPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-5 gap-3">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.type')}</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
{t('step2.type')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={pkg.type}
|
value={pkg.type}
|
||||||
onChange={e => updatePackage(index, 'type', e.target.value)}
|
onChange={e => updatePackage(index, 'type', e.target.value)}
|
||||||
@ -418,7 +455,9 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.quantity')}</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
{t('step2.quantity')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@ -429,7 +468,9 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.length')}</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
{t('step2.length')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@ -440,7 +481,9 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.width')}</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
{t('step2.width')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@ -451,7 +494,9 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.height')}</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
{t('step2.height')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@ -464,7 +509,9 @@ export default function AdvancedSearchPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.weight')}</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
{t('step2.weight')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@ -538,12 +585,12 @@ export default function AdvancedSearchPage() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={searchForm.exportAssistance}
|
checked={searchForm.exportAssistance}
|
||||||
onChange={e =>
|
onChange={e => setSearchForm({ ...searchForm, exportAssistance: e.target.checked })}
|
||||||
setSearchForm({ ...searchForm, exportAssistance: e.target.checked })
|
|
||||||
}
|
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.exportAssistance')}</span>
|
<span className="ml-2 text-sm text-gray-700">
|
||||||
|
{t('step3.customs.exportAssistance')}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -581,9 +628,7 @@ export default function AdvancedSearchPage() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={searchForm.specialHandling}
|
checked={searchForm.specialHandling}
|
||||||
onChange={e =>
|
onChange={e => setSearchForm({ ...searchForm, specialHandling: e.target.checked })}
|
||||||
setSearchForm({ ...searchForm, specialHandling: e.target.checked })
|
|
||||||
}
|
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.special')}</span>
|
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.special')}</span>
|
||||||
@ -649,9 +694,7 @@ export default function AdvancedSearchPage() {
|
|||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
|
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">{t('subtitle')}</p>
|
||||||
{t('subtitle')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center space-x-4">
|
<div className="flex items-center justify-center space-x-4">
|
||||||
@ -666,9 +709,7 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
{step < 3 && (
|
{step < 3 && (
|
||||||
<div
|
<div
|
||||||
className={`w-20 h-1 mx-2 ${
|
className={`w-20 h-1 mx-2 ${currentStep > step ? 'bg-blue-600' : 'bg-gray-200'}`}
|
||||||
currentStep > step ? 'bg-blue-600' : 'bg-gray-200'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -711,7 +752,6 @@ export default function AdvancedSearchPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,16 @@ import { useRouter } from '@/i18n/navigation';
|
|||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
|
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
|
||||||
import type { CsvRateSearchResult } from '@/types/rates';
|
import type { CsvRateSearchResult } from '@/types/rates';
|
||||||
import { Search, Lightbulb, DollarSign, Scale, Zap, Trophy, XCircle, AlertTriangle } from 'lucide-react';
|
import {
|
||||||
|
Search,
|
||||||
|
Lightbulb,
|
||||||
|
DollarSign,
|
||||||
|
Scale,
|
||||||
|
Zap,
|
||||||
|
Trophy,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface BestOptions {
|
interface BestOptions {
|
||||||
eco: CsvRateSearchResult;
|
eco: CsvRateSearchResult;
|
||||||
@ -76,8 +85,12 @@ export default function SearchResultsPage() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...results].sort((a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting);
|
const sorted = [...results].sort(
|
||||||
const fastest = [...results].sort((a, b) => (a.adjustedTransitDays ?? a.transitDays) - (b.adjustedTransitDays ?? b.transitDays));
|
(a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting
|
||||||
|
);
|
||||||
|
const fastest = [...results].sort(
|
||||||
|
(a, b) => (a.adjustedTransitDays ?? a.transitDays) - (b.adjustedTransitDays ?? b.transitDays)
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
eco: sorted[0],
|
eco: sorted[0],
|
||||||
@ -116,7 +129,9 @@ export default function SearchResultsPage() {
|
|||||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
|
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
|
||||||
<div className="mb-4 flex justify-center"><XCircle className="h-16 w-16 text-red-500" /></div>
|
<div className="mb-4 flex justify-center">
|
||||||
|
<XCircle className="h-16 w-16 text-red-500" />
|
||||||
|
</div>
|
||||||
<h3 className="text-xl font-bold text-red-900 mb-2">{t('errorTitle')}</h3>
|
<h3 className="text-xl font-bold text-red-900 mb-2">{t('errorTitle')}</h3>
|
||||||
<p className="text-red-700 mb-4">{error}</p>
|
<p className="text-red-700 mb-4">{error}</p>
|
||||||
<button
|
<button
|
||||||
@ -143,11 +158,11 @@ export default function SearchResultsPage() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
|
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
|
||||||
<div className="mb-4 flex justify-center"><Search className="h-16 w-16 text-yellow-500" /></div>
|
<div className="mb-4 flex justify-center">
|
||||||
|
<Search className="h-16 w-16 text-yellow-500" />
|
||||||
|
</div>
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-2">{t('noResultsTitle')}</h3>
|
<h3 className="text-xl font-bold text-gray-900 mb-2">{t('noResultsTitle')}</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">{t('noResultsMessage', { origin, destination })}</p>
|
||||||
{t('noResultsMessage', { origin, destination })}
|
|
||||||
</p>
|
|
||||||
<div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6">
|
<div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6">
|
||||||
<h4 className="font-semibold text-gray-900 mb-2 flex items-center">
|
<h4 className="font-semibold text-gray-900 mb-2 flex items-center">
|
||||||
<Lightbulb className="h-5 w-5 mr-2 text-yellow-500" /> {t('suggestions')}
|
<Lightbulb className="h-5 w-5 mr-2 text-yellow-500" /> {t('suggestions')}
|
||||||
@ -226,10 +241,14 @@ export default function SearchResultsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('resultsTitle')}</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('resultsTitle')}</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
<span className="font-semibold">{origin}</span> → <span className="font-semibold">{destination}</span>{' '}
|
<span className="font-semibold">{origin}</span> →{' '}
|
||||||
•{' '}
|
<span className="font-semibold">{destination}</span> •{' '}
|
||||||
{palletCount > 0
|
{palletCount > 0
|
||||||
? t('summaryWithPallets', { volume: volumeCBM, weight: weightKG, count: palletCount })
|
? t('summaryWithPallets', {
|
||||||
|
volume: volumeCBM,
|
||||||
|
weight: weightKG,
|
||||||
|
count: palletCount,
|
||||||
|
})
|
||||||
: t('summary', { volume: volumeCBM, weight: weightKG })}
|
: t('summary', { volume: volumeCBM, weight: weightKG })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -274,23 +293,31 @@ export default function SearchResultsPage() {
|
|||||||
<div className="bg-white rounded-lg p-4 mb-4">
|
<div className="bg-white rounded-lg p-4 mb-4">
|
||||||
<div className="text-center mb-3">
|
<div className="text-center mb-3">
|
||||||
<p className="text-sm text-gray-600 mb-1">{t('totalPrice')}</p>
|
<p className="text-sm text-gray-600 mb-1">{t('totalPrice')}</p>
|
||||||
<p className="text-3xl font-bold text-gray-900">{formatPrice(card.option.priceBreakdown.totalPriceForSorting)}</p>
|
<p className="text-3xl font-bold text-gray-900">
|
||||||
|
{formatPrice(card.option.priceBreakdown.totalPriceForSorting)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-200 pt-3 space-y-2 text-sm">
|
<div className="border-t border-gray-200 pt-3 space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">{t('carrier')}</span>
|
<span className="text-gray-600">{t('carrier')}</span>
|
||||||
<span className="font-semibold text-gray-900">{card.option.companyName}</span>
|
<span className="font-semibold text-gray-900">
|
||||||
|
{card.option.companyName}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">{t('transit')}</span>
|
<span className="text-gray-600">{t('transit')}</span>
|
||||||
<span className="font-semibold text-gray-900">
|
<span className="font-semibold text-gray-900">
|
||||||
{t('transitDays', { days: card.option.adjustedTransitDays ?? card.option.transitDays })}
|
{t('transitDays', {
|
||||||
|
days: card.option.adjustedTransitDays ?? card.option.transitDays,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">{t('type')}</span>
|
<span className="text-gray-600">{t('type')}</span>
|
||||||
<span className="font-semibold text-gray-900">{card.option.containerType}</span>
|
<span className="font-semibold text-gray-900">
|
||||||
|
{card.option.containerType}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -298,7 +325,9 @@ export default function SearchResultsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const rateData = encodeURIComponent(JSON.stringify(card.option));
|
const rateData = encodeURIComponent(JSON.stringify(card.option));
|
||||||
router.push(`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`);
|
router.push(
|
||||||
|
`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors`}
|
className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors`}
|
||||||
>
|
>
|
||||||
@ -314,11 +343,16 @@ export default function SearchResultsPage() {
|
|||||||
|
|
||||||
{/* All Results */}
|
{/* All Results */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">{t('allResults', { count: results.length })}</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||||
|
{t('allResults', { count: results.length })}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{results.map((result, index) => (
|
{results.map((result, index) => (
|
||||||
<div key={index} className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
|
<div
|
||||||
|
key={index}
|
||||||
|
className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-gray-900">{result.companyName}</h3>
|
<h3 className="text-xl font-bold text-gray-900">{result.companyName}</h3>
|
||||||
@ -327,20 +361,26 @@ export default function SearchResultsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-3xl font-bold text-blue-600">{formatPrice(result.priceBreakdown.totalPriceForSorting)}</p>
|
<p className="text-3xl font-bold text-blue-600">
|
||||||
|
{formatPrice(result.priceBreakdown.totalPriceForSorting)}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-gray-500">{t('totalPrice')}</p>
|
<p className="text-sm text-gray-500">{t('totalPrice')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
<p className="text-xs text-gray-600 mb-1">Fret ({result.priceBreakdown.freightCurrency})</p>
|
<p className="text-xs text-gray-600 mb-1">
|
||||||
|
Fret ({result.priceBreakdown.freightCurrency})
|
||||||
|
</p>
|
||||||
<p className="font-semibold text-gray-900">
|
<p className="font-semibold text-gray-900">
|
||||||
{formatPrice(result.priceBreakdown.totalFreight)}
|
{formatPrice(result.priceBreakdown.totalFreight)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
<p className="text-xs text-gray-600 mb-1">FOB ({result.priceBreakdown.fobCurrency})</p>
|
<p className="text-xs text-gray-600 mb-1">
|
||||||
|
FOB ({result.priceBreakdown.fobCurrency})
|
||||||
|
</p>
|
||||||
<p className="font-semibold text-gray-900">
|
<p className="font-semibold text-gray-900">
|
||||||
{formatPrice(result.priceBreakdown.totalFob)}
|
{formatPrice(result.priceBreakdown.totalFob)}
|
||||||
</p>
|
</p>
|
||||||
@ -359,7 +399,11 @@ export default function SearchResultsPage() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
||||||
<span>{t('validUntil', { date: new Date(result.validUntil).toLocaleDateString(dateLocale) })}</span>
|
<span>
|
||||||
|
{t('validUntil', {
|
||||||
|
date: new Date(result.validUntil).toLocaleDateString(dateLocale),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
{result.dgSurchargeStatus === 'not_accepted' && (
|
{result.dgSurchargeStatus === 'not_accepted' && (
|
||||||
<span className="text-orange-600 flex items-center">
|
<span className="text-orange-600 flex items-center">
|
||||||
<AlertTriangle className="h-4 w-4 mr-1" /> DG non accepté
|
<AlertTriangle className="h-4 w-4 mr-1" /> DG non accepté
|
||||||
@ -374,7 +418,9 @@ export default function SearchResultsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const rateData = encodeURIComponent(JSON.stringify(result));
|
const rateData = encodeURIComponent(JSON.stringify(result));
|
||||||
router.push(`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`);
|
router.push(
|
||||||
|
`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -136,7 +136,9 @@ export default function RateSearchPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Origin Port */}
|
{/* Origin Port */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Port d'origine *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Port d'origine *
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -317,7 +319,9 @@ export default function RateSearchPage() {
|
|||||||
{/* Error */}
|
{/* Error */}
|
||||||
{searchError && (
|
{searchError && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||||
<div className="text-sm text-red-800">La recherche de tarifs a échoué. Veuillez réessayer.</div>
|
<div className="text-sm text-red-800">
|
||||||
|
La recherche de tarifs a échoué. Veuillez réessayer.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -341,7 +345,9 @@ export default function RateSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Fourchette de prix (USD)</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-3">
|
||||||
|
Fourchette de prix (USD)
|
||||||
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@ -400,7 +406,9 @@ export default function RateSearchPage() {
|
|||||||
<div className="lg:col-span-3 space-y-4">
|
<div className="lg:col-span-3 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
{filteredAndSortedQuotes.length} tarif{filteredAndSortedQuotes.length !== 1 ? 's' : ''} trouvé{filteredAndSortedQuotes.length !== 1 ? 's' : ''}
|
{filteredAndSortedQuotes.length} tarif
|
||||||
|
{filteredAndSortedQuotes.length !== 1 ? 's' : ''} trouvé
|
||||||
|
{filteredAndSortedQuotes.length !== 1 ? 's' : ''}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -592,9 +600,12 @@ export default function RateSearchPage() {
|
|||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 className="mt-4 text-lg font-medium text-gray-900">Rechercher des tarifs maritimes</h3>
|
<h3 className="mt-4 text-lg font-medium text-gray-900">
|
||||||
|
Rechercher des tarifs maritimes
|
||||||
|
</h3>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
Saisissez votre origine, destination et détails du conteneur pour comparer les tarifs de plusieurs transporteurs
|
Saisissez votre origine, destination et détails du conteneur pour comparer les tarifs de
|
||||||
|
plusieurs transporteurs
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -169,8 +169,7 @@ function CreateKeyModal({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
{t('expiry')}{' '}
|
{t('expiry')} <span className="text-gray-400 font-normal">{t('optional')}</span>
|
||||||
<span className="text-gray-400 font-normal">{t('optional')}</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@ -346,9 +345,7 @@ export default function ApiKeysPage() {
|
|||||||
onClose={() => setShowCreateModal(false)}
|
onClose={() => setShowCreateModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{createdKey && (
|
{createdKey && <CreatedKeyModal result={createdKey} onClose={() => setCreatedKey(null)} />}
|
||||||
<CreatedKeyModal result={createdKey} onClose={() => setCreatedKey(null)} />
|
|
||||||
)}
|
|
||||||
{revokeTarget && (
|
{revokeTarget && (
|
||||||
<RevokeConfirmModal
|
<RevokeConfirmModal
|
||||||
apiKey={revokeTarget}
|
apiKey={revokeTarget}
|
||||||
|
|||||||
@ -174,7 +174,12 @@ export default function OrganizationSettingsPage() {
|
|||||||
label: t('tabs.information'),
|
label: t('tabs.information'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -183,31 +188,53 @@ export default function OrganizationSettingsPage() {
|
|||||||
label: t('tabs.address'),
|
label: t('tabs.address'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
<path
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(canViewBilling ? [
|
...(canViewBilling
|
||||||
{
|
? [
|
||||||
id: 'subscription' as TabType,
|
{
|
||||||
label: t('tabs.subscription'),
|
id: 'subscription' as TabType,
|
||||||
icon: (
|
label: t('tabs.subscription'),
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
icon: (
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path
|
||||||
),
|
strokeLinecap="round"
|
||||||
},
|
strokeLinejoin="round"
|
||||||
{
|
strokeWidth={2}
|
||||||
id: 'licenses' as TabType,
|
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||||
label: t('tabs.licenses'),
|
/>
|
||||||
icon: (
|
</svg>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
),
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
},
|
||||||
</svg>
|
{
|
||||||
),
|
id: 'licenses' as TabType,
|
||||||
},
|
label: t('tabs.licenses'),
|
||||||
] : []),
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -220,8 +247,18 @@ export default function OrganizationSettingsPage() {
|
|||||||
{successMessage && (activeTab === 'information' || activeTab === 'address') && (
|
{successMessage && (activeTab === 'information' || activeTab === 'address') && (
|
||||||
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
|
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg className="w-5 h-5 text-green-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
className="w-5 h-5 text-green-600 mr-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-green-800 font-medium">{successMessage}</p>
|
<p className="text-green-800 font-medium">{successMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -231,8 +268,18 @@ export default function OrganizationSettingsPage() {
|
|||||||
{error && (activeTab === 'information' || activeTab === 'address') && (
|
{error && (activeTab === 'information' || activeTab === 'address') && (
|
||||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg className="w-5 h-5 text-red-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
className="w-5 h-5 text-red-600 mr-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-red-800 font-medium">{error}</p>
|
<p className="text-red-800 font-medium">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -242,8 +289,18 @@ export default function OrganizationSettingsPage() {
|
|||||||
{!canEdit && (activeTab === 'information' || activeTab === 'address') && (
|
{!canEdit && (activeTab === 'information' || activeTab === 'address') && (
|
||||||
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg className="w-5 h-5 text-blue-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="w-5 h-5 text-blue-600 mr-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-blue-800 font-medium">{t('readOnlyWarning')}</p>
|
<p className="text-blue-800 font-medium">{t('readOnlyWarning')}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -253,7 +310,7 @@ export default function OrganizationSettingsPage() {
|
|||||||
<div className="bg-white rounded-lg shadow-md">
|
<div className="bg-white rounded-lg shadow-md">
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<nav className="flex -mb-px overflow-x-auto">
|
<nav className="flex -mb-px overflow-x-auto">
|
||||||
{tabs.map((tab) => (
|
{tabs.map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
@ -298,7 +355,9 @@ export default function OrganizationSettingsPage() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.siren}
|
value={formData.siren}
|
||||||
onChange={e => handleChange('siren', e.target.value.replace(/\D/g, '').slice(0, 9))}
|
onChange={e =>
|
||||||
|
handleChange('siren', e.target.value.replace(/\D/g, '').slice(0, 9))
|
||||||
|
}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||||
placeholder={t('information.sirenPlaceholder')}
|
placeholder={t('information.sirenPlaceholder')}
|
||||||
@ -325,7 +384,9 @@ export default function OrganizationSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('information.phone')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('information.phone')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={formData.contact_phone}
|
value={formData.contact_phone}
|
||||||
@ -337,7 +398,9 @@ export default function OrganizationSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('information.email')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('information.email')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.contact_email}
|
value={formData.contact_email}
|
||||||
|
|||||||
@ -199,7 +199,10 @@ export default function UsersManagementPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRoleChange = (userId: string, newRole: string) => {
|
const handleRoleChange = (userId: string, newRole: string) => {
|
||||||
changeRoleMutation.mutate({ id: userId, role: newRole as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' });
|
changeRoleMutation.mutate({
|
||||||
|
id: userId,
|
||||||
|
role: newRole as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER',
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleActive = (userId: string, isActive: boolean) => {
|
const handleToggleActive = (userId: string, isActive: boolean) => {
|
||||||
@ -235,7 +238,10 @@ export default function UsersManagementPage() {
|
|||||||
const pagedUsers = allUsers.slice((usersPage - 1) * PAGE_SIZE, usersPage * PAGE_SIZE);
|
const pagedUsers = allUsers.slice((usersPage - 1) * PAGE_SIZE, usersPage * PAGE_SIZE);
|
||||||
|
|
||||||
const allPending = (pendingInvitations || []).filter(inv => !inv.isUsed);
|
const allPending = (pendingInvitations || []).filter(inv => !inv.isUsed);
|
||||||
const pagedInvitations = allPending.slice((invitationsPage - 1) * PAGE_SIZE, invitationsPage * PAGE_SIZE);
|
const pagedInvitations = allPending.slice(
|
||||||
|
(invitationsPage - 1) * PAGE_SIZE,
|
||||||
|
invitationsPage * PAGE_SIZE
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -244,16 +250,26 @@ export default function UsersManagementPage() {
|
|||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<svg className="h-5 w-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-5 w-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 flex-1">
|
<div className="ml-3 flex-1">
|
||||||
<h3 className="text-sm font-medium text-amber-800">{t('license.limitTitle')}</h3>
|
<h3 className="text-sm font-medium text-amber-800">{t('license.limitTitle')}</h3>
|
||||||
<p className="mt-1 text-sm text-amber-700">
|
<p className="mt-1 text-sm text-amber-700">
|
||||||
{t('license.limitMessage', { used: licenseStatus.usedLicenses, max: licenseStatus.maxLicenses })}
|
{t('license.limitMessage', {
|
||||||
|
used: licenseStatus.usedLicenses,
|
||||||
|
max: licenseStatus.maxLicenses,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-amber-800 hover:text-amber-900 underline">
|
<Link
|
||||||
|
href="/dashboard/settings/subscription"
|
||||||
|
className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"
|
||||||
|
>
|
||||||
{t('license.upgradeLink')}
|
{t('license.upgradeLink')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -262,27 +278,37 @@ export default function UsersManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{licenseStatus && licenseStatus.canInvite && licenseStatus.availableLicenses <= 2 && licenseStatus.maxLicenses !== -1 && (
|
{licenseStatus &&
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
licenseStatus.canInvite &&
|
||||||
<div className="flex items-center justify-between">
|
licenseStatus.availableLicenses <= 2 &&
|
||||||
<div className="flex items-center">
|
licenseStatus.maxLicenses !== -1 && (
|
||||||
<svg className="h-5 w-5 text-blue-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
<div className="flex items-center justify-between">
|
||||||
</svg>
|
<div className="flex items-center">
|
||||||
<span className="text-sm text-blue-800">
|
<svg className="h-5 w-5 text-blue-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
{t('license.remaining', {
|
<path
|
||||||
count: licenseStatus.availableLicenses,
|
fillRule="evenodd"
|
||||||
used: licenseStatus.usedLicenses,
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
max: licenseStatus.maxLicenses,
|
clipRule="evenodd"
|
||||||
})}
|
/>
|
||||||
</span>
|
</svg>
|
||||||
|
<span className="text-sm text-blue-800">
|
||||||
|
{t('license.remaining', {
|
||||||
|
count: licenseStatus.availableLicenses,
|
||||||
|
used: licenseStatus.usedLicenses,
|
||||||
|
max: licenseStatus.maxLicenses,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/subscription"
|
||||||
|
className="text-sm font-medium text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
{t('license.manageLink')}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-blue-600 hover:text-blue-800">
|
|
||||||
{t('license.manageLink')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('header.title')}
|
title={t('header.title')}
|
||||||
@ -296,9 +322,21 @@ export default function UsersManagementPage() {
|
|||||||
{ key: 'firstName', label: t('export.firstName') },
|
{ key: 'firstName', label: t('export.firstName') },
|
||||||
{ key: 'lastName', label: t('export.lastName') },
|
{ key: 'lastName', label: t('export.lastName') },
|
||||||
{ key: 'email', label: t('export.email') },
|
{ key: 'email', label: t('export.email') },
|
||||||
{ key: 'role', label: t('export.role'), format: (v) => t(`modal.rolesExport.${v}` as any) || v },
|
{
|
||||||
{ key: 'isActive', label: t('export.status'), format: (v) => v ? t('users.active') : t('users.inactive') },
|
key: 'role',
|
||||||
{ key: 'createdAt', label: t('export.createdAt'), format: (v) => v ? new Date(v).toLocaleDateString(dateLocale) : '' },
|
label: t('export.role'),
|
||||||
|
format: v => t(`modal.rolesExport.${v}` as any) || v,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isActive',
|
||||||
|
label: t('export.status'),
|
||||||
|
format: v => (v ? t('users.active') : t('users.inactive')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
label: t('export.createdAt'),
|
||||||
|
format: v => (v ? new Date(v).toLocaleDateString(dateLocale) : ''),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{licenseStatus?.canInvite ? (
|
{licenseStatus?.canInvite ? (
|
||||||
@ -340,7 +378,9 @@ export default function UsersManagementPage() {
|
|||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<h2 className="text-lg font-medium text-gray-900">{t('users.title')}</h2>
|
<h2 className="text-lg font-medium text-gray-900">{t('users.title')}</h2>
|
||||||
{allUsers.length > 0 && (
|
{allUsers.length > 0 && (
|
||||||
<p className="text-sm text-gray-500 mt-1">{t('users.membersCount', { count: allUsers.length })}</p>
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{t('users.membersCount', { count: allUsers.length })}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@ -354,12 +394,24 @@ export default function UsersManagementPage() {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.user')}</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.email')}</th>
|
{t('users.table.user')}
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.role')}</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.status')}</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.createdAt')}</th>
|
{t('users.table.email')}
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.actions')}</th>
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('users.table.role')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('users.table.status')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('users.table.createdAt')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('users.table.actions')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
@ -368,10 +420,13 @@ export default function UsersManagementPage() {
|
|||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0 h-10 w-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-semibold">
|
<div className="flex-shrink-0 h-10 w-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-semibold">
|
||||||
{user.firstName[0]}{user.lastName[0]}
|
{user.firstName[0]}
|
||||||
|
{user.lastName[0]}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<div className="text-sm font-medium text-gray-900">{user.firstName} {user.lastName}</div>
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{user.firstName} {user.lastName}
|
||||||
|
</div>
|
||||||
<div className="text-sm text-gray-500">{user.email}</div>
|
<div className="text-sm text-gray-500">{user.email}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -390,14 +445,18 @@ export default function UsersManagementPage() {
|
|||||||
user.id === currentUser?.id
|
user.id === currentUser?.id
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{currentUser?.role === 'ADMIN' && <option value="ADMIN">{t('modal.roles.ADMIN')}</option>}
|
{currentUser?.role === 'ADMIN' && (
|
||||||
|
<option value="ADMIN">{t('modal.roles.ADMIN')}</option>
|
||||||
|
)}
|
||||||
<option value="MANAGER">{t('modal.roles.MANAGER')}</option>
|
<option value="MANAGER">{t('modal.roles.MANAGER')}</option>
|
||||||
<option value="USER">{t('modal.roles.USER')}</option>
|
<option value="USER">{t('modal.roles.USER')}</option>
|
||||||
<option value="VIEWER">{t('modal.roles.VIEWER')}</option>
|
<option value="VIEWER">{t('modal.roles.VIEWER')}</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
<span
|
||||||
|
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}
|
||||||
|
>
|
||||||
{user.isActive ? t('users.active') : t('users.inactive')}
|
{user.isActive ? t('users.active') : t('users.inactive')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@ -406,7 +465,7 @@ export default function UsersManagementPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={e => {
|
||||||
if (openMenuId === user.id) {
|
if (openMenuId === user.id) {
|
||||||
setOpenMenuId(null);
|
setOpenMenuId(null);
|
||||||
setMenuPosition(null);
|
setMenuPosition(null);
|
||||||
@ -418,7 +477,11 @@ export default function UsersManagementPage() {
|
|||||||
}}
|
}}
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-600"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@ -428,23 +491,46 @@ export default function UsersManagementPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<Pagination page={usersPage} total={allUsers.length} onPage={p => { setUsersPage(p); setOpenMenuId(null); }} />
|
<Pagination
|
||||||
|
page={usersPage}
|
||||||
|
total={allUsers.length}
|
||||||
|
onPage={p => {
|
||||||
|
setUsersPage(p);
|
||||||
|
setOpenMenuId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-6 py-12 text-center">
|
<div className="px-6 py-12 text-center">
|
||||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
className="mx-auto h-12 w-12 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('users.empty.title')}</h3>
|
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('users.empty.title')}</h3>
|
||||||
<p className="mt-1 text-sm text-gray-500">{t('users.empty.description')}</p>
|
<p className="mt-1 text-sm text-gray-500">{t('users.empty.description')}</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{licenseStatus?.canInvite ? (
|
{licenseStatus?.canInvite ? (
|
||||||
<button onClick={() => setShowInviteModal(true)} className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
<button
|
||||||
|
onClick={() => setShowInviteModal(true)}
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
<span className="mr-2">+</span>
|
<span className="mr-2">+</span>
|
||||||
{t('actions.invite')}
|
{t('actions.invite')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Link href="/dashboard/settings/subscription" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700">
|
<Link
|
||||||
|
href="/dashboard/settings/subscription"
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
|
||||||
|
>
|
||||||
<span className="mr-2">+</span>
|
<span className="mr-2">+</span>
|
||||||
{t('actions.upgrade')}
|
{t('actions.upgrade')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -466,12 +552,24 @@ export default function UsersManagementPage() {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.user')}</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.email')}</th>
|
{t('invitations.table.user')}
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.role')}</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.expires')}</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.status')}</th>
|
{t('invitations.table.email')}
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.actions')}</th>
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('invitations.table.role')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('invitations.table.expires')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('invitations.table.status')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('invitations.table.actions')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
@ -482,16 +580,23 @@ export default function UsersManagementPage() {
|
|||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0 h-10 w-10 bg-gray-200 rounded-full flex items-center justify-center text-gray-500 font-semibold">
|
<div className="flex-shrink-0 h-10 w-10 bg-gray-200 rounded-full flex items-center justify-center text-gray-500 font-semibold">
|
||||||
{inv.firstName[0]}{inv.lastName[0]}
|
{inv.firstName[0]}
|
||||||
|
{inv.lastName[0]}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<div className="text-sm font-medium text-gray-900">{inv.firstName} {inv.lastName}</div>
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{inv.firstName} {inv.lastName}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{inv.email}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{inv.email}
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getRoleBadgeColor(inv.role)}`}>
|
<span
|
||||||
|
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getRoleBadgeColor(inv.role)}`}
|
||||||
|
>
|
||||||
{inv.role}
|
{inv.role}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@ -499,18 +604,32 @@ export default function UsersManagementPage() {
|
|||||||
{new Date(inv.expiresAt).toLocaleDateString(dateLocale)}
|
{new Date(inv.expiresAt).toLocaleDateString(dateLocale)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${isExpired ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'}`}>
|
<span
|
||||||
|
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${isExpired ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'}`}
|
||||||
|
>
|
||||||
{isExpired ? t('invitations.expired') : t('invitations.pending')}
|
{isExpired ? t('invitations.expired') : t('invitations.pending')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCancelInvitation(inv.id, `${inv.firstName} ${inv.lastName}`)}
|
onClick={() =>
|
||||||
|
handleCancelInvitation(inv.id, `${inv.firstName} ${inv.lastName}`)
|
||||||
|
}
|
||||||
disabled={cancelInvitationMutation.isPending}
|
disabled={cancelInvitationMutation.isPending}
|
||||||
className="inline-flex items-center px-3 py-1 text-xs font-medium text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors disabled:opacity-50"
|
className="inline-flex items-center px-3 py-1 text-xs font-medium text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
className="w-4 h-4 mr-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('invitations.cancel')}
|
{t('invitations.cancel')}
|
||||||
</button>
|
</button>
|
||||||
@ -521,7 +640,11 @@ export default function UsersManagementPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<Pagination page={invitationsPage} total={allPending.length} onPage={setInvitationsPage} />
|
<Pagination
|
||||||
|
page={invitationsPage}
|
||||||
|
total={allPending.length}
|
||||||
|
onPage={setInvitationsPage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -529,7 +652,10 @@ export default function UsersManagementPage() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[998]"
|
className="fixed inset-0 z-[998]"
|
||||||
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
|
onClick={() => {
|
||||||
|
setOpenMenuId(null);
|
||||||
|
setMenuPosition(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
|
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
|
||||||
@ -548,17 +674,41 @@ export default function UsersManagementPage() {
|
|||||||
>
|
>
|
||||||
{users?.users.find(u => u.id === openMenuId)?.isActive ? (
|
{users?.users.find(u => u.id === openMenuId)?.isActive ? (
|
||||||
<>
|
<>
|
||||||
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
className="w-5 h-5 text-orange-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-medium text-gray-700">{t('users.actions.deactivate')}</span>
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
{t('users.actions.deactivate')}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="w-5 h-5 text-green-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-medium text-gray-700">{t('users.actions.activate')}</span>
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
{t('users.actions.activate')}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -571,10 +721,22 @@ export default function UsersManagementPage() {
|
|||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
|
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
className="w-5 h-5 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-medium text-red-600">{t('users.actions.delete')}</span>
|
<span className="text-sm font-medium text-red-600">
|
||||||
|
{t('users.actions.delete')}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -584,21 +746,34 @@ export default function UsersManagementPage() {
|
|||||||
{showInviteModal && (
|
{showInviteModal && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" onClick={() => setShowInviteModal(false)} />
|
<div
|
||||||
|
className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
|
||||||
|
onClick={() => setShowInviteModal(false)}
|
||||||
|
/>
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900">{t('modal.title')}</h3>
|
<h3 className="text-lg font-medium text-gray-900">{t('modal.title')}</h3>
|
||||||
<button onClick={() => setShowInviteModal(false)} className="text-gray-400 hover:text-gray-500">
|
<button
|
||||||
|
onClick={() => setShowInviteModal(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-500"
|
||||||
|
>
|
||||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleInvite} className="space-y-4">
|
<form onSubmit={handleInvite} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')} *</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.firstName')} *
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -608,7 +783,9 @@ export default function UsersManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')} *</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.lastName')} *
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -619,7 +796,9 @@ export default function UsersManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.email')} *</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.email')} *
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
@ -629,7 +808,9 @@ export default function UsersManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('modal.role')} *</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.role')} *
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={inviteForm.role}
|
value={inviteForm.role}
|
||||||
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
|
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
|
||||||
|
|||||||
@ -33,16 +33,99 @@ interface SearchHistoryItem {
|
|||||||
type CarrierDescKey = 'containerOrBl' | 'containerBlOrBooking' | 'containerOnly';
|
type CarrierDescKey = 'containerOrBl' | 'containerBlOrBooking' | 'containerOnly';
|
||||||
|
|
||||||
const carriers = [
|
const carriers = [
|
||||||
{ id: 'maersk', name: 'Maersk', color: '#00243D', textColor: 'text-white', trackingUrl: 'https://www.maersk.com/tracking/', placeholder: 'Ex: MSKU1234567', descKey: 'containerOrBl' as CarrierDescKey },
|
{
|
||||||
{ id: 'msc', name: 'MSC', color: '#002B5C', textColor: 'text-white', trackingUrl: 'https://www.msc.com/track-a-shipment?query=', placeholder: 'Ex: MSCU1234567', descKey: 'containerBlOrBooking' as CarrierDescKey },
|
id: 'maersk',
|
||||||
{ id: 'cma-cgm', name: 'CMA CGM', color: '#E30613', textColor: 'text-white', trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=', placeholder: 'Ex: CMAU1234567', descKey: 'containerOrBl' as CarrierDescKey },
|
name: 'Maersk',
|
||||||
{ id: 'hapag-lloyd', name: 'Hapag-Lloyd', color: '#FF6600', textColor: 'text-white', trackingUrl: 'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=', placeholder: 'Ex: HLCU1234567', descKey: 'containerOnly' as CarrierDescKey },
|
color: '#00243D',
|
||||||
{ id: 'cosco', name: 'COSCO', color: '#003A70', textColor: 'text-white', trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=', placeholder: 'Ex: COSU1234567', descKey: 'containerOrBl' as CarrierDescKey },
|
textColor: 'text-white',
|
||||||
{ id: 'one', name: 'ONE', color: '#FF00FF', textColor: 'text-white', trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=', placeholder: 'Ex: ONEU1234567', descKey: 'containerOrBl' as CarrierDescKey },
|
trackingUrl: 'https://www.maersk.com/tracking/',
|
||||||
{ id: 'evergreen', name: 'Evergreen', color: '#006633', textColor: 'text-white', trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=', placeholder: 'Ex: EGHU1234567', descKey: 'containerOrBl' as CarrierDescKey },
|
placeholder: 'Ex: MSKU1234567',
|
||||||
{ id: 'yangming', name: 'Yang Ming', color: '#FFD700', textColor: 'text-gray-900', trackingUrl: 'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=', placeholder: 'Ex: YMLU1234567', descKey: 'containerOnly' as CarrierDescKey },
|
descKey: 'containerOrBl' as CarrierDescKey,
|
||||||
{ id: 'zim', name: 'ZIM', color: '#1E3A8A', textColor: 'text-white', trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=', placeholder: 'Ex: ZIMU1234567', descKey: 'containerOrBl' as CarrierDescKey },
|
},
|
||||||
{ id: 'hmm', name: 'HMM', color: '#E65100', textColor: 'text-white', trackingUrl: 'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=', placeholder: 'Ex: HDMU1234567', descKey: 'containerOrBl' as CarrierDescKey },
|
{
|
||||||
|
id: 'msc',
|
||||||
|
name: 'MSC',
|
||||||
|
color: '#002B5C',
|
||||||
|
textColor: 'text-white',
|
||||||
|
trackingUrl: 'https://www.msc.com/track-a-shipment?query=',
|
||||||
|
placeholder: 'Ex: MSCU1234567',
|
||||||
|
descKey: 'containerBlOrBooking' as CarrierDescKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cma-cgm',
|
||||||
|
name: 'CMA CGM',
|
||||||
|
color: '#E30613',
|
||||||
|
textColor: 'text-white',
|
||||||
|
trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=',
|
||||||
|
placeholder: 'Ex: CMAU1234567',
|
||||||
|
descKey: 'containerOrBl' as CarrierDescKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hapag-lloyd',
|
||||||
|
name: 'Hapag-Lloyd',
|
||||||
|
color: '#FF6600',
|
||||||
|
textColor: 'text-white',
|
||||||
|
trackingUrl:
|
||||||
|
'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=',
|
||||||
|
placeholder: 'Ex: HLCU1234567',
|
||||||
|
descKey: 'containerOnly' as CarrierDescKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cosco',
|
||||||
|
name: 'COSCO',
|
||||||
|
color: '#003A70',
|
||||||
|
textColor: 'text-white',
|
||||||
|
trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=',
|
||||||
|
placeholder: 'Ex: COSU1234567',
|
||||||
|
descKey: 'containerOrBl' as CarrierDescKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'one',
|
||||||
|
name: 'ONE',
|
||||||
|
color: '#FF00FF',
|
||||||
|
textColor: 'text-white',
|
||||||
|
trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=',
|
||||||
|
placeholder: 'Ex: ONEU1234567',
|
||||||
|
descKey: 'containerOrBl' as CarrierDescKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'evergreen',
|
||||||
|
name: 'Evergreen',
|
||||||
|
color: '#006633',
|
||||||
|
textColor: 'text-white',
|
||||||
|
trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=',
|
||||||
|
placeholder: 'Ex: EGHU1234567',
|
||||||
|
descKey: 'containerOrBl' as CarrierDescKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'yangming',
|
||||||
|
name: 'Yang Ming',
|
||||||
|
color: '#FFD700',
|
||||||
|
textColor: 'text-gray-900',
|
||||||
|
trackingUrl:
|
||||||
|
'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=',
|
||||||
|
placeholder: 'Ex: YMLU1234567',
|
||||||
|
descKey: 'containerOnly' as CarrierDescKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zim',
|
||||||
|
name: 'ZIM',
|
||||||
|
color: '#1E3A8A',
|
||||||
|
textColor: 'text-white',
|
||||||
|
trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=',
|
||||||
|
placeholder: 'Ex: ZIMU1234567',
|
||||||
|
descKey: 'containerOrBl' as CarrierDescKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hmm',
|
||||||
|
name: 'HMM',
|
||||||
|
color: '#E65100',
|
||||||
|
textColor: 'text-white',
|
||||||
|
trackingUrl:
|
||||||
|
'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=',
|
||||||
|
placeholder: 'Ex: HDMU1234567',
|
||||||
|
descKey: 'containerOrBl' as CarrierDescKey,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const HISTORY_KEY = 'xpeditis_track_history';
|
const HISTORY_KEY = 'xpeditis_track_history';
|
||||||
@ -63,10 +146,12 @@ export default function TrackTracePage() {
|
|||||||
if (savedHistory) {
|
if (savedHistory) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(savedHistory);
|
const parsed = JSON.parse(savedHistory);
|
||||||
setSearchHistory(parsed.map((item: any) => ({
|
setSearchHistory(
|
||||||
...item,
|
parsed.map((item: any) => ({
|
||||||
timestamp: new Date(item.timestamp)
|
...item,
|
||||||
})));
|
timestamp: new Date(item.timestamp),
|
||||||
|
}))
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse search history:', e);
|
console.error('Failed to parse search history:', e);
|
||||||
}
|
}
|
||||||
@ -100,9 +185,16 @@ export default function TrackTracePage() {
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedHistory = [newHistoryItem, ...searchHistory.filter(
|
const updatedHistory = [
|
||||||
h => !(h.trackingNumber === newHistoryItem.trackingNumber && h.carrierId === newHistoryItem.carrierId)
|
newHistoryItem,
|
||||||
)].slice(0, 10);
|
...searchHistory.filter(
|
||||||
|
h =>
|
||||||
|
!(
|
||||||
|
h.trackingNumber === newHistoryItem.trackingNumber &&
|
||||||
|
h.carrierId === newHistoryItem.carrierId
|
||||||
|
)
|
||||||
|
),
|
||||||
|
].slice(0, 10);
|
||||||
|
|
||||||
saveHistory(updatedHistory);
|
saveHistory(updatedHistory);
|
||||||
|
|
||||||
@ -189,9 +281,13 @@ export default function TrackTracePage() {
|
|||||||
className={`w-12 h-12 rounded-lg flex items-center justify-center text-sm font-bold mb-2 shadow-sm ${carrier.textColor}`}
|
className={`w-12 h-12 rounded-lg flex items-center justify-center text-sm font-bold mb-2 shadow-sm ${carrier.textColor}`}
|
||||||
style={{ backgroundColor: carrier.color }}
|
style={{ backgroundColor: carrier.color }}
|
||||||
>
|
>
|
||||||
{carrier.name.length <= 3 ? carrier.name : carrier.name.substring(0, 2).toUpperCase()}
|
{carrier.name.length <= 3
|
||||||
|
? carrier.name
|
||||||
|
: carrier.name.substring(0, 2).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-semibold text-gray-800 text-center leading-tight">{carrier.name}</span>
|
<span className="text-xs font-semibold text-gray-800 text-center leading-tight">
|
||||||
|
{carrier.name}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -199,7 +295,10 @@ export default function TrackTracePage() {
|
|||||||
|
|
||||||
{/* Tracking Number Input */}
|
{/* Tracking Number Input */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="tracking-number" className="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
htmlFor="tracking-number"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
{t('searchCard.trackingNumber')}
|
{t('searchCard.trackingNumber')}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@ -236,14 +335,15 @@ export default function TrackTracePage() {
|
|||||||
{/* Map Toggle */}
|
{/* Map Toggle */}
|
||||||
<div className="flex flex-wrap gap-3 pt-2">
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
<Button
|
<Button
|
||||||
variant={showMap ? "default" : "outline"}
|
variant={showMap ? 'default' : 'outline'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowMap(!showMap);
|
setShowMap(!showMap);
|
||||||
if (!showMap) setIsMapLoading(true);
|
if (!showMap) setIsMapLoading(true);
|
||||||
}}
|
}}
|
||||||
className={showMap
|
className={
|
||||||
? "bg-blue-600 hover:bg-blue-700 text-white"
|
showMap
|
||||||
: "text-gray-700 border-gray-300 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700"
|
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||||
|
: 'text-gray-700 border-gray-300 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Globe className="mr-2 h-4 w-4" />
|
<Globe className="mr-2 h-4 w-4" />
|
||||||
@ -263,9 +363,13 @@ export default function TrackTracePage() {
|
|||||||
{/* Vessel Position Map */}
|
{/* Vessel Position Map */}
|
||||||
{showMap && (
|
{showMap && (
|
||||||
<div className={`${isMapFullscreen ? 'fixed inset-0 z-50 bg-gray-900' : ''}`}>
|
<div className={`${isMapFullscreen ? 'fixed inset-0 z-50 bg-gray-900' : ''}`}>
|
||||||
<Card className={`bg-white shadow-xl overflow-hidden ${isMapFullscreen ? 'h-full rounded-none' : ''}`}>
|
<Card
|
||||||
|
className={`bg-white shadow-xl overflow-hidden ${isMapFullscreen ? 'h-full rounded-none' : ''}`}
|
||||||
|
>
|
||||||
{/* Map Header */}
|
{/* Map Header */}
|
||||||
<div className={`flex items-center justify-between px-6 py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white ${isMapFullscreen ? '' : 'rounded-t-lg'}`}>
|
<div
|
||||||
|
className={`flex items-center justify-between px-6 py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white ${isMapFullscreen ? '' : 'rounded-t-lg'}`}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-white/20 rounded-lg">
|
<div className="p-2 bg-white/20 rounded-lg">
|
||||||
<Globe className="h-6 w-6" />
|
<Globe className="h-6 w-6" />
|
||||||
@ -309,7 +413,9 @@ export default function TrackTracePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map Container */}
|
{/* Map Container */}
|
||||||
<div className={`relative w-full ${isMapFullscreen ? 'h-[calc(100vh-80px)]' : 'h-[70vh] min-h-[500px] max-h-[800px]'}`}>
|
<div
|
||||||
|
className={`relative w-full ${isMapFullscreen ? 'h-[calc(100vh-80px)]' : 'h-[70vh] min-h-[500px] max-h-[800px]'}`}
|
||||||
|
>
|
||||||
{isMapLoading && (
|
{isMapLoading && (
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center z-10">
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center z-10">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@ -317,9 +423,18 @@ export default function TrackTracePage() {
|
|||||||
<Ship className="h-16 w-16 text-blue-600 animate-pulse" />
|
<Ship className="h-16 w-16 text-blue-600 animate-pulse" />
|
||||||
<div className="absolute -bottom-1 left-1/2 transform -translate-x-1/2">
|
<div className="absolute -bottom-1 left-1/2 transform -translate-x-1/2">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
<div
|
||||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"
|
||||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
style={{ animationDelay: '0ms' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"
|
||||||
|
style={{ animationDelay: '150ms' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"
|
||||||
|
style={{ animationDelay: '300ms' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -338,7 +453,9 @@ export default function TrackTracePage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Map Legend Overlay */}
|
{/* Map Legend Overlay */}
|
||||||
<div className={`absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? 'max-w-xs' : 'max-w-[280px]'}`}>
|
<div
|
||||||
|
className={`absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? 'max-w-xs' : 'max-w-[280px]'}`}
|
||||||
|
>
|
||||||
<h4 className="font-semibold text-gray-800 text-sm mb-3 flex items-center gap-2">
|
<h4 className="font-semibold text-gray-800 text-sm mb-3 flex items-center gap-2">
|
||||||
<Anchor className="h-4 w-4 text-blue-600" />
|
<Anchor className="h-4 w-4 text-blue-600" />
|
||||||
{t('map.legend')}
|
{t('map.legend')}
|
||||||
@ -364,7 +481,9 @@ export default function TrackTracePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats Overlay */}
|
{/* Quick Stats Overlay */}
|
||||||
<div className={`absolute top-4 right-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? '' : 'hidden lg:block'}`}>
|
<div
|
||||||
|
className={`absolute top-4 right-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? '' : 'hidden lg:block'}`}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-2xl font-bold text-blue-600">90K+</p>
|
<p className="text-2xl font-bold text-blue-600">90K+</p>
|
||||||
@ -444,12 +563,16 @@ export default function TrackTracePage() {
|
|||||||
{item.carrierName.substring(0, 2).toUpperCase()}
|
{item.carrierName.substring(0, 2).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-mono text-sm font-medium text-gray-900">{item.trackingNumber}</p>
|
<p className="font-mono text-sm font-medium text-gray-900">
|
||||||
<p className="text-xs text-gray-500">{item.carrierName} • {formatTimeAgo(item.timestamp)}</p>
|
{item.trackingNumber}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{item.carrierName} • {formatTimeAgo(item.timestamp)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDeleteHistory(item.id);
|
handleDeleteHistory(item.id);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -7,20 +7,35 @@ export default async function AssurancePage() {
|
|||||||
const t = await getTranslations('dashboard.wikiPages');
|
const t = await getTranslations('dashboard.wikiPages');
|
||||||
|
|
||||||
const clauses = t.raw('assurance.clauses') as Array<{
|
const clauses = t.raw('assurance.clauses') as Array<{
|
||||||
name: string; level: string; includes: string[]; excludes: string[];
|
name: string;
|
||||||
|
level: string;
|
||||||
|
includes: string[];
|
||||||
|
excludes: string[];
|
||||||
}>;
|
}>;
|
||||||
const extensions = t.raw('assurance.extensions') as Array<{ name: string; description: string }>;
|
const extensions = t.raw('assurance.extensions') as Array<{ name: string; description: string }>;
|
||||||
const processSteps = t.raw('assurance.processSteps') as string[];
|
const processSteps = t.raw('assurance.processSteps') as string[];
|
||||||
|
|
||||||
const clauseColors = ['border-green-500 bg-green-50', 'border-yellow-500 bg-yellow-50', 'border-red-500 bg-red-50'];
|
const clauseColors = [
|
||||||
|
'border-green-500 bg-green-50',
|
||||||
|
'border-yellow-500 bg-yellow-50',
|
||||||
|
'border-red-500 bg-red-50',
|
||||||
|
];
|
||||||
const clauseBadges = ['bg-green-600', 'bg-yellow-600', 'bg-red-600'];
|
const clauseBadges = ['bg-green-600', 'bg-yellow-600', 'bg-red-600'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
|
<Link
|
||||||
|
href="/dashboard/wiki"
|
||||||
|
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backToWiki')}
|
{t('backToWiki')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -42,10 +57,14 @@ export default async function AssurancePage() {
|
|||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-xl font-bold">{clause.name}</h3>
|
<h3 className="text-xl font-bold">{clause.name}</h3>
|
||||||
<span className={`px-2 py-1 rounded text-white text-sm ${clauseBadges[i]}`}>{clause.level}</span>
|
<span className={`px-2 py-1 rounded text-white text-sm ${clauseBadges[i]}`}>
|
||||||
|
{clause.level}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<h4 className="text-sm font-semibold text-green-700 mb-1">{t('includesLabel')}</h4>
|
<h4 className="text-sm font-semibold text-green-700 mb-1">
|
||||||
|
{t('includesLabel')}
|
||||||
|
</h4>
|
||||||
<ul className="text-sm space-y-1">
|
<ul className="text-sm space-y-1">
|
||||||
{clause.includes.map((item, j) => (
|
{clause.includes.map((item, j) => (
|
||||||
<li key={j} className="flex items-start gap-1 text-gray-700">
|
<li key={j} className="flex items-start gap-1 text-gray-700">
|
||||||
@ -73,7 +92,7 @@ export default async function AssurancePage() {
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('assurance.extensionsTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('assurance.extensionsTitle')}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{extensions.map((ext) => (
|
{extensions.map(ext => (
|
||||||
<Card key={ext.name} className="bg-white">
|
<Card key={ext.name} className="bg-white">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<h4 className="font-semibold text-gray-900">{ext.name}</h4>
|
<h4 className="font-semibold text-gray-900">{ext.name}</h4>
|
||||||
@ -98,7 +117,9 @@ export default async function AssurancePage() {
|
|||||||
<Card className="mt-4 bg-blue-50 border-blue-200">
|
<Card className="mt-4 bg-blue-50 border-blue-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-blue-900 mb-2">{t('assurance.valueTitle')}</h3>
|
<h3 className="font-semibold text-blue-900 mb-2">{t('assurance.valueTitle')}</h3>
|
||||||
<p className="font-mono text-blue-800 bg-white p-3 rounded border text-sm">{t('assurance.valueFormula')}</p>
|
<p className="font-mono text-blue-800 bg-white p-3 rounded border text-sm">
|
||||||
|
{t('assurance.valueFormula')}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-blue-700 mt-2">{t('assurance.valueNote')}</p>
|
<p className="text-sm text-blue-700 mt-2">{t('assurance.valueNote')}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -7,19 +7,32 @@ export default async function CalculFretPage() {
|
|||||||
const t = await getTranslations('dashboard.wikiPages');
|
const t = await getTranslations('dashboard.wikiPages');
|
||||||
|
|
||||||
const surcharges = t.raw('calculFret.surcharges') as Array<{
|
const surcharges = t.raw('calculFret.surcharges') as Array<{
|
||||||
code: string; name: string; description: string; variation: string;
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
variation: string;
|
||||||
}>;
|
}>;
|
||||||
const additionalCosts = t.raw('calculFret.additionalCosts') as Array<{
|
const additionalCosts = t.raw('calculFret.additionalCosts') as Array<{
|
||||||
name: string; description: string; typical: string;
|
name: string;
|
||||||
|
description: string;
|
||||||
|
typical: string;
|
||||||
}>;
|
}>;
|
||||||
const exampleItems = t.raw('calculFret.exampleItems') as Array<{ item: string; amount: string }>;
|
const exampleItems = t.raw('calculFret.exampleItems') as Array<{ item: string; amount: string }>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
|
<Link
|
||||||
|
href="/dashboard/wiki"
|
||||||
|
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backToWiki')}
|
{t('backToWiki')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -46,7 +59,7 @@ export default async function CalculFretPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{surcharges.map((s) => (
|
{surcharges.map(s => (
|
||||||
<tr key={s.code} className="border-t hover:bg-gray-50">
|
<tr key={s.code} className="border-t hover:bg-gray-50">
|
||||||
<td className="p-3 font-mono font-bold text-blue-600">{s.code}</td>
|
<td className="p-3 font-mono font-bold text-blue-600">{s.code}</td>
|
||||||
<td className="p-3 font-medium">{s.name}</td>
|
<td className="p-3 font-medium">{s.name}</td>
|
||||||
@ -60,14 +73,18 @@ export default async function CalculFretPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('calculFret.additionalCostsTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
{t('calculFret.additionalCostsTitle')}
|
||||||
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{additionalCosts.map((cost) => (
|
{additionalCosts.map(cost => (
|
||||||
<Card key={cost.name} className="bg-white">
|
<Card key={cost.name} className="bg-white">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<h4 className="font-semibold text-gray-900">{cost.name}</h4>
|
<h4 className="font-semibold text-gray-900">{cost.name}</h4>
|
||||||
<p className="text-sm text-gray-600 mt-1">{cost.description}</p>
|
<p className="text-sm text-gray-600 mt-1">{cost.description}</p>
|
||||||
<p className="text-xs text-gray-400 mt-2">{t('calculFret.colCost')}: {cost.typical}</p>
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
{t('calculFret.colCost')}: {cost.typical}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@ -86,7 +103,10 @@ export default async function CalculFretPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{exampleItems.map((item, i) => (
|
{exampleItems.map((item, i) => (
|
||||||
<tr key={i} className={`border-b last:border-0 ${i === exampleItems.length - 1 ? 'font-bold text-blue-700' : ''}`}>
|
<tr
|
||||||
|
key={i}
|
||||||
|
className={`border-b last:border-0 ${i === exampleItems.length - 1 ? 'font-bold text-blue-700' : ''}`}
|
||||||
|
>
|
||||||
<td className="py-2">{item.item}</td>
|
<td className="py-2">{item.item}</td>
|
||||||
<td className="py-2 text-right font-mono">{item.amount}</td>
|
<td className="py-2 text-right font-mono">{item.amount}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -7,17 +7,36 @@ export default async function ConteneursPage() {
|
|||||||
const t = await getTranslations('dashboard.wikiPages');
|
const t = await getTranslations('dashboard.wikiPages');
|
||||||
|
|
||||||
const containers = t.raw('conteneurs.containers') as Array<{
|
const containers = t.raw('conteneurs.containers') as Array<{
|
||||||
type: string; description: string; internal: string; door: string; volume: string; payload: string;
|
type: string;
|
||||||
|
description: string;
|
||||||
|
internal: string;
|
||||||
|
door: string;
|
||||||
|
volume: string;
|
||||||
|
payload: string;
|
||||||
|
}>;
|
||||||
|
const specialEquipment = t.raw('conteneurs.specialEquipment') as Array<{
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
const selectionGuide = t.raw('conteneurs.selectionGuide') as Array<{
|
||||||
|
condition: string;
|
||||||
|
recommendation: string;
|
||||||
}>;
|
}>;
|
||||||
const specialEquipment = t.raw('conteneurs.specialEquipment') as Array<{ name: string; description: string }>;
|
|
||||||
const selectionGuide = t.raw('conteneurs.selectionGuide') as Array<{ condition: string; recommendation: string }>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
|
<Link
|
||||||
|
href="/dashboard/wiki"
|
||||||
|
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backToWiki')}
|
{t('backToWiki')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -45,7 +64,7 @@ export default async function ConteneursPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{containers.map((c) => (
|
{containers.map(c => (
|
||||||
<tr key={c.type} className="border-t hover:bg-gray-50">
|
<tr key={c.type} className="border-t hover:bg-gray-50">
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<div className="font-semibold text-gray-900">{c.type}</div>
|
<div className="font-semibold text-gray-900">{c.type}</div>
|
||||||
@ -63,9 +82,11 @@ export default async function ConteneursPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('conteneurs.specialEquipmentTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
{t('conteneurs.specialEquipmentTitle')}
|
||||||
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{specialEquipment.map((eq) => (
|
{specialEquipment.map(eq => (
|
||||||
<Card key={eq.name} className="bg-white">
|
<Card key={eq.name} className="bg-white">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<h4 className="font-semibold text-gray-900">{eq.name}</h4>
|
<h4 className="font-semibold text-gray-900">{eq.name}</h4>
|
||||||
@ -82,8 +103,12 @@ export default async function ConteneursPage() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-blue-200">
|
<tr className="border-b border-blue-200">
|
||||||
<th className="text-left py-2 font-medium text-blue-800">{t('conteneurs.colCondition')}</th>
|
<th className="text-left py-2 font-medium text-blue-800">
|
||||||
<th className="text-left py-2 font-medium text-blue-800">{t('conteneurs.colRecommendation')}</th>
|
{t('conteneurs.colCondition')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 font-medium text-blue-800">
|
||||||
|
{t('conteneurs.colRecommendation')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@ -7,17 +7,34 @@ export default async function DocumentsTransportPage() {
|
|||||||
const t = await getTranslations('dashboard.wikiPages');
|
const t = await getTranslations('dashboard.wikiPages');
|
||||||
|
|
||||||
const documents = t.raw('documentsTransport.documents') as Array<{
|
const documents = t.raw('documentsTransport.documents') as Array<{
|
||||||
name: string; type: string; description: string; types: string[];
|
name: string;
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
types: string[];
|
||||||
|
}>;
|
||||||
|
const additionalDocs = t.raw('documentsTransport.additionalDocs') as Array<{
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
const blFunctions = t.raw('documentsTransport.blFunctions') as Array<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
}>;
|
}>;
|
||||||
const additionalDocs = t.raw('documentsTransport.additionalDocs') as Array<{ name: string; description: string }>;
|
|
||||||
const blFunctions = t.raw('documentsTransport.blFunctions') as Array<{ title: string; description: string }>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
|
<Link
|
||||||
|
href="/dashboard/wiki"
|
||||||
|
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backToWiki')}
|
{t('backToWiki')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -32,21 +49,30 @@ export default async function DocumentsTransportPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('documentsTransport.mainDocumentsTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
{t('documentsTransport.mainDocumentsTitle')}
|
||||||
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{documents.map((doc) => (
|
{documents.map(doc => (
|
||||||
<Card key={doc.name} className="bg-white">
|
<Card key={doc.name} className="bg-white">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h4 className="font-semibold text-gray-900">{doc.name}</h4>
|
<h4 className="font-semibold text-gray-900">{doc.name}</h4>
|
||||||
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded">{doc.type}</span>
|
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded">
|
||||||
|
{doc.type}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 mb-2">{doc.description}</p>
|
<p className="text-sm text-gray-600 mb-2">{doc.description}</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{doc.types.map((type, j) => (
|
{doc.types.map((type, j) => (
|
||||||
<span key={j} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{type}</span>
|
<span
|
||||||
|
key={j}
|
||||||
|
className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded"
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -58,9 +84,11 @@ export default async function DocumentsTransportPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('documentsTransport.additionalDocsTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
{t('documentsTransport.additionalDocsTitle')}
|
||||||
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{additionalDocs.map((doc) => (
|
{additionalDocs.map(doc => (
|
||||||
<Card key={doc.name} className="bg-white">
|
<Card key={doc.name} className="bg-white">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<h4 className="font-semibold text-gray-900">{doc.name}</h4>
|
<h4 className="font-semibold text-gray-900">{doc.name}</h4>
|
||||||
@ -73,9 +101,11 @@ export default async function DocumentsTransportPage() {
|
|||||||
|
|
||||||
<Card className="mt-8 bg-blue-50 border-blue-200">
|
<Card className="mt-8 bg-blue-50 border-blue-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-blue-900 mb-4">{t('documentsTransport.blFocusTitle')}</h3>
|
<h3 className="font-semibold text-blue-900 mb-4">
|
||||||
|
{t('documentsTransport.blFocusTitle')}
|
||||||
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{blFunctions.map((fn) => (
|
{blFunctions.map(fn => (
|
||||||
<div key={fn.title} className="bg-white p-4 rounded-lg border border-blue-200">
|
<div key={fn.title} className="bg-white p-4 rounded-lg border border-blue-200">
|
||||||
<h4 className="font-medium text-blue-800 mb-1">{fn.title}</h4>
|
<h4 className="font-medium text-blue-800 mb-1">{fn.title}</h4>
|
||||||
<p className="text-sm text-gray-600">{fn.description}</p>
|
<p className="text-sm text-gray-600">{fn.description}</p>
|
||||||
|
|||||||
@ -6,18 +6,32 @@ import { ShieldCheck } from 'lucide-react';
|
|||||||
export default async function DouanesPage() {
|
export default async function DouanesPage() {
|
||||||
const t = await getTranslations('dashboard.wikiPages');
|
const t = await getTranslations('dashboard.wikiPages');
|
||||||
|
|
||||||
const regimes = t.raw('douanes.regimes') as Array<{ code: string; name: string; description: string }>;
|
const regimes = t.raw('douanes.regimes') as Array<{
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
const documents = t.raw('douanes.documents') as Array<{
|
const documents = t.raw('douanes.documents') as Array<{
|
||||||
name: string; mandatory: boolean; description: string;
|
name: string;
|
||||||
|
mandatory: boolean;
|
||||||
|
description: string;
|
||||||
}>;
|
}>;
|
||||||
const duties = t.raw('douanes.duties') as Array<{ type: string; description: string }>;
|
const duties = t.raw('douanes.duties') as Array<{ type: string; description: string }>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
|
<Link
|
||||||
|
href="/dashboard/wiki"
|
||||||
|
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backToWiki')}
|
{t('backToWiki')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -43,7 +57,7 @@ export default async function DouanesPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{regimes.map((r) => (
|
{regimes.map(r => (
|
||||||
<tr key={r.code} className="border-t hover:bg-gray-50">
|
<tr key={r.code} className="border-t hover:bg-gray-50">
|
||||||
<td className="p-3 font-mono font-bold text-blue-600">{r.code}</td>
|
<td className="p-3 font-mono font-bold text-blue-600">{r.code}</td>
|
||||||
<td className="p-3 font-medium">{r.name}</td>
|
<td className="p-3 font-medium">{r.name}</td>
|
||||||
@ -58,11 +72,13 @@ export default async function DouanesPage() {
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.documentsTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.documentsTitle')}</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{documents.map((doc) => (
|
{documents.map(doc => (
|
||||||
<Card key={doc.name} className="bg-white">
|
<Card key={doc.name} className="bg-white">
|
||||||
<CardContent className="py-3">
|
<CardContent className="py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className={`px-2 py-0.5 text-xs rounded font-medium ${doc.mandatory ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'}`}>
|
<span
|
||||||
|
className={`px-2 py-0.5 text-xs rounded font-medium ${doc.mandatory ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'}`}
|
||||||
|
>
|
||||||
{doc.mandatory ? t('mandatoryLabel') : t('optionalLabel')}
|
{doc.mandatory ? t('mandatoryLabel') : t('optionalLabel')}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
@ -79,7 +95,7 @@ export default async function DouanesPage() {
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.dutiesTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.dutiesTitle')}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{duties.map((d) => (
|
{duties.map(d => (
|
||||||
<Card key={d.type} className="bg-white">
|
<Card key={d.type} className="bg-white">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<h4 className="font-semibold text-gray-900 mb-2">{d.type}</h4>
|
<h4 className="font-semibold text-gray-900 mb-2">{d.type}</h4>
|
||||||
|
|||||||
@ -7,17 +7,31 @@ export default async function ImdgPage() {
|
|||||||
const t = await getTranslations('dashboard.wikiPages');
|
const t = await getTranslations('dashboard.wikiPages');
|
||||||
|
|
||||||
const classes = t.raw('imdg.classes') as Array<{
|
const classes = t.raw('imdg.classes') as Array<{
|
||||||
class: string; name: string; description: string; subdivisions?: string[];
|
class: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
subdivisions?: string[];
|
||||||
}>;
|
}>;
|
||||||
const documents = t.raw('imdg.documents') as Array<{ name: string; description: string }>;
|
const documents = t.raw('imdg.documents') as Array<{ name: string; description: string }>;
|
||||||
const packagingGroups = t.raw('imdg.packagingGroups') as Array<{ group: string; description: string }>;
|
const packagingGroups = t.raw('imdg.packagingGroups') as Array<{
|
||||||
|
group: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
|
<Link
|
||||||
|
href="/dashboard/wiki"
|
||||||
|
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backToWiki')}
|
{t('backToWiki')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -34,17 +48,21 @@ export default async function ImdgPage() {
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.classesTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.classesTitle')}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{classes.map((cls) => (
|
{classes.map(cls => (
|
||||||
<Card key={cls.class} className="bg-white border-orange-200">
|
<Card key={cls.class} className="bg-white border-orange-200">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="px-2 py-0.5 bg-orange-100 text-orange-800 text-xs font-bold rounded">{cls.class}</span>
|
<span className="px-2 py-0.5 bg-orange-100 text-orange-800 text-xs font-bold rounded">
|
||||||
|
{cls.class}
|
||||||
|
</span>
|
||||||
<h4 className="font-semibold text-gray-900">{cls.name}</h4>
|
<h4 className="font-semibold text-gray-900">{cls.name}</h4>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">{cls.description}</p>
|
<p className="text-sm text-gray-600">{cls.description}</p>
|
||||||
{cls.subdivisions && (
|
{cls.subdivisions && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-xs font-medium text-gray-500 mb-1">{t('imdg.subdivisionsLabel')}</p>
|
<p className="text-xs font-medium text-gray-500 mb-1">
|
||||||
|
{t('imdg.subdivisionsLabel')}
|
||||||
|
</p>
|
||||||
<ul className="text-xs text-gray-600 space-y-0.5">
|
<ul className="text-xs text-gray-600 space-y-0.5">
|
||||||
{cls.subdivisions.map((sub, j) => (
|
{cls.subdivisions.map((sub, j) => (
|
||||||
<li key={j}>• {sub}</li>
|
<li key={j}>• {sub}</li>
|
||||||
@ -61,7 +79,7 @@ export default async function ImdgPage() {
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.documentsTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.documentsTitle')}</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{documents.map((doc) => (
|
{documents.map(doc => (
|
||||||
<Card key={doc.name} className="bg-white">
|
<Card key={doc.name} className="bg-white">
|
||||||
<CardContent className="py-3">
|
<CardContent className="py-3">
|
||||||
<h4 className="font-semibold text-gray-900">{doc.name}</h4>
|
<h4 className="font-semibold text-gray-900">{doc.name}</h4>
|
||||||
@ -76,7 +94,11 @@ export default async function ImdgPage() {
|
|||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.packagingGroupsTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.packagingGroupsTitle')}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{packagingGroups.map((pg, i) => {
|
{packagingGroups.map((pg, i) => {
|
||||||
const colors = ['bg-red-50 border-red-200', 'bg-yellow-50 border-yellow-200', 'bg-green-50 border-green-200'];
|
const colors = [
|
||||||
|
'bg-red-50 border-red-200',
|
||||||
|
'bg-yellow-50 border-yellow-200',
|
||||||
|
'bg-green-50 border-green-200',
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<Card key={pg.group} className={`border ${colors[i]}`}>
|
<Card key={pg.group} className={`border ${colors[i]}`}>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
|
|||||||
@ -7,22 +7,40 @@ export default async function IncotermsPage() {
|
|||||||
const t = await getTranslations('dashboard.wikiPages');
|
const t = await getTranslations('dashboard.wikiPages');
|
||||||
|
|
||||||
const list = t.raw('incoterms.list') as Array<{
|
const list = t.raw('incoterms.list') as Array<{
|
||||||
code: string; name: string; description: string; risk: string; transport: string;
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
risk: string;
|
||||||
|
transport: string;
|
||||||
}>;
|
}>;
|
||||||
const categorySections = t.raw('incoterms.categorySections') as Array<{
|
const categorySections = t.raw('incoterms.categorySections') as Array<{
|
||||||
name: string; description: string; terms: string[];
|
name: string;
|
||||||
|
description: string;
|
||||||
|
terms: string[];
|
||||||
}>;
|
}>;
|
||||||
const keyPoints = t.raw('incoterms.keyPoints') as string[];
|
const keyPoints = t.raw('incoterms.keyPoints') as string[];
|
||||||
const tips = t.raw('incoterms.tips') as string[];
|
const tips = t.raw('incoterms.tips') as string[];
|
||||||
|
|
||||||
const categoryColors = ['bg-green-100 text-green-800', 'bg-red-100 text-red-800', 'bg-blue-100 text-blue-800'];
|
const categoryColors = [
|
||||||
|
'bg-green-100 text-green-800',
|
||||||
|
'bg-red-100 text-red-800',
|
||||||
|
'bg-blue-100 text-blue-800',
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
|
<Link
|
||||||
|
href="/dashboard/wiki"
|
||||||
|
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backToWiki')}
|
{t('backToWiki')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -59,8 +77,13 @@ export default async function IncotermsPage() {
|
|||||||
<h3 className="font-semibold text-gray-900">{cat.name}</h3>
|
<h3 className="font-semibold text-gray-900">{cat.name}</h3>
|
||||||
<p className="text-sm text-gray-600 mt-1">{cat.description}</p>
|
<p className="text-sm text-gray-600 mt-1">{cat.description}</p>
|
||||||
<div className="flex flex-wrap gap-1 mt-3">
|
<div className="flex flex-wrap gap-1 mt-3">
|
||||||
{cat.terms.map((term) => (
|
{cat.terms.map(term => (
|
||||||
<span key={term} className={`px-2 py-0.5 rounded text-xs font-mono font-bold ${categoryColors[i]}`}>{term}</span>
|
<span
|
||||||
|
key={term}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs font-mono font-bold ${categoryColors[i]}`}
|
||||||
|
>
|
||||||
|
{term}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -83,7 +106,7 @@ export default async function IncotermsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{list.map((item) => (
|
{list.map(item => (
|
||||||
<tr key={item.code} className="border-t hover:bg-gray-50">
|
<tr key={item.code} className="border-t hover:bg-gray-50">
|
||||||
<td className="p-3 font-mono font-bold text-blue-600">{item.code}</td>
|
<td className="p-3 font-mono font-bold text-blue-600">{item.code}</td>
|
||||||
<td className="p-3 font-medium">{item.name}</td>
|
<td className="p-3 font-medium">{item.name}</td>
|
||||||
|
|||||||
@ -6,17 +6,33 @@ import { Scale } from 'lucide-react';
|
|||||||
export default async function LclVsFclPage() {
|
export default async function LclVsFclPage() {
|
||||||
const t = await getTranslations('dashboard.wikiPages');
|
const t = await getTranslations('dashboard.wikiPages');
|
||||||
|
|
||||||
const criteria = t.raw('lclVsFcl.criteria') as Array<{ criterion: string; lcl: string; fcl: string }>;
|
const criteria = t.raw('lclVsFcl.criteria') as Array<{
|
||||||
const lclProcess = t.raw('lclVsFcl.lclProcess') as Array<{ step: string; title: string; description: string }>;
|
criterion: string;
|
||||||
|
lcl: string;
|
||||||
|
fcl: string;
|
||||||
|
}>;
|
||||||
|
const lclProcess = t.raw('lclVsFcl.lclProcess') as Array<{
|
||||||
|
step: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
const chooseLcl = t.raw('lclVsFcl.chooseLcl') as string[];
|
const chooseLcl = t.raw('lclVsFcl.chooseLcl') as string[];
|
||||||
const chooseFcl = t.raw('lclVsFcl.chooseFcl') as string[];
|
const chooseFcl = t.raw('lclVsFcl.chooseFcl') as string[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
|
<Link
|
||||||
|
href="/dashboard/wiki"
|
||||||
|
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backToWiki')}
|
{t('backToWiki')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -57,7 +73,7 @@ export default async function LclVsFclPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{criteria.map((row) => (
|
{criteria.map(row => (
|
||||||
<tr key={row.criterion} className="border-t hover:bg-gray-50">
|
<tr key={row.criterion} className="border-t hover:bg-gray-50">
|
||||||
<td className="p-3 font-medium text-gray-900">{row.criterion}</td>
|
<td className="p-3 font-medium text-gray-900">{row.criterion}</td>
|
||||||
<td className="p-3 text-gray-600">{row.lcl}</td>
|
<td className="p-3 text-gray-600">{row.lcl}</td>
|
||||||
@ -72,7 +88,7 @@ export default async function LclVsFclPage() {
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lclVsFcl.lclProcessTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lclVsFcl.lclProcessTitle')}</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{lclProcess.map((step) => (
|
{lclProcess.map(step => (
|
||||||
<div key={step.step} className="flex items-start gap-4 bg-white p-4 rounded-lg border">
|
<div key={step.step} className="flex items-start gap-4 bg-white p-4 rounded-lg border">
|
||||||
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold text-sm">
|
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold text-sm">
|
||||||
{step.step}
|
{step.step}
|
||||||
|
|||||||
@ -10,15 +10,29 @@ export default async function LettreCreditPage() {
|
|||||||
const parties = t.raw('lettreCredit.parties') as Array<{ role: string; description: string }>;
|
const parties = t.raw('lettreCredit.parties') as Array<{ role: string; description: string }>;
|
||||||
const documents = t.raw('lettreCredit.documents') as Array<{ name: string; description: string }>;
|
const documents = t.raw('lettreCredit.documents') as Array<{ name: string; description: string }>;
|
||||||
const errors = t.raw('lettreCredit.errors') as string[];
|
const errors = t.raw('lettreCredit.errors') as string[];
|
||||||
const datesItems = t.raw('lettreCredit.datesItems') as Array<{ label: string; description: string }>;
|
const datesItems = t.raw('lettreCredit.datesItems') as Array<{
|
||||||
const costsItems = t.raw('lettreCredit.costsItems') as Array<{ label: string; description: string }>;
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
const costsItems = t.raw('lettreCredit.costsItems') as Array<{
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
|
<Link
|
||||||
|
href="/dashboard/wiki"
|
||||||
|
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backToWiki')}
|
{t('backToWiki')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -35,7 +49,7 @@ export default async function LettreCreditPage() {
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.typesTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.typesTitle')}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{types.map((type) => (
|
{types.map(type => (
|
||||||
<Card key={type.name} className="bg-white">
|
<Card key={type.name} className="bg-white">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<h4 className="font-semibold text-gray-900">{type.name}</h4>
|
<h4 className="font-semibold text-gray-900">{type.name}</h4>
|
||||||
@ -57,7 +71,7 @@ export default async function LettreCreditPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{parties.map((p) => (
|
{parties.map(p => (
|
||||||
<tr key={p.role} className="border-t hover:bg-gray-50">
|
<tr key={p.role} className="border-t hover:bg-gray-50">
|
||||||
<td className="p-3 font-medium text-blue-700">{p.role}</td>
|
<td className="p-3 font-medium text-blue-700">{p.role}</td>
|
||||||
<td className="p-3 text-gray-600">{p.description}</td>
|
<td className="p-3 text-gray-600">{p.description}</td>
|
||||||
@ -71,7 +85,7 @@ export default async function LettreCreditPage() {
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.documentsTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.documentsTitle')}</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{documents.map((doc) => (
|
{documents.map(doc => (
|
||||||
<div key={doc.name} className="flex items-start gap-3 bg-white p-4 rounded-lg border">
|
<div key={doc.name} className="flex items-start gap-3 bg-white p-4 rounded-lg border">
|
||||||
<span className="text-green-500 mt-0.5 flex-shrink-0">✓</span>
|
<span className="text-green-500 mt-0.5 flex-shrink-0">✓</span>
|
||||||
<div>
|
<div>
|
||||||
@ -108,7 +122,7 @@ export default async function LettreCreditPage() {
|
|||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">{t('lettreCredit.datesTitle')}</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">{t('lettreCredit.datesTitle')}</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{datesItems.map((item) => (
|
{datesItems.map(item => (
|
||||||
<div key={item.label}>
|
<div key={item.label}>
|
||||||
<span className="font-medium text-gray-800 text-sm">{item.label}</span>
|
<span className="font-medium text-gray-800 text-sm">{item.label}</span>
|
||||||
<p className="text-xs text-gray-600 mt-0.5">{item.description}</p>
|
<p className="text-xs text-gray-600 mt-0.5">{item.description}</p>
|
||||||
@ -121,7 +135,7 @@ export default async function LettreCreditPage() {
|
|||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">{t('lettreCredit.costsTitle')}</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">{t('lettreCredit.costsTitle')}</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{costsItems.map((item) => (
|
{costsItems.map(item => (
|
||||||
<div key={item.label}>
|
<div key={item.label}>
|
||||||
<span className="font-medium text-gray-800 text-sm">{item.label}</span>
|
<span className="font-medium text-gray-800 text-sm">{item.label}</span>
|
||||||
<p className="text-xs text-gray-600 mt-0.5">{item.description}</p>
|
<p className="text-xs text-gray-600 mt-0.5">{item.description}</p>
|
||||||
|
|||||||
@ -17,7 +17,19 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type TopicKey = 'incoterms' | 'documents' | 'containers' | 'lclFcl' | 'customs' | 'insurance' | 'freight' | 'ports' | 'vgm' | 'imdg' | 'letterOfCredit' | 'transitTime';
|
type TopicKey =
|
||||||
|
| 'incoterms'
|
||||||
|
| 'documents'
|
||||||
|
| 'containers'
|
||||||
|
| 'lclFcl'
|
||||||
|
| 'customs'
|
||||||
|
| 'insurance'
|
||||||
|
| 'freight'
|
||||||
|
| 'ports'
|
||||||
|
| 'vgm'
|
||||||
|
| 'imdg'
|
||||||
|
| 'letterOfCredit'
|
||||||
|
| 'transitTime';
|
||||||
|
|
||||||
interface WikiTopic {
|
interface WikiTopic {
|
||||||
key: TopicKey;
|
key: TopicKey;
|
||||||
@ -27,18 +39,63 @@ interface WikiTopic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wikiTopics: WikiTopic[] = [
|
const wikiTopics: WikiTopic[] = [
|
||||||
{ key: 'incoterms', icon: ScrollText, href: '/dashboard/wiki/incoterms', tags: ['FOB', 'CIF', 'EXW', 'DDP'] },
|
{
|
||||||
{ key: 'documents', icon: ClipboardList, href: '/dashboard/wiki/documents-transport', tags: ['B/L', 'Sea Waybill', 'Manifest'] },
|
key: 'incoterms',
|
||||||
{ key: 'containers', icon: Package, href: '/dashboard/wiki/conteneurs', tags: ["20'", "40'", 'Reefer', 'Open Top'] },
|
icon: ScrollText,
|
||||||
|
href: '/dashboard/wiki/incoterms',
|
||||||
|
tags: ['FOB', 'CIF', 'EXW', 'DDP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'documents',
|
||||||
|
icon: ClipboardList,
|
||||||
|
href: '/dashboard/wiki/documents-transport',
|
||||||
|
tags: ['B/L', 'Sea Waybill', 'Manifest'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'containers',
|
||||||
|
icon: Package,
|
||||||
|
href: '/dashboard/wiki/conteneurs',
|
||||||
|
tags: ["20'", "40'", 'Reefer', 'Open Top'],
|
||||||
|
},
|
||||||
{ key: 'lclFcl', icon: Scale, href: '/dashboard/wiki/lcl-vs-fcl', tags: ['LCL', 'FCL'] },
|
{ key: 'lclFcl', icon: Scale, href: '/dashboard/wiki/lcl-vs-fcl', tags: ['LCL', 'FCL'] },
|
||||||
{ key: 'customs', icon: ShieldCheck, href: '/dashboard/wiki/douanes', tags: ['HS Code', 'EUR.1', 'AEO'] },
|
{
|
||||||
{ key: 'insurance', icon: Shield, href: '/dashboard/wiki/assurance', tags: ['ICC A', 'ICC B', 'ICC C'] },
|
key: 'customs',
|
||||||
{ key: 'freight', icon: Calculator, href: '/dashboard/wiki/calcul-fret', tags: ['CBM', 'THC', 'BAF', 'CAF'] },
|
icon: ShieldCheck,
|
||||||
{ key: 'ports', icon: Globe, href: '/dashboard/wiki/ports-routes', tags: ['Hub', 'Suez', 'Panama'] },
|
href: '/dashboard/wiki/douanes',
|
||||||
|
tags: ['HS Code', 'EUR.1', 'AEO'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'insurance',
|
||||||
|
icon: Shield,
|
||||||
|
href: '/dashboard/wiki/assurance',
|
||||||
|
tags: ['ICC A', 'ICC B', 'ICC C'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'freight',
|
||||||
|
icon: Calculator,
|
||||||
|
href: '/dashboard/wiki/calcul-fret',
|
||||||
|
tags: ['CBM', 'THC', 'BAF', 'CAF'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ports',
|
||||||
|
icon: Globe,
|
||||||
|
href: '/dashboard/wiki/ports-routes',
|
||||||
|
tags: ['Hub', 'Suez', 'Panama'],
|
||||||
|
},
|
||||||
{ key: 'vgm', icon: Anchor, href: '/dashboard/wiki/vgm', tags: ['SOLAS', 'VGM'] },
|
{ key: 'vgm', icon: Anchor, href: '/dashboard/wiki/vgm', tags: ['SOLAS', 'VGM'] },
|
||||||
{ key: 'imdg', icon: AlertTriangle, href: '/dashboard/wiki/imdg', tags: ['IMDG', 'MSDS', 'DG'] },
|
{ key: 'imdg', icon: AlertTriangle, href: '/dashboard/wiki/imdg', tags: ['IMDG', 'MSDS', 'DG'] },
|
||||||
{ key: 'letterOfCredit', icon: CreditCard, href: '/dashboard/wiki/lettre-credit', tags: ['L/C', 'SWIFT', 'UCP 600'] },
|
{
|
||||||
{ key: 'transitTime', icon: Timer, href: '/dashboard/wiki/transit-time', tags: ['Cut-off', 'Free time', 'Demurrage'] },
|
key: 'letterOfCredit',
|
||||||
|
icon: CreditCard,
|
||||||
|
href: '/dashboard/wiki/lettre-credit',
|
||||||
|
tags: ['L/C', 'SWIFT', 'UCP 600'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'transitTime',
|
||||||
|
icon: Timer,
|
||||||
|
href: '/dashboard/wiki/transit-time',
|
||||||
|
tags: ['Cut-off', 'Free time', 'Demurrage'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default async function WikiPage() {
|
export default async function WikiPage() {
|
||||||
@ -54,7 +111,7 @@ export default async function WikiPage() {
|
|||||||
|
|
||||||
{/* Cards Grid */}
|
{/* Cards Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{wikiTopics.map((topic) => {
|
{wikiTopics.map(topic => {
|
||||||
const IconComponent = topic.icon;
|
const IconComponent = topic.icon;
|
||||||
return (
|
return (
|
||||||
<Link key={topic.href} href={topic.href} className="block group">
|
<Link key={topic.href} href={topic.href} className="block group">
|
||||||
@ -74,7 +131,7 @@ export default async function WikiPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{topic.tags.map((tag) => (
|
{topic.tags.map(tag => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded-full"
|
className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded-full"
|
||||||
|
|||||||
@ -7,21 +7,40 @@ export default async function PortsRoutesPage() {
|
|||||||
const t = await getTranslations('dashboard.wikiPages');
|
const t = await getTranslations('dashboard.wikiPages');
|
||||||
|
|
||||||
const routes = t.raw('portsRoutes.routes') as Array<{
|
const routes = t.raw('portsRoutes.routes') as Array<{
|
||||||
name: string; description: string; via: string; transitTime: string; majorPorts: string[];
|
name: string;
|
||||||
|
description: string;
|
||||||
|
via: string;
|
||||||
|
transitTime: string;
|
||||||
|
majorPorts: string[];
|
||||||
}>;
|
}>;
|
||||||
const passages = t.raw('portsRoutes.passages') as Array<{
|
const passages = t.raw('portsRoutes.passages') as Array<{
|
||||||
name: string; location: string; length: string; description: string; keyStat: string;
|
name: string;
|
||||||
|
location: string;
|
||||||
|
length: string;
|
||||||
|
description: string;
|
||||||
|
keyStat: string;
|
||||||
}>;
|
}>;
|
||||||
const ports = t.raw('portsRoutes.ports') as Array<{
|
const ports = t.raw('portsRoutes.ports') as Array<{
|
||||||
rank: number; port: string; country: string; teu: string;
|
rank: number;
|
||||||
|
port: string;
|
||||||
|
country: string;
|
||||||
|
teu: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
|
<Link
|
||||||
|
href="/dashboard/wiki"
|
||||||
|
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backToWiki')}
|
{t('backToWiki')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -36,20 +55,33 @@ export default async function PortsRoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('portsRoutes.majorRoutesTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
{t('portsRoutes.majorRoutesTitle')}
|
||||||
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{routes.map((route) => (
|
{routes.map(route => (
|
||||||
<Card key={route.name} className="bg-white">
|
<Card key={route.name} className="bg-white">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<h4 className="font-semibold text-gray-900 mb-1">{route.name}</h4>
|
<h4 className="font-semibold text-gray-900 mb-1">{route.name}</h4>
|
||||||
<p className="text-sm text-gray-600 mb-3">{route.description}</p>
|
<p className="text-sm text-gray-600 mb-3">{route.description}</p>
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<span className="text-gray-500">{t('portsRoutes.colVia')}: <strong className="text-gray-800">{route.via}</strong></span>
|
<span className="text-gray-500">
|
||||||
<span className="text-gray-500">{t('portsRoutes.colTransit')}: <strong className="text-blue-600">{route.transitTime}</strong></span>
|
{t('portsRoutes.colVia')}:{' '}
|
||||||
|
<strong className="text-gray-800">{route.via}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{t('portsRoutes.colTransit')}:{' '}
|
||||||
|
<strong className="text-blue-600">{route.transitTime}</strong>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{route.majorPorts.map((port) => (
|
{route.majorPorts.map(port => (
|
||||||
<span key={port} className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded">{port}</span>
|
<span
|
||||||
|
key={port}
|
||||||
|
className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded"
|
||||||
|
>
|
||||||
|
{port}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -61,15 +93,19 @@ export default async function PortsRoutesPage() {
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('portsRoutes.passagesTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('portsRoutes.passagesTitle')}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{passages.map((p) => (
|
{passages.map(p => (
|
||||||
<Card key={p.name} className="bg-white border-blue-200">
|
<Card key={p.name} className="bg-white border-blue-200">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-gray-900">{p.name}</h4>
|
<h4 className="font-semibold text-gray-900">{p.name}</h4>
|
||||||
<p className="text-sm text-gray-500">{p.location} — {t('portsRoutes.colLength')}: {p.length}</p>
|
<p className="text-sm text-gray-500">
|
||||||
|
{p.location} — {t('portsRoutes.colLength')}: {p.length}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded font-medium whitespace-nowrap">{p.keyStat}</span>
|
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded font-medium whitespace-nowrap">
|
||||||
|
{p.keyStat}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">{p.description}</p>
|
<p className="text-sm text-gray-600">{p.description}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -91,7 +127,7 @@ export default async function PortsRoutesPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ports.map((port) => (
|
{ports.map(port => (
|
||||||
<tr key={port.rank} className="border-t hover:bg-gray-50">
|
<tr key={port.rank} className="border-t hover:bg-gray-50">
|
||||||
<td className="p-3 font-bold text-gray-400">#{port.rank}</td>
|
<td className="p-3 font-bold text-gray-400">#{port.rank}</td>
|
||||||
<td className="p-3 font-medium text-gray-900">{port.port}</td>
|
<td className="p-3 font-medium text-gray-900">{port.port}</td>
|
||||||
|
|||||||
@ -7,11 +7,21 @@ export default async function TransitTimePage() {
|
|||||||
const t = await getTranslations('dashboard.wikiPages');
|
const t = await getTranslations('dashboard.wikiPages');
|
||||||
|
|
||||||
const timeline = t.raw('transitTime.timeline') as Array<{
|
const timeline = t.raw('transitTime.timeline') as Array<{
|
||||||
step: string; description: string; delay: string; responsible: string;
|
step: string;
|
||||||
|
description: string;
|
||||||
|
delay: string;
|
||||||
|
responsible: string;
|
||||||
|
}>;
|
||||||
|
const transitTimes = t.raw('transitTime.transitTimes') as Array<{
|
||||||
|
route: string;
|
||||||
|
time: string;
|
||||||
|
via: string;
|
||||||
}>;
|
}>;
|
||||||
const transitTimes = t.raw('transitTime.transitTimes') as Array<{ route: string; time: string; via: string }>;
|
|
||||||
const lateFees = t.raw('transitTime.lateFees') as Array<{
|
const lateFees = t.raw('transitTime.lateFees') as Array<{
|
||||||
name: string; definition: string; rate: string; location: string;
|
name: string;
|
||||||
|
definition: string;
|
||||||
|
rate: string;
|
||||||
|
location: string;
|
||||||
}>;
|
}>;
|
||||||
const potentialDelays = t.raw('transitTime.potentialDelays') as string[];
|
const potentialDelays = t.raw('transitTime.potentialDelays') as string[];
|
||||||
const seasonalVariations = t.raw('transitTime.seasonalVariations') as string[];
|
const seasonalVariations = t.raw('transitTime.seasonalVariations') as string[];
|
||||||
@ -28,9 +38,17 @@ export default async function TransitTimePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
|
<Link
|
||||||
|
href="/dashboard/wiki"
|
||||||
|
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backToWiki')}
|
{t('backToWiki')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -48,7 +66,7 @@ export default async function TransitTimePage() {
|
|||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-blue-900 mb-3">{t('transitTime.keyTermsTitle')}</h3>
|
<h3 className="font-semibold text-blue-900 mb-3">{t('transitTime.keyTermsTitle')}</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{keyTerms.map((term) => (
|
{keyTerms.map(term => (
|
||||||
<div key={term.key}>
|
<div key={term.key}>
|
||||||
<h4 className="font-medium text-blue-800">{term.key}</h4>
|
<h4 className="font-medium text-blue-800">{term.key}</h4>
|
||||||
<p className="text-sm text-blue-700">{term.def}</p>
|
<p className="text-sm text-blue-700">{term.def}</p>
|
||||||
@ -62,7 +80,10 @@ export default async function TransitTimePage() {
|
|||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.timelineTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.timelineTitle')}</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{timeline.map((item, index) => (
|
{timeline.map((item, index) => (
|
||||||
<Card key={index} className={`bg-white ${index === 5 || index === 7 ? 'border-blue-300 border-2' : ''}`}>
|
<Card
|
||||||
|
key={index}
|
||||||
|
className={`bg-white ${index === 5 || index === 7 ? 'border-blue-300 border-2' : ''}`}
|
||||||
|
>
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold text-sm">
|
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold text-sm">
|
||||||
@ -71,10 +92,14 @@ export default async function TransitTimePage() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||||
<h4 className="font-medium text-gray-900">{item.step}</h4>
|
<h4 className="font-medium text-gray-900">{item.step}</h4>
|
||||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">{item.delay}</span>
|
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">
|
||||||
|
{item.delay}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">{item.description}</p>
|
<p className="text-sm text-gray-600">{item.description}</p>
|
||||||
<p className="text-xs text-gray-400 mt-1">{t('transitTime.responsibleLabel')} : {item.responsible}</p>
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{t('transitTime.responsibleLabel')} : {item.responsible}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -84,7 +109,9 @@ export default async function TransitTimePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.transitTimesTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
{t('transitTime.transitTimesTitle')}
|
||||||
|
</h2>
|
||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@ -97,7 +124,7 @@ export default async function TransitTimePage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{transitTimes.map((tt) => (
|
{transitTimes.map(tt => (
|
||||||
<tr key={tt.route} className="border-b last:border-0 hover:bg-gray-50">
|
<tr key={tt.route} className="border-b last:border-0 hover:bg-gray-50">
|
||||||
<td className="py-3 text-gray-900">{tt.route}</td>
|
<td className="py-3 text-gray-900">{tt.route}</td>
|
||||||
<td className="py-3 text-center font-mono text-blue-600">{tt.time}</td>
|
<td className="py-3 text-center font-mono text-blue-600">{tt.time}</td>
|
||||||
@ -137,7 +164,7 @@ export default async function TransitTimePage() {
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.lateFeesTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.lateFeesTitle')}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{lateFees.map((fee) => (
|
{lateFees.map(fee => (
|
||||||
<Card key={fee.name} className="bg-white border-red-200">
|
<Card key={fee.name} className="bg-white border-red-200">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<h4 className="text-lg font-semibold text-red-700 mb-1">{fee.name}</h4>
|
<h4 className="text-lg font-semibold text-red-700 mb-1">{fee.name}</h4>
|
||||||
@ -158,18 +185,28 @@ export default async function TransitTimePage() {
|
|||||||
|
|
||||||
<Card className="mt-8 bg-orange-50 border-orange-200">
|
<Card className="mt-8 bg-orange-50 border-orange-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-orange-900 mb-3">{t('transitTime.delayFactorsTitle')}</h3>
|
<h3 className="font-semibold text-orange-900 mb-3">
|
||||||
|
{t('transitTime.delayFactorsTitle')}
|
||||||
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium text-orange-800">{t('transitTime.potentialDelaysTitle')}</h4>
|
<h4 className="font-medium text-orange-800">
|
||||||
|
{t('transitTime.potentialDelaysTitle')}
|
||||||
|
</h4>
|
||||||
<ul className="text-sm text-orange-700 mt-2 space-y-1">
|
<ul className="text-sm text-orange-700 mt-2 space-y-1">
|
||||||
{potentialDelays.map((d, i) => <li key={i}>• {d}</li>)}
|
{potentialDelays.map((d, i) => (
|
||||||
|
<li key={i}>• {d}</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium text-orange-800">{t('transitTime.seasonalVariationsTitle')}</h4>
|
<h4 className="font-medium text-orange-800">
|
||||||
|
{t('transitTime.seasonalVariationsTitle')}
|
||||||
|
</h4>
|
||||||
<ul className="text-sm text-orange-700 mt-2 space-y-1">
|
<ul className="text-sm text-orange-700 mt-2 space-y-1">
|
||||||
{seasonalVariations.map((v, i) => <li key={i}>• {v}</li>)}
|
{seasonalVariations.map((v, i) => (
|
||||||
|
<li key={i}>• {v}</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -183,7 +220,9 @@ export default async function TransitTimePage() {
|
|||||||
<div className="bg-white p-4 rounded-lg border">
|
<div className="bg-white p-4 rounded-lg border">
|
||||||
<h4 className="font-medium text-gray-900">{t('transitTime.rolloverCausesTitle')}</h4>
|
<h4 className="font-medium text-gray-900">{t('transitTime.rolloverCausesTitle')}</h4>
|
||||||
<ul className="text-sm text-gray-600 mt-2 space-y-1">
|
<ul className="text-sm text-gray-600 mt-2 space-y-1">
|
||||||
{rolloverCauses.map((cause, i) => <li key={i}>• {cause}</li>)}
|
{rolloverCauses.map((cause, i) => (
|
||||||
|
<li key={i}>• {cause}</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-xs text-gray-500 mt-3">{t('transitTime.rolloverImpact')}</p>
|
<p className="text-xs text-gray-500 mt-3">{t('transitTime.rolloverImpact')}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -194,7 +233,9 @@ export default async function TransitTimePage() {
|
|||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-amber-900 mb-3">{t('transitTime.tipsTitle')}</h3>
|
<h3 className="font-semibold text-amber-900 mb-3">{t('transitTime.tipsTitle')}</h3>
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||||
{tips.map((tip, i) => <li key={i}>{tip}</li>)}
|
{tips.map((tip, i) => (
|
||||||
|
<li key={i}>{tip}</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -7,21 +7,40 @@ export default async function VGMPage() {
|
|||||||
const t = await getTranslations('dashboard.wikiPages');
|
const t = await getTranslations('dashboard.wikiPages');
|
||||||
|
|
||||||
const why = t.raw('vgm.why') as Array<{ title: string; description: string }>;
|
const why = t.raw('vgm.why') as Array<{ title: string; description: string }>;
|
||||||
const elements = t.raw('vgm.elements') as Array<{ element: string; description: string; example: string }>;
|
const elements = t.raw('vgm.elements') as Array<{
|
||||||
const methods = t.raw('vgm.methods') as Array<{
|
element: string;
|
||||||
method: string; name: string; description: string;
|
description: string;
|
||||||
process: string[]; advantages: string[]; disadvantages: string[];
|
example: string;
|
||||||
|
}>;
|
||||||
|
const methods = t.raw('vgm.methods') as Array<{
|
||||||
|
method: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
process: string[];
|
||||||
|
advantages: string[];
|
||||||
|
disadvantages: string[];
|
||||||
|
}>;
|
||||||
|
const responsibilities = t.raw('vgm.responsibilities') as Array<{
|
||||||
|
role: string;
|
||||||
|
description: string;
|
||||||
}>;
|
}>;
|
||||||
const responsibilities = t.raw('vgm.responsibilities') as Array<{ role: string; description: string }>;
|
|
||||||
const sanctions = t.raw('vgm.sanctions') as Array<{ region: string; sanction: string }>;
|
const sanctions = t.raw('vgm.sanctions') as Array<{ region: string; sanction: string }>;
|
||||||
const tips = t.raw('vgm.tips') as string[];
|
const tips = t.raw('vgm.tips') as string[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
|
<Link
|
||||||
|
href="/dashboard/wiki"
|
||||||
|
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backToWiki')}
|
{t('backToWiki')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -39,7 +58,7 @@ export default async function VGMPage() {
|
|||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-blue-900 mb-3">{t('vgm.whyTitle')}</h3>
|
<h3 className="font-semibold text-blue-900 mb-3">{t('vgm.whyTitle')}</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800">
|
||||||
{why.map((item) => (
|
{why.map(item => (
|
||||||
<div key={item.title}>
|
<div key={item.title}>
|
||||||
<h4 className="font-medium">{item.title}</h4>
|
<h4 className="font-medium">{item.title}</h4>
|
||||||
<p className="text-sm mt-0.5">{item.description}</p>
|
<p className="text-sm mt-0.5">{item.description}</p>
|
||||||
@ -54,16 +73,23 @@ export default async function VGMPage() {
|
|||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="bg-gray-50 p-4 rounded-lg border mb-4 text-center font-mono text-lg">
|
<div className="bg-gray-50 p-4 rounded-lg border mb-4 text-center font-mono text-lg">
|
||||||
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded">{t('vgm.formula')}</span>
|
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
||||||
|
{t('vgm.formula')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{elements.map((item) => (
|
{elements.map(item => (
|
||||||
<div key={item.element} className="flex items-center justify-between py-3 border-b last:border-0">
|
<div
|
||||||
|
key={item.element}
|
||||||
|
className="flex items-center justify-between py-3 border-b last:border-0"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium text-gray-900">{item.element}</h4>
|
<h4 className="font-medium text-gray-900">{item.element}</h4>
|
||||||
<p className="text-sm text-gray-600">{item.description}</p>
|
<p className="text-sm text-gray-600">{item.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-mono bg-gray-100 px-3 py-1 rounded ml-4 flex-shrink-0">{item.example}</span>
|
<span className="text-sm font-mono bg-gray-100 px-3 py-1 rounded ml-4 flex-shrink-0">
|
||||||
|
{item.example}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -74,11 +100,13 @@ export default async function VGMPage() {
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('vgm.methodsTitle')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('vgm.methodsTitle')}</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{methods.map((method) => (
|
{methods.map(method => (
|
||||||
<Card key={method.method} className="bg-white">
|
<Card key={method.method} className="bg-white">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="flex items-center gap-3">
|
<CardTitle className="flex items-center gap-3">
|
||||||
<span className="px-3 py-1 bg-green-600 text-white rounded-md text-sm">{method.method}</span>
|
<span className="px-3 py-1 bg-green-600 text-white rounded-md text-sm">
|
||||||
|
{method.method}
|
||||||
|
</span>
|
||||||
<span className="text-lg">{method.name}</span>
|
<span className="text-lg">{method.name}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -88,25 +116,33 @@ export default async function VGMPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium text-gray-700 mb-2">{t('vgm.processLabel')}</h4>
|
<h4 className="font-medium text-gray-700 mb-2">{t('vgm.processLabel')}</h4>
|
||||||
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
|
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
|
||||||
{method.process.map((step, i) => <li key={i}>{step}</li>)}
|
{method.process.map((step, i) => (
|
||||||
|
<li key={i}>{step}</li>
|
||||||
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium text-green-700 mb-2">✓ {t('vgm.advantagesLabel')}</h4>
|
<h4 className="font-medium text-green-700 mb-2">
|
||||||
|
✓ {t('vgm.advantagesLabel')}
|
||||||
|
</h4>
|
||||||
<ul className="text-sm text-gray-600 space-y-1">
|
<ul className="text-sm text-gray-600 space-y-1">
|
||||||
{method.advantages.map((adv) => (
|
{method.advantages.map(adv => (
|
||||||
<li key={adv} className="flex items-center gap-2">
|
<li key={adv} className="flex items-center gap-2">
|
||||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0" />{adv}
|
<span className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0" />
|
||||||
|
{adv}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium text-red-700 mb-2">✗ {t('vgm.disadvantagesLabel')}</h4>
|
<h4 className="font-medium text-red-700 mb-2">
|
||||||
|
✗ {t('vgm.disadvantagesLabel')}
|
||||||
|
</h4>
|
||||||
<ul className="text-sm text-gray-600 space-y-1">
|
<ul className="text-sm text-gray-600 space-y-1">
|
||||||
{method.disadvantages.map((dis) => (
|
{method.disadvantages.map(dis => (
|
||||||
<li key={dis} className="flex items-center gap-2">
|
<li key={dis} className="flex items-center gap-2">
|
||||||
<span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />{dis}
|
<span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
|
||||||
|
{dis}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -122,7 +158,7 @@ export default async function VGMPage() {
|
|||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">{t('vgm.responsibilityTitle')}</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">{t('vgm.responsibilityTitle')}</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{responsibilities.map((r) => (
|
{responsibilities.map(r => (
|
||||||
<div key={r.role} className="bg-white p-4 rounded-lg border">
|
<div key={r.role} className="bg-white p-4 rounded-lg border">
|
||||||
<h4 className="font-medium text-gray-900">{r.role}</h4>
|
<h4 className="font-medium text-gray-900">{r.role}</h4>
|
||||||
<p className="text-sm text-gray-600 mt-1">{r.description}</p>
|
<p className="text-sm text-gray-600 mt-1">{r.description}</p>
|
||||||
@ -155,8 +191,11 @@ export default async function VGMPage() {
|
|||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{sanctions.map((s) => (
|
{sanctions.map(s => (
|
||||||
<div key={s.region} className="flex items-center justify-between py-3 border-b last:border-0">
|
<div
|
||||||
|
key={s.region}
|
||||||
|
className="flex items-center justify-between py-3 border-b last:border-0"
|
||||||
|
>
|
||||||
<span className="font-medium text-gray-900">{s.region}</span>
|
<span className="font-medium text-gray-900">{s.region}</span>
|
||||||
<span className="text-sm text-red-600">{s.sanction}</span>
|
<span className="text-sm text-red-600">{s.sanction}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -170,7 +209,9 @@ export default async function VGMPage() {
|
|||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-amber-900 mb-3">{t('vgm.tipsTitle')}</h3>
|
<h3 className="font-semibold text-amber-900 mb-3">{t('vgm.tipsTitle')}</h3>
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||||
{tips.map((tip, i) => <li key={i}>{tip}</li>)}
|
{tips.map((tip, i) => (
|
||||||
|
<li key={i}>{tip}</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
@ -24,9 +24,7 @@ export default function DemoPage() {
|
|||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Démo Carte Maritime</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Démo Carte Maritime</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">Visualisation de la route entre Marseille et Barcelone</p>
|
||||||
Visualisation de la route entre Marseille et Barcelone
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||||
@ -34,9 +32,7 @@ export default function DemoPage() {
|
|||||||
<h2 className="text-white text-lg font-semibold">
|
<h2 className="text-white text-lg font-semibold">
|
||||||
Route: Port de Marseille → Port de Barcelone
|
Route: Port de Marseille → Port de Barcelone
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-blue-100 text-sm mt-1">
|
<p className="text-blue-100 text-sm mt-1">Distance approximative: ~350 km par la mer</p>
|
||||||
Distance approximative: ~350 km par la mer
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PortRouteMap portA={portA} portB={portB} height="600px" />
|
<PortRouteMap portA={portA} portB={portB} height="600px" />
|
||||||
@ -46,12 +42,16 @@ export default function DemoPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900 mb-2">📍 Port d'origine</h3>
|
<h3 className="font-semibold text-gray-900 mb-2">📍 Port d'origine</h3>
|
||||||
<p className="text-gray-600">Marseille, France</p>
|
<p className="text-gray-600">Marseille, France</p>
|
||||||
<p className="text-gray-500 text-xs">Lat: {portA.lat}, Lng: {portA.lng}</p>
|
<p className="text-gray-500 text-xs">
|
||||||
|
Lat: {portA.lat}, Lng: {portA.lng}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900 mb-2">📍 Port de destination</h3>
|
<h3 className="font-semibold text-gray-900 mb-2">📍 Port de destination</h3>
|
||||||
<p className="text-gray-600">Barcelone, Espagne</p>
|
<p className="text-gray-600">Barcelone, Espagne</p>
|
||||||
<p className="text-gray-500 text-xs">Lat: {portB.lat}, Lng: {portB.lng}</p>
|
<p className="text-gray-500 text-xs">
|
||||||
|
Lat: {portB.lat}, Lng: {portB.lng}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -50,8 +50,18 @@ export default function ForgotPasswordPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-6">
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-6">
|
||||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
className="w-8 h-8 text-green-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-h1 text-brand-navy mb-2">{t('successTitle')}</h1>
|
<h1 className="text-h1 text-brand-navy mb-2">{t('successTitle')}</h1>
|
||||||
@ -61,9 +71,7 @@ export default function ForgotPasswordPage() {
|
|||||||
__html: t('successMessage', { email }),
|
__html: t('successMessage', { email }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-body-sm text-neutral-500 mt-3">
|
<p className="text-body-sm text-neutral-500 mt-3">{t('successHint')}</p>
|
||||||
{t('successHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Link href="/login" className="btn-primary w-full text-center block text-lg">
|
<Link href="/login" className="btn-primary w-full text-center block text-lg">
|
||||||
{t('backToLogin')}
|
{t('backToLogin')}
|
||||||
@ -73,15 +81,23 @@ export default function ForgotPasswordPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-h1 text-brand-navy mb-2">{t('title')}</h1>
|
<h1 className="text-h1 text-brand-navy mb-2">{t('title')}</h1>
|
||||||
<p className="text-body text-neutral-600">
|
<p className="text-body text-neutral-600">{t('subtitle')}</p>
|
||||||
{t('subtitle')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||||
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-body-sm text-red-800">{error}</p>
|
<p className="text-body-sm text-red-800">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -115,9 +131,17 @@ export default function ForgotPasswordPage() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
<Link href="/login" className="text-body-sm link flex items-center justify-center gap-2">
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-body-sm link flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backToLogin')}
|
{t('backToLogin')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -146,30 +170,54 @@ export default function ForgotPasswordPage() {
|
|||||||
<div className="absolute inset-0 flex flex-col justify-center px-16 xl:px-24 text-white">
|
<div className="absolute inset-0 flex flex-col justify-center px-16 xl:px-24 text-white">
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<h2 className="text-display-sm mb-6 text-white">{t('sidePanel.title')}</h2>
|
<h2 className="text-display-sm mb-6 text-white">{t('sidePanel.title')}</h2>
|
||||||
<p className="text-body-lg text-neutral-200 mb-12">
|
<p className="text-body-lg text-neutral-200 mb-12">{t('sidePanel.description')}</p>
|
||||||
{t('sidePanel.description')}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
|
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
|
||||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
className="w-6 h-6 text-white"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<h3 className="text-h5 mb-1 text-white">{t('sidePanel.features.secure.title')}</h3>
|
<h3 className="text-h5 mb-1 text-white">
|
||||||
<p className="text-body-sm text-neutral-300">{t('sidePanel.features.secure.description')}</p>
|
{t('sidePanel.features.secure.title')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-body-sm text-neutral-300">
|
||||||
|
{t('sidePanel.features.secure.description')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
|
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
|
||||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
className="w-6 h-6 text-white"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<h3 className="text-h5 mb-1 text-white">{t('sidePanel.features.email.title')}</h3>
|
<h3 className="text-h5 mb-1 text-white">{t('sidePanel.features.email.title')}</h3>
|
||||||
<p className="text-body-sm text-neutral-300">{t('sidePanel.features.email.description')}</p>
|
<p className="text-body-sm text-neutral-300">
|
||||||
|
{t('sidePanel.features.email.description')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -177,9 +225,30 @@ export default function ForgotPasswordPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-0 right-0 opacity-10">
|
<div className="absolute bottom-0 right-0 opacity-10">
|
||||||
<svg width="400" height="400" viewBox="0 0 400 400" fill="none">
|
<svg width="400" height="400" viewBox="0 0 400 400" fill="none">
|
||||||
<circle cx="200" cy="200" r="150" stroke="currentColor" strokeWidth="2" className="text-white" />
|
<circle
|
||||||
<circle cx="200" cy="200" r="100" stroke="currentColor" strokeWidth="2" className="text-white" />
|
cx="200"
|
||||||
<circle cx="200" cy="200" r="50" stroke="currentColor" strokeWidth="2" className="text-white" />
|
cy="200"
|
||||||
|
r="150"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className="text-white"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="200"
|
||||||
|
cy="200"
|
||||||
|
r="100"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className="text-white"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="200"
|
||||||
|
cy="200"
|
||||||
|
r="50"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className="text-white"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,11 +13,7 @@ export function generateStaticParams() {
|
|||||||
return routing.locales.map(locale => ({ locale }));
|
return routing.locales.map(locale => ({ locale }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<Params>;
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'metadata.home' });
|
const t = await getTranslations({ locale, namespace: 'metadata.home' });
|
||||||
|
|
||||||
|
|||||||
@ -135,9 +135,7 @@ function LoginPageContent() {
|
|||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-h1 text-brand-navy mb-2">{tLogin('title')}</h1>
|
<h1 className="text-h1 text-brand-navy mb-2">{tLogin('title')}</h1>
|
||||||
<p className="text-body text-neutral-600">
|
<p className="text-body text-neutral-600">{tLogin('subtitle')}</p>
|
||||||
{tLogin('subtitle')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@ -181,7 +179,10 @@ function LoginPageContent() {
|
|||||||
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
|
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
|
||||||
/>
|
/>
|
||||||
{fieldErrors.email && (
|
{fieldErrors.email && (
|
||||||
<p id="email-error" className="mt-1.5 text-body-sm text-red-600 flex items-center gap-1">
|
<p
|
||||||
|
id="email-error"
|
||||||
|
className="mt-1.5 text-body-sm text-red-600 flex items-center gap-1"
|
||||||
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@ -196,7 +197,10 @@ function LoginPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className={`label ${fieldErrors.password ? 'text-red-600' : ''}`}>
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className={`label ${fieldErrors.password ? 'text-red-600' : ''}`}
|
||||||
|
>
|
||||||
{tLogin('passwordLabel')}
|
{tLogin('passwordLabel')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -216,7 +220,10 @@ function LoginPageContent() {
|
|||||||
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
|
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
|
||||||
/>
|
/>
|
||||||
{fieldErrors.password && (
|
{fieldErrors.password && (
|
||||||
<p id="password-error" className="mt-1.5 text-body-sm text-red-600 flex items-center gap-1">
|
<p
|
||||||
|
id="password-error"
|
||||||
|
className="mt-1.5 text-body-sm text-red-600 flex items-center gap-1"
|
||||||
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@ -287,9 +294,7 @@ function LoginPageContent() {
|
|||||||
<div className="absolute inset-0 flex flex-col justify-center px-16 xl:px-24 text-white">
|
<div className="absolute inset-0 flex flex-col justify-center px-16 xl:px-24 text-white">
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<h2 className="text-display-sm mb-6 text-white">{tPanel('title')}</h2>
|
<h2 className="text-display-sm mb-6 text-white">{tPanel('title')}</h2>
|
||||||
<p className="text-body-lg text-neutral-200 mb-12">
|
<p className="text-body-lg text-neutral-200 mb-12">{tPanel('description')}</p>
|
||||||
{tPanel('description')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
@ -309,7 +314,9 @@ function LoginPageContent() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<h3 className="text-h5 mb-1 text-white">{tPanel('features.instantRates.title')}</h3>
|
<h3 className="text-h5 mb-1 text-white">
|
||||||
|
{tPanel('features.instantRates.title')}
|
||||||
|
</h3>
|
||||||
<p className="text-body-sm text-neutral-300">
|
<p className="text-body-sm text-neutral-300">
|
||||||
{tPanel('features.instantRates.description')}
|
{tPanel('features.instantRates.description')}
|
||||||
</p>
|
</p>
|
||||||
@ -372,11 +379,15 @@ function LoginPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-display-sm text-brand-turquoise">10k+</div>
|
<div className="text-display-sm text-brand-turquoise">10k+</div>
|
||||||
<div className="text-body-sm text-neutral-300 mt-1">{tPanel('stats.shipments')}</div>
|
<div className="text-body-sm text-neutral-300 mt-1">
|
||||||
|
{tPanel('stats.shipments')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-display-sm text-brand-turquoise">99.5%</div>
|
<div className="text-display-sm text-brand-turquoise">99.5%</div>
|
||||||
<div className="text-body-sm text-neutral-300 mt-1">{tPanel('stats.satisfaction')}</div>
|
<div className="text-body-sm text-neutral-300 mt-1">
|
||||||
|
{tPanel('stats.satisfaction')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,221 +1,238 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { motion, useInView } from 'framer-motion';
|
import { motion, useInView } from 'framer-motion';
|
||||||
import { Ship, Home, ArrowRight, LayoutDashboard, Anchor } from 'lucide-react';
|
import { Ship, Home, ArrowRight, LayoutDashboard, Anchor } from 'lucide-react';
|
||||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const heroRef = useRef(null);
|
const heroRef = useRef(null);
|
||||||
const isHeroInView = useInView(heroRef, { once: true });
|
const isHeroInView = useInView(heroRef, { once: true });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white flex flex-col">
|
<div className="min-h-screen bg-white flex flex-col">
|
||||||
<LandingHeader transparentOnTop={true} />
|
<LandingHeader transparentOnTop={true} />
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section
|
<section
|
||||||
ref={heroRef}
|
ref={heroRef}
|
||||||
className="relative flex-1 flex items-center justify-center overflow-hidden"
|
className="relative flex-1 flex items-center justify-center overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-brand-navy via-brand-navy/95 to-brand-navy/90">
|
<div className="absolute inset-0 bg-gradient-to-br from-brand-navy via-brand-navy/95 to-brand-navy/90">
|
||||||
<div className="absolute inset-0 opacity-10">
|
<div className="absolute inset-0 opacity-10">
|
||||||
<div className="absolute top-10 left-10 w-72 h-72 lg:w-96 lg:h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
<div className="absolute top-10 left-10 w-72 h-72 lg:w-96 lg:h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||||
<div className="absolute bottom-32 right-10 w-72 h-72 lg:w-96 lg:h-96 bg-brand-green rounded-full blur-3xl" />
|
<div className="absolute bottom-32 right-10 w-72 h-72 lg:w-96 lg:h-96 bg-brand-green rounded-full blur-3xl" />
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-brand-turquoise/50 rounded-full blur-3xl" />
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-brand-turquoise/50 rounded-full blur-3xl" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="relative z-10 max-w-4xl mx-auto px-6 text-center pt-32 pb-40">
|
<div className="relative z-10 max-w-4xl mx-auto px-6 text-center pt-32 pb-40">
|
||||||
{/* Badge */}
|
{/* Badge */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
|
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
|
||||||
transition={{ duration: 0.6, delay: 0.2 }}
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-10 border border-white/20"
|
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-10 border border-white/20"
|
||||||
>
|
>
|
||||||
<Anchor className="w-5 h-5 text-brand-turquoise" />
|
<Anchor className="w-5 h-5 text-brand-turquoise" />
|
||||||
<span className="text-white/90 text-sm font-medium font-heading">
|
<span className="text-white/90 text-sm font-medium font-heading">Erreur 404</span>
|
||||||
Erreur 404
|
</motion.div>
|
||||||
</span>
|
|
||||||
</motion.div>
|
{/* Animated Ship + Waves illustration */}
|
||||||
|
<motion.div
|
||||||
{/* Animated Ship + Waves illustration */}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
<motion.div
|
animate={isHeroInView ? { opacity: 1, scale: 1 } : {}}
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
transition={{ duration: 0.8, delay: 0.3 }}
|
||||||
animate={isHeroInView ? { opacity: 1, scale: 1 } : {}}
|
className="relative mb-8 flex justify-center"
|
||||||
transition={{ duration: 0.8, delay: 0.3 }}
|
>
|
||||||
className="relative mb-8 flex justify-center"
|
<div className="relative w-64 h-40 lg:w-80 lg:h-48">
|
||||||
>
|
{/* Ship */}
|
||||||
<div className="relative w-64 h-40 lg:w-80 lg:h-48">
|
<svg
|
||||||
{/* Ship */}
|
viewBox="0 0 200 120"
|
||||||
<svg
|
className="absolute inset-0 w-full h-full animate-float"
|
||||||
viewBox="0 0 200 120"
|
fill="none"
|
||||||
className="absolute inset-0 w-full h-full animate-float"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
{/* Hull */}
|
||||||
>
|
<path d="M40 75 L55 95 L145 95 L160 75 Z" fill="#34CCCD" opacity="0.9" />
|
||||||
{/* Hull */}
|
{/* Deck */}
|
||||||
<path
|
<rect x="60" y="58" width="80" height="17" rx="2" fill="white" opacity="0.9" />
|
||||||
d="M40 75 L55 95 L145 95 L160 75 Z"
|
{/* Bridge */}
|
||||||
fill="#34CCCD"
|
<rect x="85" y="35" width="35" height="23" rx="2" fill="white" opacity="0.85" />
|
||||||
opacity="0.9"
|
{/* Window */}
|
||||||
/>
|
<rect x="92" y="40" width="8" height="6" rx="1" fill="#34CCCD" opacity="0.7" />
|
||||||
{/* Deck */}
|
<rect x="105" y="40" width="8" height="6" rx="1" fill="#34CCCD" opacity="0.7" />
|
||||||
<rect x="60" y="58" width="80" height="17" rx="2" fill="white" opacity="0.9" />
|
{/* Smokestack */}
|
||||||
{/* Bridge */}
|
<rect x="95" y="22" width="12" height="13" rx="1" fill="#10183A" opacity="0.8" />
|
||||||
<rect x="85" y="35" width="35" height="23" rx="2" fill="white" opacity="0.85" />
|
<rect x="95" y="22" width="12" height="4" rx="1" fill="#34CCCD" opacity="0.6" />
|
||||||
{/* Window */}
|
{/* Mast */}
|
||||||
<rect x="92" y="40" width="8" height="6" rx="1" fill="#34CCCD" opacity="0.7" />
|
<line
|
||||||
<rect x="105" y="40" width="8" height="6" rx="1" fill="#34CCCD" opacity="0.7" />
|
x1="102"
|
||||||
{/* Smokestack */}
|
y1="10"
|
||||||
<rect x="95" y="22" width="12" height="13" rx="1" fill="#10183A" opacity="0.8" />
|
x2="102"
|
||||||
<rect x="95" y="22" width="12" height="4" rx="1" fill="#34CCCD" opacity="0.6" />
|
y2="22"
|
||||||
{/* Mast */}
|
stroke="white"
|
||||||
<line x1="102" y1="10" x2="102" y2="22" stroke="white" strokeWidth="1.5" opacity="0.7" />
|
strokeWidth="1.5"
|
||||||
{/* Flag */}
|
opacity="0.7"
|
||||||
<path d="M102 10 L115 15 L102 20" fill="#34CCCD" opacity="0.8" />
|
/>
|
||||||
{/* Containers on deck */}
|
{/* Flag */}
|
||||||
<rect x="65" y="60" width="12" height="10" rx="1" fill="#067224" opacity="0.7" />
|
<path d="M102 10 L115 15 L102 20" fill="#34CCCD" opacity="0.8" />
|
||||||
<rect x="79" y="60" width="12" height="10" rx="1" fill="#34CCCD" opacity="0.5" />
|
{/* Containers on deck */}
|
||||||
<rect x="130" y="60" width="12" height="10" rx="1" fill="#067224" opacity="0.5" />
|
<rect x="65" y="60" width="12" height="10" rx="1" fill="#067224" opacity="0.7" />
|
||||||
<rect x="144" y="60" width="10" height="10" rx="1" fill="white" opacity="0.3" />
|
<rect x="79" y="60" width="12" height="10" rx="1" fill="#34CCCD" opacity="0.5" />
|
||||||
</svg>
|
<rect x="130" y="60" width="12" height="10" rx="1" fill="#067224" opacity="0.5" />
|
||||||
|
<rect x="144" y="60" width="10" height="10" rx="1" fill="white" opacity="0.3" />
|
||||||
{/* Waves layer 1 */}
|
</svg>
|
||||||
<svg
|
|
||||||
viewBox="0 0 400 30"
|
{/* Waves layer 1 */}
|
||||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[150%] animate-wave"
|
<svg
|
||||||
preserveAspectRatio="none"
|
viewBox="0 0 400 30"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[150%] animate-wave"
|
||||||
>
|
preserveAspectRatio="none"
|
||||||
<path
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
d="M0,15 C50,5 100,25 150,15 C200,5 250,25 300,15 C350,5 400,25 400,15 L400,30 L0,30 Z"
|
>
|
||||||
fill="#34CCCD"
|
<path
|
||||||
opacity="0.3"
|
d="M0,15 C50,5 100,25 150,15 C200,5 250,25 300,15 C350,5 400,25 400,15 L400,30 L0,30 Z"
|
||||||
/>
|
fill="#34CCCD"
|
||||||
</svg>
|
opacity="0.3"
|
||||||
|
/>
|
||||||
{/* Waves layer 2 */}
|
</svg>
|
||||||
<svg
|
|
||||||
viewBox="0 0 400 30"
|
{/* Waves layer 2 */}
|
||||||
className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-[160%] animate-wave-slow"
|
<svg
|
||||||
preserveAspectRatio="none"
|
viewBox="0 0 400 30"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-[160%] animate-wave-slow"
|
||||||
>
|
preserveAspectRatio="none"
|
||||||
<path
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
d="M0,18 C60,8 120,28 180,18 C240,8 300,28 360,18 L400,18 L400,30 L0,30 Z"
|
>
|
||||||
fill="#34CCCD"
|
<path
|
||||||
opacity="0.15"
|
d="M0,18 C60,8 120,28 180,18 C240,8 300,28 360,18 L400,18 L400,30 L0,30 Z"
|
||||||
/>
|
fill="#34CCCD"
|
||||||
</svg>
|
opacity="0.15"
|
||||||
</div>
|
/>
|
||||||
</motion.div>
|
</svg>
|
||||||
|
</div>
|
||||||
{/* 404 */}
|
</motion.div>
|
||||||
<motion.h1
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
{/* 404 */}
|
||||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
<motion.h1
|
||||||
transition={{ duration: 0.8, delay: 0.4 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
className="text-8xl lg:text-[12rem] font-bold text-white mb-2 leading-none font-heading tracking-tight"
|
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||||
>
|
transition={{ duration: 0.8, delay: 0.4 }}
|
||||||
404
|
className="text-8xl lg:text-[12rem] font-bold text-white mb-2 leading-none font-heading tracking-tight"
|
||||||
</motion.h1>
|
>
|
||||||
|
404
|
||||||
{/* Subtitle */}
|
</motion.h1>
|
||||||
<motion.h2
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{/* Subtitle */}
|
||||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
<motion.h2
|
||||||
transition={{ duration: 0.8, delay: 0.5 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
className="text-3xl lg:text-5xl font-bold mb-6 font-heading"
|
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||||
>
|
transition={{ duration: 0.8, delay: 0.5 }}
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
className="text-3xl lg:text-5xl font-bold mb-6 font-heading"
|
||||||
Page introuvable
|
>
|
||||||
</span>
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||||
</motion.h2>
|
Page introuvable
|
||||||
|
</span>
|
||||||
{/* Description */}
|
</motion.h2>
|
||||||
<motion.p
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{/* Description */}
|
||||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
<motion.p
|
||||||
transition={{ duration: 0.8, delay: 0.6 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
className="text-lg lg:text-xl text-white/70 mb-12 max-w-2xl mx-auto leading-relaxed font-body"
|
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||||
>
|
transition={{ duration: 0.8, delay: 0.6 }}
|
||||||
Ce navire a pris le large... La page que vous cherchez
|
className="text-lg lg:text-xl text-white/70 mb-12 max-w-2xl mx-auto leading-relaxed font-body"
|
||||||
n'existe pas ou a été déplacée.
|
>
|
||||||
</motion.p>
|
Ce navire a pris le large... La page que vous cherchez n'existe pas ou a été
|
||||||
|
déplacée.
|
||||||
{/* CTA Buttons */}
|
</motion.p>
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{/* CTA Buttons */}
|
||||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
<motion.div
|
||||||
transition={{ duration: 0.8, delay: 0.7 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6"
|
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||||
>
|
transition={{ duration: 0.8, delay: 0.7 }}
|
||||||
<Link
|
className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6"
|
||||||
href="/"
|
>
|
||||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2 font-heading"
|
<Link
|
||||||
>
|
href="/"
|
||||||
<Home className="w-5 h-5" />
|
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2 font-heading"
|
||||||
<span>Retour à l'accueil</span>
|
>
|
||||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
<Home className="w-5 h-5" />
|
||||||
</Link>
|
<span>Retour à l'accueil</span>
|
||||||
|
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||||
<Link
|
</Link>
|
||||||
href="/dashboard"
|
|
||||||
className="group px-8 py-4 bg-white/10 backdrop-blur-sm text-white border border-white/20 rounded-lg hover:bg-white/20 transition-all hover:shadow-xl font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2 font-heading"
|
<Link
|
||||||
>
|
href="/dashboard"
|
||||||
<LayoutDashboard className="w-5 h-5" />
|
className="group px-8 py-4 bg-white/10 backdrop-blur-sm text-white border border-white/20 rounded-lg hover:bg-white/20 transition-all hover:shadow-xl font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2 font-heading"
|
||||||
<span>Tableau de bord</span>
|
>
|
||||||
</Link>
|
<LayoutDashboard className="w-5 h-5" />
|
||||||
</motion.div>
|
<span>Tableau de bord</span>
|
||||||
</div>
|
</Link>
|
||||||
|
</motion.div>
|
||||||
{/* Bottom wave */}
|
</div>
|
||||||
<div className="absolute bottom-0 left-0 right-0">
|
|
||||||
<svg
|
{/* Bottom wave */}
|
||||||
className="w-full h-16 lg:h-24"
|
<div className="absolute bottom-0 left-0 right-0">
|
||||||
viewBox="0 0 1440 120"
|
<svg
|
||||||
preserveAspectRatio="none"
|
className="w-full h-16 lg:h-24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
viewBox="0 0 1440 120"
|
||||||
>
|
preserveAspectRatio="none"
|
||||||
<path
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
d="M0,60 C240,90 480,30 720,60 C960,90 1200,30 1440,60 L1440,120 L0,120 Z"
|
>
|
||||||
fill="white"
|
<path
|
||||||
/>
|
d="M0,60 C240,90 480,30 720,60 C960,90 1200,30 1440,60 L1440,120 L0,120 Z"
|
||||||
</svg>
|
fill="white"
|
||||||
</div>
|
/>
|
||||||
</section>
|
</svg>
|
||||||
|
</div>
|
||||||
<LandingFooter />
|
</section>
|
||||||
|
|
||||||
<style jsx global>{`
|
<LandingFooter />
|
||||||
@keyframes float {
|
|
||||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
<style jsx global>{`
|
||||||
25% { transform: translateY(-6px) rotate(1deg); }
|
@keyframes float {
|
||||||
75% { transform: translateY(4px) rotate(-1deg); }
|
0%,
|
||||||
}
|
100% {
|
||||||
@keyframes wave {
|
transform: translateY(0px) rotate(0deg);
|
||||||
0% { transform: translateX(-50%) translateX(0); }
|
}
|
||||||
100% { transform: translateX(-50%) translateX(-50px); }
|
25% {
|
||||||
}
|
transform: translateY(-6px) rotate(1deg);
|
||||||
@keyframes wave-slow {
|
}
|
||||||
0% { transform: translateX(-50%) translateX(0); }
|
75% {
|
||||||
100% { transform: translateX(-50%) translateX(50px); }
|
transform: translateY(4px) rotate(-1deg);
|
||||||
}
|
}
|
||||||
.animate-float {
|
}
|
||||||
animation: float 4s ease-in-out infinite;
|
@keyframes wave {
|
||||||
}
|
0% {
|
||||||
.animate-wave {
|
transform: translateX(-50%) translateX(0);
|
||||||
animation: wave 3s ease-in-out infinite alternate;
|
}
|
||||||
}
|
100% {
|
||||||
.animate-wave-slow {
|
transform: translateX(-50%) translateX(-50px);
|
||||||
animation: wave-slow 4s ease-in-out infinite alternate;
|
}
|
||||||
}
|
}
|
||||||
`}</style>
|
@keyframes wave-slow {
|
||||||
</div>
|
0% {
|
||||||
);
|
transform: translateX(-50%) translateX(0);
|
||||||
}
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-50%) translateX(50px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-float {
|
||||||
|
animation: float 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.animate-wave {
|
||||||
|
animation: wave 3s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.animate-wave-slow {
|
||||||
|
animation: wave-slow 4s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -62,7 +62,13 @@ function AnimatedCounter({
|
|||||||
}, [end, duration, isActive]);
|
}, [end, duration, isActive]);
|
||||||
|
|
||||||
const display = decimals > 0 ? count.toFixed(decimals) : Math.floor(count).toString();
|
const display = decimals > 0 ? count.toFixed(decimals) : Math.floor(count).toString();
|
||||||
return <>{prefix}{display}{suffix}</>;
|
return (
|
||||||
|
<>
|
||||||
|
{prefix}
|
||||||
|
{display}
|
||||||
|
{suffix}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
@ -139,12 +145,18 @@ export default function LandingPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ end: 50, prefix: '', suffix: '+', decimals: 0, label: t('stats.carriers'), icon: Ship },
|
{ end: 50, prefix: '', suffix: '+', decimals: 0, label: t('stats.carriers'), icon: Ship },
|
||||||
{ end: 10, prefix: '', suffix: 'K+', decimals: 0, label: t('stats.ports'), icon: Anchor },
|
{ end: 10, prefix: '', suffix: 'K+', decimals: 0, label: t('stats.ports'), icon: Anchor },
|
||||||
{ end: 2, prefix: '<', suffix: 's', decimals: 0, label: t('stats.responseTime'), icon: Zap },
|
{ end: 2, prefix: '<', suffix: 's', decimals: 0, label: t('stats.responseTime'), icon: Zap },
|
||||||
{ end: 99.5, prefix: '', suffix: '%', decimals: 1, label: t('stats.availability'), icon: CheckCircle2 },
|
{
|
||||||
|
end: 99.5,
|
||||||
|
prefix: '',
|
||||||
|
suffix: '%',
|
||||||
|
decimals: 1,
|
||||||
|
label: t('stats.availability'),
|
||||||
|
icon: CheckCircle2,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const planFeaturesByKey: Record<string, Array<{ key: string; included: boolean }>> = {
|
const planFeaturesByKey: Record<string, Array<{ key: string; included: boolean }>> = {
|
||||||
@ -245,12 +257,13 @@ export default function LandingPage() {
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const testimonials = (t.raw('testimonials.items') as Array<{
|
const testimonials =
|
||||||
quote: string;
|
(t.raw('testimonials.items') as Array<{
|
||||||
author: string;
|
quote: string;
|
||||||
role: string;
|
author: string;
|
||||||
company: string;
|
role: string;
|
||||||
}>) ?? [];
|
company: string;
|
||||||
|
}>) ?? [];
|
||||||
|
|
||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
hidden: { opacity: 0, y: 50 },
|
hidden: { opacity: 0, y: 50 },
|
||||||
@ -289,10 +302,7 @@ export default function LandingPage() {
|
|||||||
playsInline
|
playsInline
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
>
|
>
|
||||||
<source
|
<source src="https://assets.mixkit.co/videos/36264/36264-720.mp4" type="video/mp4" />
|
||||||
src="https://assets.mixkit.co/videos/36264/36264-720.mp4"
|
|
||||||
type="video/mp4"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
@ -520,7 +530,6 @@ export default function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
{/* Partner Logos Section */}
|
{/* Partner Logos Section */}
|
||||||
<section className="py-16 bg-white">
|
<section className="py-16 bg-white">
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
@ -531,9 +540,7 @@ export default function LandingPage() {
|
|||||||
transition={{ duration: 0.6 }}
|
transition={{ duration: 0.6 }}
|
||||||
className="text-center mb-12"
|
className="text-center mb-12"
|
||||||
>
|
>
|
||||||
<h3 className="text-2xl font-bold text-brand-navy mb-2">
|
<h3 className="text-2xl font-bold text-brand-navy mb-2">{t('partners.title')}</h3>
|
||||||
{t('partners.title')}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600">{t('partners.subtitle')}</p>
|
<p className="text-gray-600">{t('partners.subtitle')}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -604,7 +611,9 @@ export default function LandingPage() {
|
|||||||
transition={{ duration: 0.6, delay: 0.2 }}
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
className="flex items-center justify-center gap-4 mb-12"
|
className="flex items-center justify-center gap-4 mb-12"
|
||||||
>
|
>
|
||||||
<span className={`text-sm font-medium ${!billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}>
|
<span
|
||||||
|
className={`text-sm font-medium ${!billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}
|
||||||
|
>
|
||||||
{t('pricing.monthly')}
|
{t('pricing.monthly')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@ -619,7 +628,9 @@ export default function LandingPage() {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<span className={`text-sm font-medium ${billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}>
|
<span
|
||||||
|
className={`text-sm font-medium ${billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}
|
||||||
|
>
|
||||||
{t('pricing.yearly')}
|
{t('pricing.yearly')}
|
||||||
</span>
|
</span>
|
||||||
{billingYearly && (
|
{billingYearly && (
|
||||||
@ -635,7 +646,7 @@ export default function LandingPage() {
|
|||||||
animate={isPricingInView ? 'visible' : 'hidden'}
|
animate={isPricingInView ? 'visible' : 'hidden'}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch"
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch"
|
||||||
>
|
>
|
||||||
{pricingPlans.map((plan) => {
|
{pricingPlans.map(plan => {
|
||||||
const planName = t(`pricing.plans.${plan.key}.name` as any);
|
const planName = t(`pricing.plans.${plan.key}.name` as any);
|
||||||
const planDescription = t(`pricing.plans.${plan.key}.description` as any);
|
const planDescription = t(`pricing.plans.${plan.key}.description` as any);
|
||||||
const planUsers = t(`pricing.plans.${plan.key}.users` as any);
|
const planUsers = t(`pricing.plans.${plan.key}.users` as any);
|
||||||
@ -673,11 +684,17 @@ export default function LandingPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col flex-1 p-6">
|
<div className="flex flex-col flex-1 p-6">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className={`inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider px-2.5 py-1 rounded-full mb-3 ${plan.highlighted ? 'bg-white/10 text-white/70' : plan.badgeBg}`}>
|
<div
|
||||||
<div className={`w-1.5 h-1.5 rounded-full bg-gradient-to-r ${plan.accentColor}`} />
|
className={`inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider px-2.5 py-1 rounded-full mb-3 ${plan.highlighted ? 'bg-white/10 text-white/70' : plan.badgeBg}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-1.5 h-1.5 rounded-full bg-gradient-to-r ${plan.accentColor}`}
|
||||||
|
/>
|
||||||
{planName}
|
{planName}
|
||||||
</div>
|
</div>
|
||||||
<h3 className={`text-xl font-bold mb-1 ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
|
<h3
|
||||||
|
className={`text-xl font-bold mb-1 ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}
|
||||||
|
>
|
||||||
{planDescription}
|
{planDescription}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@ -685,34 +702,48 @@ export default function LandingPage() {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
{plan.monthlyPrice === null ? (
|
{plan.monthlyPrice === null ? (
|
||||||
<div>
|
<div>
|
||||||
<span className={`text-3xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
|
<span
|
||||||
|
className={`text-3xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}
|
||||||
|
>
|
||||||
{t('pricing.custom')}
|
{t('pricing.custom')}
|
||||||
</span>
|
</span>
|
||||||
<p className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
|
<p
|
||||||
|
className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
{t('pricing.customSubtitle')}
|
{t('pricing.customSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : plan.monthlyPrice === 0 ? (
|
) : plan.monthlyPrice === 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<span className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
|
<span
|
||||||
|
className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}
|
||||||
|
>
|
||||||
{t('pricing.free')}
|
{t('pricing.free')}
|
||||||
</span>
|
</span>
|
||||||
<p className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
|
<p
|
||||||
|
className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
{t('pricing.freeSubtitle')}
|
{t('pricing.freeSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-end gap-1">
|
<div className="flex items-end gap-1">
|
||||||
<span className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
|
<span
|
||||||
|
className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}
|
||||||
|
>
|
||||||
{billingYearly ? plan.yearlyMonthly : plan.monthlyPrice}€
|
{billingYearly ? plan.yearlyMonthly : plan.monthlyPrice}€
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-sm pb-1.5 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
|
<span
|
||||||
|
className={`text-sm pb-1.5 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
{t('pricing.perMonth')}
|
{t('pricing.perMonth')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{billingYearly ? (
|
{billingYearly ? (
|
||||||
<p className={`text-xs mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
|
<p
|
||||||
|
className={`text-xs mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
{t('pricing.billedYearly', {
|
{t('pricing.billedYearly', {
|
||||||
price: numberFormat.format(plan.yearlyPrice ?? 0),
|
price: numberFormat.format(plan.yearlyPrice ?? 0),
|
||||||
})}
|
})}
|
||||||
@ -726,18 +757,30 @@ export default function LandingPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`rounded-xl p-3 mb-5 space-y-2 ${plan.highlighted ? 'bg-white/10' : 'bg-gray-50'}`}>
|
<div
|
||||||
|
className={`rounded-xl p-3 mb-5 space-y-2 ${plan.highlighted ? 'bg-white/10' : 'bg-gray-50'}`}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
|
<Users className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
|
||||||
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}>{planUsers}</span>
|
<span
|
||||||
|
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}
|
||||||
|
>
|
||||||
|
{planUsers}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Ship className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
|
<Ship className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
|
||||||
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}>{planShipments}</span>
|
<span
|
||||||
|
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}
|
||||||
|
>
|
||||||
|
{planShipments}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BarChart3 className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
|
<BarChart3 className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
|
||||||
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}>
|
<span
|
||||||
|
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}
|
||||||
|
>
|
||||||
{t('pricing.commission', { rate: plan.commission })}
|
{t('pricing.commission', { rate: plan.commission })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -747,15 +790,25 @@ export default function LandingPage() {
|
|||||||
{planFeatures.map((feature, featureIndex) => (
|
{planFeatures.map((feature, featureIndex) => (
|
||||||
<li key={featureIndex} className="flex items-start gap-2.5">
|
<li key={featureIndex} className="flex items-start gap-2.5">
|
||||||
{feature.included ? (
|
{feature.included ? (
|
||||||
<Check className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-green'}`} />
|
<Check
|
||||||
|
className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-green'}`}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<X className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-white/20' : 'text-gray-300'}`} />
|
<X
|
||||||
|
className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-white/20' : 'text-gray-300'}`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<span className={`text-sm ${
|
<span
|
||||||
feature.included
|
className={`text-sm ${
|
||||||
? plan.highlighted ? 'text-white/90' : 'text-gray-700'
|
feature.included
|
||||||
: plan.highlighted ? 'text-white/30' : 'text-gray-400'
|
? plan.highlighted
|
||||||
}`}>
|
? 'text-white/90'
|
||||||
|
: 'text-gray-700'
|
||||||
|
: plan.highlighted
|
||||||
|
? 'text-white/30'
|
||||||
|
: 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{t(`pricing.features.${feature.key}` as any)}
|
{t(`pricing.features.${feature.key}` as any)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@ -768,8 +821,8 @@ export default function LandingPage() {
|
|||||||
plan.highlighted
|
plan.highlighted
|
||||||
? 'bg-brand-turquoise text-white hover:bg-brand-turquoise/90 shadow-lg shadow-brand-turquoise/30 hover:shadow-xl'
|
? 'bg-brand-turquoise text-white hover:bg-brand-turquoise/90 shadow-lg shadow-brand-turquoise/30 hover:shadow-xl'
|
||||||
: plan.key === 'bronze'
|
: plan.key === 'bronze'
|
||||||
? 'bg-gray-100 text-brand-navy hover:bg-gray-200'
|
? 'bg-gray-100 text-brand-navy hover:bg-gray-200'
|
||||||
: 'bg-brand-navy text-white hover:bg-brand-navy/90 shadow-md hover:shadow-lg'
|
: 'bg-brand-navy text-white hover:bg-brand-navy/90 shadow-md hover:shadow-lg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{planCta}
|
{planCta}
|
||||||
@ -786,9 +839,7 @@ export default function LandingPage() {
|
|||||||
transition={{ duration: 0.8, delay: 0.5 }}
|
transition={{ duration: 0.8, delay: 0.5 }}
|
||||||
className="mt-12 text-center space-y-2"
|
className="mt-12 text-center space-y-2"
|
||||||
>
|
>
|
||||||
<p className="text-gray-600 text-sm">
|
<p className="text-gray-600 text-sm">{t('pricing.noCommitment')}</p>
|
||||||
{t('pricing.noCommitment')}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{t('pricing.questions')}{' '}
|
{t('pricing.questions')}{' '}
|
||||||
<Link href="/contact" className="text-brand-turquoise font-medium hover:underline">
|
<Link href="/contact" className="text-brand-turquoise font-medium hover:underline">
|
||||||
@ -816,10 +867,10 @@ export default function LandingPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="text-center mb-16"
|
className="text-center mb-16"
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-4">{t('howItWorks.title')}</h2>
|
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-4">
|
||||||
<p className="text-xl text-white/80 max-w-2xl mx-auto">
|
{t('howItWorks.title')}
|
||||||
{t('howItWorks.subtitle')}
|
</h2>
|
||||||
</p>
|
<p className="text-xl text-white/80 max-w-2xl mx-auto">{t('howItWorks.subtitle')}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -886,9 +937,7 @@ export default function LandingPage() {
|
|||||||
<span>{tCommon('tryNow')}</span>
|
<span>{tCommon('tryNow')}</span>
|
||||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-3 text-white/50 text-sm">
|
<p className="mt-3 text-white/50 text-sm">{t('howItWorks.ctaHint')}</p>
|
||||||
{t('howItWorks.ctaHint')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -962,9 +1011,7 @@ export default function LandingPage() {
|
|||||||
<h2 className="text-2xl sm:text-4xl lg:text-5xl font-bold text-brand-navy mb-4 sm:mb-6">
|
<h2 className="text-2xl sm:text-4xl lg:text-5xl font-bold text-brand-navy mb-4 sm:mb-6">
|
||||||
{t('cta.title')}
|
{t('cta.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base sm:text-xl text-gray-600 mb-8 sm:mb-10">
|
<p className="text-base sm:text-xl text-gray-600 mb-8 sm:mb-10">{t('cta.subtitle')}</p>
|
||||||
{t('cta.subtitle')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@ -106,7 +106,10 @@ export default function PressPage() {
|
|||||||
<LandingHeader activePage="press" />
|
<LandingHeader activePage="press" />
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
<section
|
||||||
|
ref={heroRef}
|
||||||
|
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
|
||||||
|
>
|
||||||
<div className="absolute inset-0 opacity-10">
|
<div className="absolute inset-0 opacity-10">
|
||||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||||
@ -203,9 +206,7 @@ export default function PressPage() {
|
|||||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||||
{t('releasesTitle')}
|
{t('releasesTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('releasesSubtitle')}</p>
|
||||||
{t('releasesSubtitle')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -214,7 +215,7 @@ export default function PressPage() {
|
|||||||
animate={isNewsInView ? 'visible' : 'hidden'}
|
animate={isNewsInView ? 'visible' : 'hidden'}
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
{RELEASES.map((release) => (
|
{RELEASES.map(release => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={release.id}
|
key={release.id}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
@ -272,9 +273,7 @@ export default function PressPage() {
|
|||||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||||
{t('coverageTitle')}
|
{t('coverageTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('coverageSubtitle')}</p>
|
||||||
{t('coverageSubtitle')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@ -303,7 +302,9 @@ export default function PressPage() {
|
|||||||
<h3 className="text-lg font-bold text-brand-navy mb-2 group-hover:text-brand-turquoise transition-colors">
|
<h3 className="text-lg font-bold text-brand-navy mb-2 group-hover:text-brand-turquoise transition-colors">
|
||||||
{t(`coverage.${article.key}.title`)}
|
{t(`coverage.${article.key}.title`)}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-gray-500 text-sm">{t(`coverage.${article.key}.date`)}</span>
|
<span className="text-gray-500 text-sm">
|
||||||
|
{t(`coverage.${article.key}.date`)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
@ -321,12 +322,8 @@ export default function PressPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="text-center mb-16"
|
className="text-center mb-16"
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('kitTitle')}</h2>
|
||||||
{t('kitTitle')}
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('kitSubtitle')}</p>
|
||||||
</h2>
|
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
|
||||||
{t('kitSubtitle')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -335,7 +332,7 @@ export default function PressPage() {
|
|||||||
animate={isResourcesInView ? 'visible' : 'hidden'}
|
animate={isResourcesInView ? 'visible' : 'hidden'}
|
||||||
className="grid grid-cols-1 md:grid-cols-3 gap-8"
|
className="grid grid-cols-1 md:grid-cols-3 gap-8"
|
||||||
>
|
>
|
||||||
{KIT_ITEMS.map((item) => {
|
{KIT_ITEMS.map(item => {
|
||||||
const IconComponent = item.icon;
|
const IconComponent = item.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -347,7 +344,9 @@ export default function PressPage() {
|
|||||||
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-2xl flex items-center justify-center mb-6">
|
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-2xl flex items-center justify-center mb-6">
|
||||||
<IconComponent className="w-8 h-8 text-brand-turquoise" />
|
<IconComponent className="w-8 h-8 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`kit.${item.key}.title`)}</h3>
|
<h3 className="text-xl font-bold text-brand-navy mb-2">
|
||||||
|
{t(`kit.${item.key}.title`)}
|
||||||
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">{t(`kit.${item.key}.description`)}</p>
|
<p className="text-gray-600 mb-4">{t(`kit.${item.key}.description`)}</p>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-500">{t(`kit.${item.key}.format`)}</span>
|
<span className="text-sm text-gray-500">{t(`kit.${item.key}.format`)}</span>
|
||||||
@ -367,7 +366,10 @@ export default function PressPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Milestones */}
|
{/* Milestones */}
|
||||||
<section ref={milestonesRef} className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95">
|
<section
|
||||||
|
ref={milestonesRef}
|
||||||
|
className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95"
|
||||||
|
>
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
@ -378,9 +380,7 @@ export default function PressPage() {
|
|||||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-4">
|
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-4">
|
||||||
{t('milestonesTitle')}
|
{t('milestonesTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-white/80 max-w-2xl mx-auto">
|
<p className="text-xl text-white/80 max-w-2xl mx-auto">{t('milestonesSubtitle')}</p>
|
||||||
{t('milestonesSubtitle')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-center gap-8">
|
<div className="flex flex-wrap justify-center gap-8">
|
||||||
@ -397,8 +397,12 @@ export default function PressPage() {
|
|||||||
<div className="w-20 h-20 bg-brand-turquoise rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-20 h-20 bg-brand-turquoise rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<IconComponent className="w-10 h-10 text-white" />
|
<IconComponent className="w-10 h-10 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-brand-turquoise mb-1">{milestone.key}</div>
|
<div className="text-2xl font-bold text-brand-turquoise mb-1">
|
||||||
<div className="text-white/80 max-w-[150px]">{t(`milestones.${milestone.key}` as any)}</div>
|
{milestone.key}
|
||||||
|
</div>
|
||||||
|
<div className="text-white/80 max-w-[150px]">
|
||||||
|
{t(`milestones.${milestone.key}` as any)}
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -418,9 +422,7 @@ export default function PressPage() {
|
|||||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||||
{t('contact.title')}
|
{t('contact.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('contact.body')}</p>
|
||||||
{t('contact.body')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -431,7 +433,9 @@ export default function PressPage() {
|
|||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-bold text-brand-navy mb-6">{t('contact.relationsTitle')}</h3>
|
<h3 className="text-2xl font-bold text-brand-navy mb-6">
|
||||||
|
{t('contact.relationsTitle')}
|
||||||
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center">
|
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center">
|
||||||
@ -465,17 +469,19 @@ export default function PressPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-bold text-brand-navy mb-6">{t('contact.responsibleTitle')}</h3>
|
<h3 className="text-2xl font-bold text-brand-navy mb-6">
|
||||||
|
{t('contact.responsibleTitle')}
|
||||||
|
</h3>
|
||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center flex-shrink-0">
|
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
<Users className="w-8 h-8 text-brand-turquoise" />
|
<Users className="w-8 h-8 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg font-bold text-brand-navy">Camille Dumont</div>
|
<div className="text-lg font-bold text-brand-navy">Camille Dumont</div>
|
||||||
<div className="text-brand-turquoise font-medium mb-2">{t('contact.responsibleRole')}</div>
|
<div className="text-brand-turquoise font-medium mb-2">
|
||||||
<p className="text-gray-600 text-sm">
|
{t('contact.responsibleRole')}
|
||||||
{t('contact.responsibleBio')}
|
</div>
|
||||||
</p>
|
<p className="text-gray-600 text-sm">{t('contact.responsibleBio')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -485,9 +491,7 @@ export default function PressPage() {
|
|||||||
<div className="flex items-start space-x-4 bg-gray-50 p-6 rounded-xl">
|
<div className="flex items-start space-x-4 bg-gray-50 p-6 rounded-xl">
|
||||||
<Quote className="w-8 h-8 text-brand-turquoise flex-shrink-0" />
|
<Quote className="w-8 h-8 text-brand-turquoise flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-600 italic mb-4">
|
<p className="text-gray-600 italic mb-4">“{t('contact.quote')}”</p>
|
||||||
“{t('contact.quote')}”
|
|
||||||
</p>
|
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-bold text-brand-navy">Jean-Pierre Durand</span>
|
<span className="font-bold text-brand-navy">Jean-Pierre Durand</span>
|
||||||
<span className="text-gray-500">{t('contact.quoteRole')}</span>
|
<span className="text-gray-500">{t('contact.quoteRole')}</span>
|
||||||
|
|||||||
@ -177,7 +177,9 @@ export default function PricingPage() {
|
|||||||
|
|
||||||
{/* Billing toggle */}
|
{/* Billing toggle */}
|
||||||
<div className="flex items-center justify-center gap-4 mb-12">
|
<div className="flex items-center justify-center gap-4 mb-12">
|
||||||
<span className={`text-sm font-medium ${billing === 'monthly' ? 'text-gray-900' : 'text-gray-500'}`}>
|
<span
|
||||||
|
className={`text-sm font-medium ${billing === 'monthly' ? 'text-gray-900' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
{t('hero.monthly')}
|
{t('hero.monthly')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@ -192,7 +194,9 @@ export default function PricingPage() {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<span className={`text-sm font-medium ${billing === 'yearly' ? 'text-gray-900' : 'text-gray-500'}`}>
|
<span
|
||||||
|
className={`text-sm font-medium ${billing === 'yearly' ? 'text-gray-900' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
{t('hero.yearly')}
|
{t('hero.yearly')}
|
||||||
</span>
|
</span>
|
||||||
{billing === 'yearly' && (
|
{billing === 'yearly' && (
|
||||||
@ -206,7 +210,7 @@ export default function PricingPage() {
|
|||||||
{/* Plans grid */}
|
{/* Plans grid */}
|
||||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{PLANS.map((plan) => (
|
{PLANS.map(plan => (
|
||||||
<div
|
<div
|
||||||
key={plan.key}
|
key={plan.key}
|
||||||
className={`relative rounded-2xl border-2 p-6 flex flex-col ${
|
className={`relative rounded-2xl border-2 p-6 flex flex-col ${
|
||||||
@ -227,11 +231,15 @@ export default function PricingPage() {
|
|||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<h3 className="text-xl font-bold text-gray-900">{t(`plans.${plan.key}.name`)}</h3>
|
<h3 className="text-xl font-bold text-gray-900">{t(`plans.${plan.key}.name`)}</h3>
|
||||||
{plan.badge && (
|
{plan.badge && (
|
||||||
<Shield className={`w-5 h-5 ${
|
<Shield
|
||||||
plan.badge === 'silver' ? 'text-slate-500' :
|
className={`w-5 h-5 ${
|
||||||
plan.badge === 'gold' ? 'text-yellow-500' :
|
plan.badge === 'silver'
|
||||||
'text-purple-500'
|
? 'text-slate-500'
|
||||||
}`} />
|
: plan.badge === 'gold'
|
||||||
|
? 'text-yellow-500'
|
||||||
|
: 'text-purple-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -249,7 +257,9 @@ export default function PricingPage() {
|
|||||||
{billing === 'monthly'
|
{billing === 'monthly'
|
||||||
? formatPrice(plan.monthlyPrice)
|
? formatPrice(plan.monthlyPrice)
|
||||||
: formatPrice(Math.round(plan.yearlyPrice / 12))}
|
: formatPrice(Math.round(plan.yearlyPrice / 12))}
|
||||||
<span className="text-base font-normal text-gray-500">{t('currency.perMonth')}</span>
|
<span className="text-base font-normal text-gray-500">
|
||||||
|
{t('currency.perMonth')}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{billing === 'yearly' && (
|
{billing === 'yearly' && (
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
@ -284,7 +294,7 @@ export default function PricingPage() {
|
|||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<div className="flex-1 space-y-2 mb-6">
|
<div className="flex-1 space-y-2 mb-6">
|
||||||
{plan.features.map((feature) => (
|
{plan.features.map(feature => (
|
||||||
<div key={feature.key} className="flex items-center gap-2 text-sm">
|
<div key={feature.key} className="flex items-center gap-2 text-sm">
|
||||||
{feature.included ? (
|
{feature.included ? (
|
||||||
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user