Compare commits

..

10 Commits

Author SHA1 Message Date
David
0dc56ff1eb feat(bookings): reservation quota based on org plan + PAID bookings
- Nouvel endpoint GET /csv-bookings/reservation-quota: limite = vrai plan de
  l'org (pas l'override ADMIN PLATINIUM), used = bookings PAYES de l'annee
  (PENDING_BANK_TRANSFER/PENDING/ACCEPTED), limitReached.
- ShipmentCounter: countPaidShipmentsForOrganizationInYear.
- create-check compte desormais les payes (au lieu de tous).
- Frontend useReservationQuota consomme l'endpoint (fiable meme pour un admin
  dont l'overview renvoie PLATINIUM).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:36:15 +02:00
David
f7427825d1 feat(bookings): block new reservations on free plan limit + upgrade CTA
Le plan gratuit (BRONZE) est limite a maxShipmentsPerYear (5/an). Cote frontend,
rien ne le refletait: le bouton 'Nouvelle Reservation' restait actif. Ajout d'un
hook useReservationQuota (limite du plan vs reservations de l'annee) qui:
- remplace le bouton 'Nouvelle Reservation' par un bouton 'Ameliorer mon
  abonnement' + banniere sur la liste des reservations quand la limite est
  atteinte;
- bloque la page de recherche (nouvelle resa) avec un ecran d'upgrade, sauf en
  mode edition d'une resa existante.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:02:56 +02:00
David
c312e7901e fix(auth): normalize email to lowercase on register/login + rollback orphaned org
L'email n'etait pas normalise, or la contrainte chk_users_email exige des
minuscules. Un email avec majuscules faisait echouer l'INSERT utilisateur APRES
la creation de l'organisation (flux non transactionnel), laissant une org
orpheline -> 'An organization with this name already exists' aux tentatives
suivantes.

- register/login: email.trim().toLowerCase()
- register: rollback de l'organisation nouvellement creee si la creation de
  l'utilisateur echoue (anti-orphelin)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:24:57 +02:00
David
64de600292 fix(booking-edit): reconstruct pallet dims with 120x80 base (not a cube)
La reprise reconstruisait un cube (cbrt du volume) au lieu de la base palette
standard, d'ou des longueur/largeur/hauteur incorrectes. Pour une palette on
reconstitue desormais 120x80 cm et on deduit la hauteur du volume (ex: 0.96 CBM
-> 120x80x100). Les colis restent en cube equivalent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:12:28 +02:00
David
c5f823f8b7 feat(bookings): persist Options & Services (customs/insurance/DG/handling)
Les options du formulaire de recherche etaient collectees mais jamais stockees.
Ajout d'une colonne jsonb options sur csv_bookings (+ migration), stockee a la
creation et a l'edition (editFromRate), renvoyee dans la reponse, et re-pre-
remplie a la reprise (search-advanced <- URL <- booking.options).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:12:18 +02:00
David
795e635df3 fix(bookings): persist editable fields on update (toOrmUpdate)
toOrmUpdate ne mappait que status/notes/commission, donc les modifications de
volume/poids/palettes/prix/transporteur/route via editDetails/editFromRate
n'etaient jamais persistees (repository.update). Ajout de tous les champs
editables au mapping de mise a jour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:41:16 +02:00
David
6cdbf29bf7 feat(booking-edit): reprise via le parcours existant pré-rempli
Remplace la page d'édition autonome par une reprise du parcours de réservation :
"Modifier" ré-ouvre la recherche pré-remplie (origine/destination/volume/poids/
palettes reconstruits), l'utilisateur ajuste si besoin, choisit une compagnie
(résultats) et passe au paiement — la réservation existante est mise à jour au
lieu d'en créer une nouvelle.

- Backend: PATCH /csv-bookings/:id/rate + UpdateCsvBookingRateDto + service
  updateBookingRate + domaine editFromRate (carrier/route/conteneur/transit/
  cargo/prix modifiables avant paiement).
- Front: search-advanced pré-rempli via query (+ editBookingId, démarre à
  l'étape colis) ; results en mode édition met à jour puis redirige vers /pay ;
  liens "Modifier" (liste, modale, paiement) pointent vers le parcours ;
  suppression de la page /booking/:id/edit autonome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:36:19 +02:00
David
25fe78ccfc fix(booking-edit): recalcul du prix cassé par le format du nom transporteur
La recherche de tarif renvoie le transporteur en slug ("ssc-consolidation")
alors que la réservation stocke le nom d'affichage ("SSC Consolidation"), donc
le matching exact companyName === carrierName ne trouvait jamais l'offre →
"aucun tarif trouvé" et prix jamais recalculé.

Matching robuste : comparaison normalisée du transporteur (minuscules, sans
séparateurs) + conteneur + transit, avec fallbacks. Vérifié en conditions
réelles (GET booking, search-csv-offers, PATCH details renvoient 200 ; le prix
change bien quand le volume change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:16:42 +02:00
David
bce77edd76 fix(bookings,docs): suppression docs, edition+recalcul prix, menu 3 points, responsive
- Suppression de documents: cause = deleteDocument restreint aux statuts
  PENDING/PENDING_PAYMENT + blocage du dernier document. Alignement sur
  replaceDocument (aucune restriction de statut) et autorisation de 0 document
  (validation domaine assouplie ; creation exige toujours >=1 doc cote service).
- Edition avant paiement: recalcul auto du prix. La page /booking/:id/edit
  relance la recherche de tarif (meme transporteur/conteneur/transit) pour le
  nouveau volume/poids et met a jour fret/FOB/total ; nouveaux champs de prix
  dans UpdateCsvBookingDetailsDto + editDetails.
- Liste reservations: actions regroupees dans un menu 3 points (kebab) en
  positionnement fixed (anti-clipping), desktop + mobile.
- Responsive (<=1280px): tables documents & blog admin en overflow-x-auto,
  menus documents en fixed, grilles de la page edition en sm:.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:56:25 +02:00
David
8877265526 feat(blog): Phase 3 GEO/IA, corbeille, duplication, drag&drop, recadrage
- Bloc GEO/IA (resume IA, FAQ, points cles, entites) + rendu public
  "En bref"/"A retenir"/FAQ + JSON-LD Article & FAQPage.
- Corbeille: soft-delete (deleted_at), filtres Tous/Publies/Brouillons/
  Planifies/Corbeille, restauration + suppression definitive.
- Duplication d'un article (nouveau brouillon, slug unique).
- Drag & drop de l'image de couverture.
- Recadrage auto de la couverture en 16:9 (sharp) via endpoint dedie
  /admin/blog/cover-images.
- Migration AddGeoAndTrashToBlogPosts (ai_summary, faq, key_takeaways,
  ai_entities, deleted_at). Ajout dependance sharp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:44:35 +02:00
43 changed files with 2766 additions and 677 deletions

View File

@ -60,6 +60,7 @@
"react-leaflet": "^5.0.0",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"sharp": "^0.35.3",
"socket.io": "^4.8.1",
"stripe": "^14.14.0",
"typeorm": "^0.3.17",
@ -1444,6 +1445,16 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
@ -1696,6 +1707,554 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@ioredis/as-callback": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@ioredis/as-callback/-/as-callback-3.0.0.tgz",
@ -12858,9 +13417,9 @@
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@ -12972,6 +13531,55 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sharp": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@ -76,6 +76,7 @@
"react-leaflet": "^5.0.0",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"sharp": "^0.35.3",
"socket.io": "^4.8.1",
"stripe": "^14.14.0",
"typeorm": "^0.3.17",

View File

@ -70,6 +70,12 @@ export class AuthService {
organizationData?: RegisterOrganizationDto,
invitationRole?: string
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
// Emails are stored lowercase (enforced by the chk_users_email DB CHECK
// constraint), so normalize before any lookup or insert. Without this, an
// address containing uppercase letters fails the user INSERT after the
// organization has already been created — leaving an orphaned organization.
email = email.trim().toLowerCase();
this.logger.log(`Registering new user: ${email}`);
const existingUser = await this.userRepository.findByEmail(email);
@ -90,6 +96,9 @@ export class AuthService {
// 2. If organizationData is provided (new user), create a new organization
// 3. Otherwise, use default organization
const finalOrganizationId = await this.resolveOrganizationId(organizationId, organizationData);
// Track whether a brand-new organization was created for this registration,
// so it can be rolled back if the subsequent user creation fails.
const createdNewOrg = !organizationId && !!organizationData;
// Determine role:
// - If invitation role is provided (invited user), use it
@ -121,7 +130,28 @@ export class AuthService {
role: userRole,
});
const savedUser = await this.userRepository.save(user);
let savedUser: User;
try {
savedUser = await this.userRepository.save(user);
} catch (err) {
// The organization is created before the user (non-atomic flow). If the
// user INSERT fails, roll the organization back so a retry isn't blocked
// by an orphaned organization ("name already exists").
if (createdNewOrg) {
try {
await this.organizationRepository.deleteById(finalOrganizationId);
this.logger.warn(
`Rolled back orphaned organization ${finalOrganizationId} after failed user creation`
);
} catch (cleanupErr) {
this.logger.error(
`Failed to roll back organization ${finalOrganizationId}`,
cleanupErr as Error
);
}
}
throw err;
}
// Allocate a license for the new user
try {
@ -158,6 +188,7 @@ export class AuthService {
password: string,
rememberMe = false
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
email = email.trim().toLowerCase();
this.logger.log(`Login attempt for: ${email}`);
const user = await this.userRepository.findByEmail(email);

View File

@ -24,6 +24,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { v4 as uuidv4 } from 'uuid';
import * as path from 'path';
import sharp from 'sharp';
import {
ApiTags,
ApiOperation,
@ -1001,17 +1002,79 @@ export class AdminController {
return { url: `/api/v1/blog/images/${filename}`, filename };
}
@Post('blog/cover-images')
@UseInterceptors(
FileInterceptor('image', {
storage: memoryStorage(),
limits: { fileSize: MAX_IMAGE_SIZE },
fileFilter: (_req, file, cb) => {
// SVG cannot be raster-cropped; restrict to raster formats.
if (['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.mimetype)) {
cb(null, true);
} else {
cb(new BadRequestException('Only JPG, PNG, WebP or GIF images are allowed'), false);
}
},
})
)
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Upload and auto-crop a blog cover image (Admin only)',
description:
'Resizes and crops the image to the public 16:9 card ratio (1280x720) so covers are never distorted or cut off on the public site.',
})
@ApiResponse({
status: 201,
schema: { properties: { url: { type: 'string' }, filename: { type: 'string' } } },
})
async uploadBlogCoverImage(
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: UserPayload
): Promise<{ url: string; filename: string }> {
if (!file) throw new BadRequestException('No image file provided');
this.logger.log(`[ADMIN: ${user.email}] Uploading blog cover image: ${file.originalname}`);
// Crop to the public blog card ratio (16:9). "attention" focuses on the most
// salient region so important content is preserved.
let processed: Buffer;
try {
processed = await sharp(file.buffer)
.resize(1280, 720, { fit: 'cover', position: sharp.strategy.attention })
.webp({ quality: 82 })
.toBuffer();
} catch (err: any) {
this.logger.error(`Failed to process cover image: ${err?.message}`);
throw new BadRequestException("Impossible de traiter l'image");
}
const filename = `${uuidv4()}-cover.webp`;
const key = `blog-images/${filename}`;
await this.storage.upload({
bucket: BLOG_IMAGES_BUCKET,
key,
body: processed,
contentType: 'image/webp',
});
this.logger.log(`[ADMIN] Blog cover image uploaded: ${key}`);
return { url: `/api/v1/blog/images/${filename}`, filename };
}
@Get('blog')
@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: 'trashed', required: false, type: Boolean })
@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('trashed') trashed?: string,
@Query('limit') limit = 50,
@Query('offset') offset = 0,
@CurrentUser() user?: UserPayload
@ -1021,6 +1084,7 @@ export class AdminController {
status,
category,
search,
trashed: trashed === 'true',
limit: Number(limit),
offset: Number(offset),
});
@ -1051,15 +1115,45 @@ export class AdminController {
@Delete('blog/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a blog post (Admin only)' })
@ApiOperation({ summary: 'Move a blog post to the trash (Admin only)' })
async deleteBlogPost(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload
): Promise<void> {
this.logger.log(`[ADMIN: ${user.email}] Deleting blog post: ${id}`);
this.logger.log(`[ADMIN: ${user.email}] Trashing blog post: ${id}`);
await this.blogService.deletePost(id);
}
@Post('blog/:id/restore')
@ApiOperation({ summary: 'Restore a blog post from the trash (Admin only)' })
async restoreBlogPost(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
this.logger.log(`[ADMIN: ${user.email}] Restoring blog post: ${id}`);
const post = await this.blogService.restorePost(id);
return this.mapBlogPostToDto(post);
}
@Delete('blog/:id/permanent')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Permanently delete a blog post (Admin only)' })
async permanentlyDeleteBlogPost(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload
): Promise<void> {
this.logger.log(`[ADMIN: ${user.email}] Permanently deleting blog post: ${id}`);
await this.blogService.permanentlyDeletePost(id);
}
@Post('blog/:id/duplicate')
@ApiOperation({ summary: 'Duplicate a blog post as a new draft (Admin only)' })
async duplicateBlogPost(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload
) {
this.logger.log(`[ADMIN: ${user.email}] Duplicating blog post: ${id}`);
const post = await this.blogService.duplicatePost(id);
return this.mapBlogPostToDto(post);
}
private mapBlogPostToDto(post: BlogPost) {
return {
id: post.id,
@ -1078,6 +1172,10 @@ export class AdminController {
metaDescription: post.metaDescription,
primaryKeyword: post.primaryKeyword,
secondaryKeywords: post.secondaryKeywords,
aiSummary: post.aiSummary,
faq: post.faq,
keyTakeaways: post.keyTakeaways,
aiEntities: post.aiEntities,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
};

View File

@ -128,6 +128,10 @@ export class BlogController {
metaDescription: post.metaDescription,
primaryKeyword: post.primaryKeyword,
secondaryKeywords: post.secondaryKeywords,
aiSummary: post.aiSummary ?? undefined,
faq: post.faq,
keyTakeaways: post.keyTakeaways,
aiEntities: post.aiEntities,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
};

View File

@ -1,13 +1,4 @@
import {
Controller,
Get,
Post,
Param,
Query,
Body,
Res,
StreamableFile,
} from '@nestjs/common';
import { Controller, Get, Post, Param, Query, Body, Res, StreamableFile } from '@nestjs/common';
import { Response } from 'express';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
import { Public } from '../decorators/public.decorator';
@ -251,10 +242,7 @@ export class CsvBookingActionsController {
);
res.setHeader('Content-Type', mimeType);
res.setHeader(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(fileName)}"`
);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
return new StreamableFile(buffer);
}
}

View File

@ -48,6 +48,7 @@ import {
CsvBookingListResponseDto,
CsvBookingStatsDto,
UpdateCsvBookingDetailsDto,
UpdateCsvBookingRateDto,
} from '../dto/csv-booking.dto';
/**
@ -167,12 +168,12 @@ export class CsvBookingsController {
// ADMIN users bypass shipment limits
if (req.user.role !== 'ADMIN') {
// Check shipment limit (Bronze plan = 12/year)
// Check the paid-reservation limit (free/Bronze plan = 5 paid shipments/year)
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
const maxShipments = subscription.plan.maxShipmentsPerYear;
if (maxShipments !== -1) {
const currentYear = new Date().getFullYear();
const count = await this.shipmentCounter.countShipmentsForOrganizationInYear(
const count = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear(
organizationId,
currentYear
);
@ -227,6 +228,35 @@ export class CsvBookingsController {
return await this.csvBookingService.getUserBookings(userId, page, limit);
}
/**
* Get the reservation (paid shipment) quota for the current organization.
*
* Uses the organization's real plan (not the ADMIN PLATINIUM override) and
* counts only PAID shipments this year, so the UI can show an upgrade prompt.
*
* GET /api/v1/csv-bookings/reservation-quota
*/
@Get('reservation-quota')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get the paid-reservation quota for the current organization' })
@ApiResponse({ status: 200, description: 'Quota retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getReservationQuota(
@Request() req: any
): Promise<{ max: number; used: number; unlimited: boolean; limitReached: boolean }> {
const organizationId = req.user.organizationId;
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
const max = subscription.plan.maxShipmentsPerYear;
const unlimited = max === -1;
const currentYear = new Date().getFullYear();
const used = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear(
organizationId,
currentYear
);
return { max, used, unlimited, limitReached: !unlimited && used >= max };
}
/**
* Get booking statistics for user
*
@ -592,6 +622,37 @@ export class CsvBookingsController {
return await this.csvBookingService.updateBookingDetails(id, userId, dto);
}
/**
* Re-apply a full rate selection before payment
*
* PATCH /api/v1/csv-bookings/:id/rate
*/
@Patch(':id/rate')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Update booking rate/route before payment',
description:
'Re-apply a rate selection (carrier, route, container, transit, cargo, price) to a PENDING_PAYMENT booking. Only the owner can edit.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({
status: 200,
description: 'Booking rate updated successfully',
type: CsvBookingResponseDto,
})
@ApiResponse({ status: 400, description: 'Booking cannot be edited (invalid status or values)' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Booking not found' })
async updateBookingRate(
@Param('id') id: string,
@Body() dto: UpdateCsvBookingRateDto,
@Request() req: any
): Promise<CsvBookingResponseDto> {
const userId = req.user.id;
return await this.csvBookingService.updateBookingRate(id, userId, dto);
}
/**
* Add documents to an existing booking
*

View File

@ -42,10 +42,7 @@ import {
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
import {
NotificationType,
NotificationPriority,
} from '@domain/entities/notification.entity';
import { NotificationType, NotificationPriority } from '@domain/entities/notification.entity';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
@ -71,7 +68,8 @@ export class OrganizationsController {
private readonly logger = new Logger(OrganizationsController.name);
constructor(
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository,
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
private readonly notificationService: NotificationService
) {}
@ -320,9 +318,7 @@ export class OrganizationsController {
})
@ApiResponse({ status: 200, description: 'Approval request sent to admins' })
@ApiResponse({ status: 400, description: 'No SIRET/SIREN registered or already verified' })
async requestSiretApproval(
@CurrentUser() user: UserPayload
): Promise<{ message: string }> {
async requestSiretApproval(@CurrentUser() user: UserPayload): Promise<{ message: string }> {
const organization = await this.organizationRepository.findById(user.organizationId);
if (!organization) {
throw new NotFoundException('Organization not found');

View File

@ -11,12 +11,63 @@ import {
MinLength,
Matches,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { BlogPostStatus, type BlogPostCategory } from '@domain/entities/blog-post.entity';
import { Type } from 'class-transformer';
import {
BlogPostStatus,
type BlogPostCategory,
type BlogFaqItem,
} from '@domain/entities/blog-post.entity';
const CATEGORIES: BlogPostCategory[] = ['industry', 'technology', 'guides', 'news'];
export class CreateBlogPostDto {
export class BlogFaqItemDto implements BlogFaqItem {
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(500)
question: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(4000)
answer: string;
}
/**
* GEO (Generative Engine Optimisation) fields, shared by create/update DTOs.
*/
export class BlogGeoFields {
@ApiPropertyOptional({ description: 'AI summary / TL;DR of the article' })
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsString()
@MaxLength(2000)
aiSummary?: string | null;
@ApiPropertyOptional({ type: [BlogFaqItemDto], description: 'FAQ (question/answer pairs)' })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => BlogFaqItemDto)
faq?: BlogFaqItemDto[];
@ApiPropertyOptional({ type: [String], description: 'Key takeaways' })
@IsOptional()
@IsArray()
@IsString({ each: true })
keyTakeaways?: string[];
@ApiPropertyOptional({ type: [String], description: 'Related entities / topics' })
@IsOptional()
@IsArray()
@IsString({ each: true })
aiEntities?: string[];
}
export class CreateBlogPostDto extends BlogGeoFields {
@ApiProperty()
@IsString()
@IsNotEmpty()
@ -95,7 +146,7 @@ export class CreateBlogPostDto {
secondaryKeywords?: string[];
}
export class UpdateBlogPostDto {
export class UpdateBlogPostDto extends BlogGeoFields {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@ -206,6 +257,10 @@ export class BlogPostResponseDto {
@ApiPropertyOptional() metaDescription?: string;
@ApiPropertyOptional() primaryKeyword?: string;
@ApiProperty({ type: [String] }) secondaryKeywords: string[];
@ApiPropertyOptional() aiSummary?: string | null;
@ApiProperty({ type: [BlogFaqItemDto] }) faq: BlogFaqItemDto[];
@ApiProperty({ type: [String] }) keyTakeaways: string[];
@ApiProperty({ type: [String] }) aiEntities: string[];
@ApiProperty() createdAt: Date;
@ApiProperty() updatedAt: Date;
}

View File

@ -6,6 +6,7 @@ import {
Min,
IsOptional,
IsEnum,
IsObject,
MinLength,
MaxLength,
} from 'class-validator';
@ -169,6 +170,16 @@ export class CreateCsvBookingDto {
@MaxLength(1000)
notes?: string;
@ApiPropertyOptional({
description:
'Selected options & services as a JSON string (multipart), e.g. {"insurance":true}',
example: '{"insurance":true,"customsStop":true}',
})
@IsOptional()
@IsString()
@MaxLength(2000)
options?: string;
// Documents will be handled via file upload interceptor
// Not included in DTO validation but processed separately
}
@ -203,6 +214,157 @@ export class UpdateCsvBookingDetailsDto {
@IsString()
@MaxLength(2000)
notes?: string;
// Recomputed pricing (sent when cargo details change). Optional; when omitted
// the price is left unchanged.
@ApiPropertyOptional({ description: 'Recomputed total price in USD' })
@IsOptional()
@IsNumber()
@Min(0)
priceUSD?: number;
@ApiPropertyOptional({ description: 'Recomputed total price in EUR' })
@IsOptional()
@IsNumber()
@Min(0)
priceEUR?: number;
@ApiPropertyOptional({ description: 'Primary currency (USD/EUR)' })
@IsOptional()
@IsString()
@MaxLength(3)
primaryCurrency?: string;
@ApiPropertyOptional({ description: 'Recomputed freight total' })
@IsOptional()
@IsNumber()
@Min(0)
freightTotal?: number;
@ApiPropertyOptional({ description: 'Freight currency' })
@IsOptional()
@IsString()
@MaxLength(3)
freightCurrency?: string;
@ApiPropertyOptional({ description: 'Recomputed FOB total' })
@IsOptional()
@IsNumber()
@Min(0)
fobTotal?: number;
@ApiPropertyOptional({ description: 'FOB currency' })
@IsOptional()
@IsString()
@MaxLength(3)
fobCurrency?: string;
}
/**
* Update CSV Booking Rate DTO
*
* Full re-selection of a rate before payment (carrier + route + container +
* transit + cargo + price). Used when the user re-runs the search and picks a
* (possibly different) rate for a PENDING_PAYMENT booking.
*/
export class UpdateCsvBookingRateDto {
@ApiProperty({ example: 'SSC Consolidation' })
@IsString()
@MinLength(2)
@MaxLength(200)
carrierName: string;
@ApiProperty({ example: 'bookings@example.com' })
@IsEmail()
carrierEmail: string;
@ApiProperty({ example: 'FRLIO' })
@IsString()
@MaxLength(5)
origin: string;
@ApiProperty({ example: 'AUADL' })
@IsString()
@MaxLength(5)
destination: string;
@ApiProperty({ example: 'LCL' })
@IsString()
@MaxLength(50)
containerType: string;
@ApiProperty({ example: 46 })
@IsNumber()
@Min(1)
transitDays: number;
@ApiProperty({ example: 12.5 })
@IsNumber()
@Min(0.01)
volumeCBM: number;
@ApiProperty({ example: 1500 })
@IsNumber()
@Min(0.01)
weightKG: number;
@ApiProperty({ example: 5 })
@IsNumber()
@Min(0)
palletCount: number;
@ApiProperty({ example: 0 })
@IsNumber()
@Min(0)
priceUSD: number;
@ApiProperty({ example: 258 })
@IsNumber()
@Min(0)
priceEUR: number;
@ApiProperty({ example: 'EUR' })
@IsString()
@MaxLength(3)
primaryCurrency: string;
@ApiPropertyOptional({ example: 150 })
@IsOptional()
@IsNumber()
@Min(0)
freightTotal?: number;
@ApiPropertyOptional({ example: 'EUR' })
@IsOptional()
@IsString()
@MaxLength(3)
freightCurrency?: string;
@ApiPropertyOptional({ example: 108 })
@IsOptional()
@IsNumber()
@Min(0)
fobTotal?: number;
@ApiPropertyOptional({ example: 'EUR' })
@IsOptional()
@IsString()
@MaxLength(3)
fobCurrency?: string;
@ApiPropertyOptional({ example: 'Handle with care' })
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@ApiPropertyOptional({
description: 'Selected options & services (customs, insurance, DG, handling…)',
example: { insurance: true, customsStop: true },
})
@IsOptional()
@IsObject()
options?: Record<string, boolean>;
}
/**
@ -459,6 +621,12 @@ export class CsvBookingResponseDto {
example: 'EUR',
})
fobCurrency?: string;
@ApiPropertyOptional({
description: 'Selected options & services (customs, insurance, DG, handling…)',
example: { insurance: true },
})
options?: Record<string, boolean>;
}
/**

View File

@ -9,11 +9,7 @@ import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
@Module({
imports: [
TypeOrmModule.forFeature([OrganizationOrmEntity]),
UsersModule,
NotificationsModule,
],
imports: [TypeOrmModule.forFeature([OrganizationOrmEntity]), UsersModule, NotificationsModule],
controllers: [OrganizationsController],
providers: [
{

View File

@ -50,11 +50,17 @@ import { CarrierOrmEntity } from '../../infrastructure/persistence/typeorm/entit
cache: any,
rateQuoteRepo: any,
portRepo: any,
carrierRepo: any,
carrierRepo: any
) => {
return new RateSearchService(connectors, cache, rateQuoteRepo, portRepo, carrierRepo);
},
inject: ['CarrierConnectors', CACHE_PORT, RATE_QUOTE_REPOSITORY, PORT_REPOSITORY, CARRIER_REPOSITORY],
inject: [
'CarrierConnectors',
CACHE_PORT,
RATE_QUOTE_REPOSITORY,
PORT_REPOSITORY,
CARRIER_REPOSITORY,
],
},
],
exports: [RATE_QUOTE_REPOSITORY, RateSearchService],

View File

@ -58,6 +58,10 @@ export class BlogService implements OnApplicationBootstrap {
metaDescription: dto.metaDescription,
primaryKeyword: dto.primaryKeyword,
secondaryKeywords: dto.secondaryKeywords ?? [],
aiSummary: dto.aiSummary,
faq: dto.faq ?? [],
keyTakeaways: dto.keyTakeaways ?? [],
aiEntities: dto.aiEntities ?? [],
});
if (dto.scheduledAt) {
@ -92,6 +96,10 @@ export class BlogService implements OnApplicationBootstrap {
metaDescription: dto.metaDescription,
primaryKeyword: dto.primaryKeyword,
secondaryKeywords: dto.secondaryKeywords,
aiSummary: dto.aiSummary,
faq: dto.faq,
keyTakeaways: dto.keyTakeaways,
aiEntities: dto.aiEntities,
});
if (dto.scheduledAt) {
@ -108,11 +116,83 @@ export class BlogService implements OnApplicationBootstrap {
return this.blogPostRepository.save(updated);
}
/**
* Soft-delete a post: move it to the trash. It can be restored later or
* permanently deleted from the trash.
*/
async deletePost(id: string): Promise<void> {
await this.findOrFail(id);
const post = await this.findOrFail(id);
await this.blogPostRepository.save(post.trash());
}
/** Restore a post from the trash. */
async restorePost(id: string): Promise<BlogPost> {
const post = await this.findOrFail(id);
return this.blogPostRepository.save(post.restore());
}
/**
* Permanently delete a post (hard delete). Also removes its cover image from
* storage when it is a locally hosted blog image (best-effort).
*/
async permanentlyDeletePost(id: string): Promise<void> {
const post = await this.findOrFail(id);
const coverUrl = post.coverImageUrl;
if (coverUrl) {
const match = coverUrl.match(/\/api\/v1\/blog\/images\/([^/?#]+)$/);
if (match) {
try {
await this.storage.delete({ bucket: BLOG_IMAGES_BUCKET, key: `blog-images/${match[1]}` });
} catch (err: any) {
this.logger.warn(`Could not delete cover image for post ${id}: ${err?.message}`);
}
}
}
await this.blogPostRepository.delete(id);
}
/**
* Duplicate a post as a new draft with a unique slug.
*/
async duplicatePost(id: string): Promise<BlogPost> {
const source = await this.findOrFail(id);
const slug = await this.generateUniqueSlug(source.slug);
const copy = BlogPost.create({
id: uuidv4(),
title: `${source.title} (copie)`,
slug,
excerpt: source.excerpt,
content: source.content,
coverImageUrl: source.coverImageUrl,
category: source.category,
tags: [...source.tags],
authorName: source.authorName,
metaTitle: source.metaTitle,
metaDescription: source.metaDescription,
primaryKeyword: source.primaryKeyword,
secondaryKeywords: [...source.secondaryKeywords],
aiSummary: source.aiSummary,
faq: source.faq.map(item => ({ ...item })),
keyTakeaways: [...source.keyTakeaways],
aiEntities: [...source.aiEntities],
});
return this.blogPostRepository.save(copy);
}
private async generateUniqueSlug(baseSlug: string): Promise<string> {
let candidate = `${baseSlug}-copie`;
let counter = 2;
while (await this.blogPostRepository.slugExists(candidate)) {
candidate = `${baseSlug}-copie-${counter}`;
counter += 1;
}
return candidate;
}
async getPostById(id: string): Promise<BlogPost> {
return this.findOrFail(id);
}

View File

@ -32,6 +32,7 @@ import {
CsvBookingListResponseDto,
CsvBookingStatsDto,
UpdateCsvBookingDetailsDto,
UpdateCsvBookingRateDto,
} from '../dto/csv-booking.dto';
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
import { SubscriptionService } from './subscription.service';
@ -171,6 +172,16 @@ export class CsvBookingService {
: CsvBookingStatus.PENDING;
// Create domain entity (no email sent yet when a payment is required)
let parsedOptions: Record<string, boolean> = {};
if (dto.options) {
try {
const parsed = JSON.parse(dto.options);
if (parsed && typeof parsed === 'object') parsedOptions = parsed;
} catch {
this.logger.warn(`Ignoring invalid options JSON for booking creation`);
}
}
const booking = new CsvBooking(
bookingId,
userId,
@ -201,7 +212,8 @@ export class CsvBookingService {
dto.freightTotal,
dto.freightCurrency,
dto.fobTotal,
dto.fobCurrency
dto.fobCurrency,
parsedOptions
);
// Save to database
@ -1035,6 +1047,16 @@ export class CsvBookingService {
throw new NotFoundException('Booking not found');
}
// Detect whether any pricing field was provided
const hasPricing =
dto.priceUSD !== undefined ||
dto.priceEUR !== undefined ||
dto.primaryCurrency !== undefined ||
dto.freightTotal !== undefined ||
dto.freightCurrency !== undefined ||
dto.fobTotal !== undefined ||
dto.fobCurrency !== undefined;
// Apply the changes (domain logic validates status + values)
try {
booking.editDetails({
@ -1042,6 +1064,17 @@ export class CsvBookingService {
weightKG: dto.weightKG,
palletCount: dto.palletCount,
notes: dto.notes,
pricing: hasPricing
? {
priceUSD: dto.priceUSD,
priceEUR: dto.priceEUR,
primaryCurrency: dto.primaryCurrency,
freightTotal: dto.freightTotal,
freightCurrency: dto.freightCurrency,
fobTotal: dto.fobTotal,
fobCurrency: dto.fobCurrency,
}
: undefined,
});
} catch (err) {
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking details');
@ -1053,6 +1086,61 @@ export class CsvBookingService {
return this.toResponseDto(updatedBooking);
}
/**
* Re-apply a full rate selection to a booking before payment (user action).
*
* Used when the user re-runs the search and picks a (possibly different) rate:
* carrier, route, container, transit, cargo and price are all replaced. Only
* the owner can edit, and only while the booking is PENDING_PAYMENT.
*/
async updateBookingRate(
id: string,
userId: string,
dto: UpdateCsvBookingRateDto
): Promise<CsvBookingResponseDto> {
this.logger.log(`Updating booking rate ${id} by user ${userId}`);
const booking = await this.csvBookingRepository.findById(id);
if (!booking) {
throw new NotFoundException('Booking not found');
}
if (booking.userId !== userId) {
throw new NotFoundException('Booking not found');
}
try {
booking.editFromRate({
carrierName: dto.carrierName,
carrierEmail: dto.carrierEmail,
origin: PortCode.create(dto.origin),
destination: PortCode.create(dto.destination),
containerType: dto.containerType,
transitDays: dto.transitDays,
volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG,
palletCount: dto.palletCount,
priceUSD: dto.priceUSD,
priceEUR: dto.priceEUR,
primaryCurrency: dto.primaryCurrency,
freightTotal: dto.freightTotal,
freightCurrency: dto.freightCurrency,
fobTotal: dto.fobTotal,
fobCurrency: dto.fobCurrency,
notes: dto.notes,
options: dto.options,
});
} catch (err) {
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking rate');
}
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${id} rate updated`);
return this.toResponseDto(updatedBooking);
}
/**
* Get bookings for a user (paginated)
*/
@ -1309,31 +1397,14 @@ export class CsvBookingService {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
// Verify booking is still pending or awaiting payment
if (
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
booking.status !== CsvBookingStatus.PENDING
) {
throw new BadRequestException('Cannot delete documents from a booking that is not pending');
}
// Find the document
// Find the document (no status restriction — aligned with replaceDocument;
// deleting down to zero documents is allowed)
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
if (documentIndex === -1) {
throw new NotFoundException(`Document with ID ${documentId} not found`);
}
// Ensure at least one document remains
if (booking.documents.length <= 1) {
throw new BadRequestException(
'Cannot delete the last document. At least one document is required.'
);
}
// Get the document to delete (for potential S3 cleanup - currently kept for audit)
const _documentToDelete = booking.documents[documentIndex];
// Remove document from array
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
@ -1512,6 +1583,7 @@ export class CsvBookingService {
freightCurrency: booking.freightCurrency,
fobTotal: booking.fobTotal,
fobCurrency: booking.fobCurrency,
options: booking.options ?? {},
};
}

View File

@ -7,6 +7,12 @@ export enum BlogPostStatus {
export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news';
/** A single question/answer pair used for GEO (AI) optimisation and FAQ schema. */
export interface BlogFaqItem {
question: string;
answer: string;
}
interface BlogPostProps {
id: string;
title: string;
@ -24,6 +30,13 @@ interface BlogPostProps {
metaDescription?: string;
primaryKeyword?: string;
secondaryKeywords: string[];
// GEO (Generative Engine Optimisation) — helps AI/LLM engines cite the article.
aiSummary?: string | null;
faq: BlogFaqItem[];
keyTakeaways: string[];
aiEntities: string[];
// Soft delete (trash). Null/undefined means the post is not trashed.
deletedAt?: Date | null;
createdAt: Date;
updatedAt: Date;
}
@ -32,12 +45,27 @@ export class BlogPost {
private constructor(private readonly props: BlogPostProps) {}
static create(
props: Omit<BlogPostProps, 'status' | 'isFeatured' | 'publishedAt' | 'createdAt' | 'updatedAt'>
props: Omit<
BlogPostProps,
| 'status'
| 'isFeatured'
| 'publishedAt'
| 'createdAt'
| 'updatedAt'
| 'faq'
| 'keyTakeaways'
| 'aiEntities'
| 'deletedAt'
> &
Partial<Pick<BlogPostProps, 'faq' | 'keyTakeaways' | 'aiEntities'>>
): BlogPost {
const now = new Date();
return new BlogPost({
...props,
secondaryKeywords: props.secondaryKeywords ?? [],
faq: props.faq ?? [],
keyTakeaways: props.keyTakeaways ?? [],
aiEntities: props.aiEntities ?? [],
status: BlogPostStatus.DRAFT,
isFeatured: false,
createdAt: now,
@ -97,6 +125,24 @@ export class BlogPost {
get secondaryKeywords(): string[] {
return this.props.secondaryKeywords;
}
get aiSummary(): string | null | undefined {
return this.props.aiSummary;
}
get faq(): BlogFaqItem[] {
return this.props.faq;
}
get keyTakeaways(): string[] {
return this.props.keyTakeaways;
}
get aiEntities(): string[] {
return this.props.aiEntities;
}
get deletedAt(): Date | null | undefined {
return this.props.deletedAt;
}
get isTrashed(): boolean {
return !!this.props.deletedAt;
}
get createdAt(): Date {
return this.props.createdAt;
}
@ -121,12 +167,26 @@ export class BlogPost {
| 'metaDescription'
| 'primaryKeyword'
| 'secondaryKeywords'
| 'aiSummary'
| 'faq'
| 'keyTakeaways'
| 'aiEntities'
>
>
): BlogPost {
return new BlogPost({ ...this.props, ...data, updatedAt: new Date() });
}
/** Move the post to the trash (soft delete). */
trash(): BlogPost {
return new BlogPost({ ...this.props, deletedAt: new Date(), updatedAt: new Date() });
}
/** Restore the post from the trash. */
restore(): BlogPost {
return new BlogPost({ ...this.props, deletedAt: null, updatedAt: new Date() });
}
publish(): BlogPost {
return new BlogPost({
...this.props,
@ -163,6 +223,7 @@ export class BlogPost {
}
isVisibleToPublic(): boolean {
if (this.props.deletedAt) return false;
if (this.props.status === BlogPostStatus.PUBLISHED) return true;
if (
this.props.status === BlogPostStatus.SCHEDULED &&

View File

@ -191,7 +191,9 @@ describe('CsvBooking Entity', () => {
}).toThrow('Invalid carrier email format');
});
it('should throw error if no documents provided', () => {
it('should allow reconstituting a booking with no documents (all deleted before payment)', () => {
// A booking may end up with zero documents after the owner deletes them.
// Reconstitution must not fail (creation still requires >=1 doc at the service level).
expect(() => {
new CsvBooking(
'booking-123',
@ -214,7 +216,7 @@ describe('CsvBooking Entity', () => {
'token-abc123',
new Date()
);
}).toThrow('At least one document is required for booking');
}).not.toThrow();
});
});

View File

@ -63,19 +63,22 @@ export class CsvBooking {
public readonly id: string,
public readonly userId: string,
public readonly organizationId: string,
public readonly carrierName: string,
public readonly carrierEmail: string,
public readonly origin: PortCode,
public readonly destination: PortCode,
// Carrier + route + transit + container — editable before payment when the
// user re-runs the search and picks another rate (see editFromRate).
public carrierName: string,
public carrierEmail: string,
public origin: PortCode,
public destination: PortCode,
// Cargo characteristics — editable before payment (see editDetails).
public volumeCBM: number,
public weightKG: number,
public palletCount: number,
public readonly priceUSD: number,
public readonly priceEUR: number,
public readonly primaryCurrency: string,
public readonly transitDays: number,
public readonly containerType: string,
// Pricing — recomputed when cargo details change before payment (see editDetails).
public priceUSD: number,
public priceEUR: number,
public primaryCurrency: string,
public transitDays: number,
public containerType: string,
public status: CsvBookingStatus,
public readonly documents: CsvBookingDocument[],
public readonly confirmationToken: string,
@ -89,10 +92,13 @@ export class CsvBooking {
public stripePaymentIntentId?: string,
// Detailed transport cost breakdown (informational — paid to the carrier,
// not collected by Xpeditis). Freight and FOB may be in different currencies.
public readonly freightTotal?: number,
public readonly freightCurrency?: string,
public readonly fobTotal?: number,
public readonly fobCurrency?: string
public freightTotal?: number,
public freightCurrency?: string,
public fobTotal?: number,
public fobCurrency?: string,
// Options & services selected in the search form (customs, insurance, DG,
// handling…). Stored as a flat map of enabled flags. Editable before payment.
public options: Record<string, boolean> = {}
) {
this.validate();
}
@ -151,9 +157,9 @@ export class CsvBooking {
throw new Error('Confirmation token is required');
}
if (!this.documents || this.documents.length === 0) {
throw new Error('At least one document is required for booking');
}
// Note: at least one document is required *at creation* (enforced in the
// create flow). A booking may end up with zero documents after the owner
// deletes them, so reconstitution must not fail here.
}
/**
@ -306,6 +312,15 @@ export class CsvBooking {
weightKG?: number;
palletCount?: number;
notes?: string;
pricing?: {
priceUSD?: number;
priceEUR?: number;
primaryCurrency?: string;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
};
}): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error(
@ -337,6 +352,89 @@ export class CsvBooking {
if (details.notes !== undefined) {
this.notes = details.notes;
}
// Pricing is recomputed from the current rate when cargo details change.
const p = details.pricing;
if (p) {
if (p.priceUSD !== undefined) {
if (p.priceUSD < 0) throw new Error('Price cannot be negative');
this.priceUSD = p.priceUSD;
}
if (p.priceEUR !== undefined) {
if (p.priceEUR < 0) throw new Error('Price cannot be negative');
this.priceEUR = p.priceEUR;
}
if (p.primaryCurrency !== undefined) this.primaryCurrency = p.primaryCurrency;
if (p.freightTotal !== undefined) this.freightTotal = p.freightTotal;
if (p.freightCurrency !== undefined) this.freightCurrency = p.freightCurrency;
if (p.fobTotal !== undefined) this.fobTotal = p.fobTotal;
if (p.fobCurrency !== undefined) this.fobCurrency = p.fobCurrency;
}
}
/**
* Re-apply a full rate selection before payment: the user re-ran the search
* and picked a rate, so carrier, route, container, transit, cargo and price
* are all replaced. Only allowed while the booking is PENDING_PAYMENT.
*
* @throws Error if the booking is not PENDING_PAYMENT or values are invalid
*/
editFromRate(data: {
carrierName: string;
carrierEmail: string;
origin: PortCode;
destination: PortCode;
containerType: string;
transitDays: number;
volumeCBM: number;
weightKG: number;
palletCount: number;
priceUSD: number;
priceEUR: number;
primaryCurrency: string;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
notes?: string;
options?: Record<string, boolean>;
}): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error(
`Cannot edit booking with status ${this.status}. Only PENDING_PAYMENT bookings can be edited.`
);
}
if (!data.carrierName || data.carrierName.trim().length === 0) {
throw new Error('Carrier name is required');
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!data.carrierEmail || !emailRegex.test(data.carrierEmail)) {
throw new Error('Invalid carrier email format');
}
if (data.volumeCBM <= 0) throw new Error('Volume must be positive');
if (data.weightKG <= 0) throw new Error('Weight must be positive');
if (data.palletCount < 0) throw new Error('Pallet count cannot be negative');
if (data.transitDays <= 0) throw new Error('Transit days must be positive');
if (data.priceUSD < 0 || data.priceEUR < 0) throw new Error('Price cannot be negative');
this.carrierName = data.carrierName;
this.carrierEmail = data.carrierEmail;
this.origin = data.origin;
this.destination = data.destination;
this.containerType = data.containerType;
this.transitDays = data.transitDays;
this.volumeCBM = data.volumeCBM;
this.weightKG = data.weightKG;
this.palletCount = data.palletCount;
this.priceUSD = data.priceUSD;
this.priceEUR = data.priceEUR;
this.primaryCurrency = data.primaryCurrency;
this.freightTotal = data.freightTotal;
this.freightCurrency = data.freightCurrency;
this.fobTotal = data.fobTotal;
this.fobCurrency = data.fobCurrency;
if (data.notes !== undefined) this.notes = data.notes;
if (data.options !== undefined) this.options = data.options;
}
/**
@ -507,7 +605,8 @@ export class CsvBooking {
freightTotal?: number,
freightCurrency?: string,
fobTotal?: number,
fobCurrency?: string
fobCurrency?: string,
options?: Record<string, boolean>
): CsvBooking {
// Create instance without calling constructor validation
const booking = Object.create(CsvBooking.prototype);
@ -543,6 +642,7 @@ export class CsvBooking {
booking.freightCurrency = freightCurrency;
booking.fobTotal = fobTotal;
booking.fobCurrency = fobCurrency;
booking.options = options ?? {};
return booking;
}

View File

@ -11,6 +11,8 @@ export interface BlogPostFilters {
offset?: number;
/** When true, also include SCHEDULED posts whose publishedAt <= now */
includeScheduled?: boolean;
/** When true, return only trashed posts; otherwise trashed posts are excluded. */
trashed?: boolean;
}
export interface BlogPostRepository {

View File

@ -12,4 +12,14 @@ export interface ShipmentCounterPort {
* Count all shipments (bookings + CSV bookings) created by an organization in a given year.
*/
countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise<number>;
/**
* Count only PAID shipments (fee paid / payment declared / accepted) created by
* an organization in a given year. Unpaid drafts (PENDING_PAYMENT), rejected and
* cancelled bookings are excluded.
*/
countPaidShipmentsForOrganizationInYear(
organizationId: string,
year: number
): Promise<number>;
}

View File

@ -52,6 +52,23 @@ export class BlogPostOrmEntity {
@Column('jsonb', { default: [] })
secondary_keywords: string[];
// GEO (Generative Engine Optimisation) fields
@Column('text', { nullable: true })
ai_summary?: string | null;
@Column('jsonb', { default: [] })
faq: { question: string; answer: string }[];
@Column('jsonb', { default: [] })
key_takeaways: string[];
@Column('jsonb', { default: [] })
ai_entities: string[];
// Soft delete (trash)
@Column('timestamp', { nullable: true })
deleted_at?: Date | null;
@CreateDateColumn()
created_at: Date;

View File

@ -183,6 +183,9 @@ export class CsvBookingOrmEntity {
@Column({ name: 'fob_currency', type: 'varchar', length: 3, nullable: true })
fobCurrency: string | null;
@Column({ name: 'options', type: 'jsonb', default: {} })
options: Record<string, boolean>;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date;

View File

@ -49,7 +49,8 @@ export class CsvBookingMapper {
ormEntity.freightTotal != null ? Number(ormEntity.freightTotal) : undefined,
ormEntity.freightCurrency ?? undefined,
ormEntity.fobTotal != null ? Number(ormEntity.fobTotal) : undefined,
ormEntity.fobCurrency ?? undefined
ormEntity.fobCurrency ?? undefined,
ormEntity.options ?? {}
);
}
@ -87,6 +88,7 @@ export class CsvBookingMapper {
freightCurrency: domain.freightCurrency ?? null,
fobTotal: domain.fobTotal ?? null,
fobCurrency: domain.fobCurrency ?? null,
options: domain.options ?? {},
};
}
@ -95,6 +97,25 @@ export class CsvBookingMapper {
*/
static toOrmUpdate(domain: CsvBooking): Partial<CsvBookingOrmEntity> {
return {
// Carrier + route + cargo + price are editable before payment
// (editDetails / editFromRate), so they must be persisted on update.
carrierName: domain.carrierName,
carrierEmail: domain.carrierEmail,
origin: domain.origin.getValue(),
destination: domain.destination.getValue(),
containerType: domain.containerType,
transitDays: domain.transitDays,
volumeCBM: domain.volumeCBM,
weightKG: domain.weightKG,
palletCount: domain.palletCount,
priceUSD: domain.priceUSD,
priceEUR: domain.priceEUR,
primaryCurrency: domain.primaryCurrency,
freightTotal: domain.freightTotal ?? null,
freightCurrency: domain.freightCurrency ?? null,
fobTotal: domain.fobTotal ?? null,
fobCurrency: domain.fobCurrency ?? null,
options: domain.options ?? {},
status: domain.status as CsvBookingOrmEntity['status'],
respondedAt: domain.respondedAt,
notes: domain.notes,

View File

@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddGeoAndTrashToBlogPosts1748000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumns('blog_posts', [
// GEO (Generative Engine Optimisation) fields
new TableColumn({
name: 'ai_summary',
type: 'text',
isNullable: true,
}),
new TableColumn({
name: 'faq',
type: 'jsonb',
isNullable: false,
default: "'[]'",
}),
new TableColumn({
name: 'key_takeaways',
type: 'jsonb',
isNullable: false,
default: "'[]'",
}),
new TableColumn({
name: 'ai_entities',
type: 'jsonb',
isNullable: false,
default: "'[]'",
}),
// Soft delete (trash)
new TableColumn({
name: 'deleted_at',
type: 'timestamp',
isNullable: true,
}),
]);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumns('blog_posts', [
'ai_summary',
'faq',
'key_takeaways',
'ai_entities',
'deleted_at',
]);
}
}

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddOptionsToCsvBookings1749000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn(
'csv_bookings',
new TableColumn({
name: 'options',
type: 'jsonb',
isNullable: false,
default: "'{}'",
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('csv_bookings', 'options');
}
}

View File

@ -29,4 +29,24 @@ export class TypeOrmShipmentCounterRepository implements ShipmentCounterPort {
.andWhere('csv_booking.created_at < :end', { end: startOfNextYear })
.getCount();
}
async countPaidShipmentsForOrganizationInYear(
organizationId: string,
year: number
): Promise<number> {
const startOfYear = new Date(year, 0, 1);
const startOfNextYear = new Date(year + 1, 0, 1);
// "Paid" = payment completed / declared / accepted. Unpaid drafts
// (PENDING_PAYMENT), rejected and cancelled bookings do not count.
const PAID_STATUSES = ['PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED'];
return this.csvBookingRepository
.createQueryBuilder('csv_booking')
.where('csv_booking.organization_id = :organizationId', { organizationId })
.andWhere('csv_booking.created_at >= :start', { start: startOfYear })
.andWhere('csv_booking.created_at < :end', { end: startOfNextYear })
.andWhere('csv_booking.status IN (:...statuses)', { statuses: PAID_STATUSES })
.getCount();
}
}

View File

@ -31,6 +31,12 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository {
async findByFilters(filters: BlogPostFilters): Promise<BlogPost[]> {
const query = this.ormRepository.createQueryBuilder('post');
if (filters.trashed) {
query.andWhere('post.deleted_at IS NOT NULL');
} else {
query.andWhere('post.deleted_at IS NULL');
}
if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) {
query.andWhere(
"(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))"
@ -65,6 +71,12 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository {
async count(filters: BlogPostFilters): Promise<number> {
const query = this.ormRepository.createQueryBuilder('post');
if (filters.trashed) {
query.andWhere('post.deleted_at IS NOT NULL');
} else {
query.andWhere('post.deleted_at IS NULL');
}
if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) {
query.andWhere(
"(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))"
@ -117,6 +129,11 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository {
metaDescription: orm.meta_description,
primaryKeyword: orm.primary_keyword,
secondaryKeywords: orm.secondary_keywords ?? [],
aiSummary: orm.ai_summary,
faq: orm.faq ?? [],
keyTakeaways: orm.key_takeaways ?? [],
aiEntities: orm.ai_entities ?? [],
deletedAt: orm.deleted_at,
createdAt: orm.created_at,
updatedAt: orm.updated_at,
});
@ -140,6 +157,11 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository {
orm.meta_description = post.metaDescription;
orm.primary_keyword = post.primaryKeyword;
orm.secondary_keywords = post.secondaryKeywords;
orm.ai_summary = post.aiSummary;
orm.faq = post.faq;
orm.key_takeaways = post.keyTakeaways;
orm.ai_entities = post.aiEntities;
orm.deleted_at = post.deletedAt;
orm.created_at = post.createdAt;
orm.updated_at = post.updatedAt;
return orm;

View File

@ -192,7 +192,8 @@ export function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: bool
// sites (cross-origin), otherwise the browser drops the auth cookies set in
// the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS).
// Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups.
const sameSite = (process.env.COOKIE_SAMESITE?.toLowerCase() as 'lax' | 'strict' | 'none') || 'lax';
const sameSite =
(process.env.COOKIE_SAMESITE?.toLowerCase() as 'lax' | 'strict' | 'none') || 'lax';
const secure = process.env.NODE_ENV === 'production' || sameSite === 'none';
return {

View File

@ -6,11 +6,14 @@ import {
createBlogPost,
updateBlogPost,
deleteBlogPost,
restoreBlogPost,
permanentlyDeleteBlogPost,
duplicateBlogPost,
type CreateBlogPostRequest,
type UpdateBlogPostRequest,
} from '@/lib/api/admin';
import { upload } from '@/lib/api/client';
import type { BlogPost, BlogPostCategory, BlogPostStatus } from '@/lib/api/blog';
import type { BlogPost, BlogPostCategory, BlogPostStatus, BlogFaqItem } from '@/lib/api/blog';
import { PageHeader } from '@/components/ui/PageHeader';
import { RichTextEditor } from '@/components/blog/RichTextEditor';
import {
@ -30,6 +33,9 @@ import {
ChevronDown,
ChevronUp,
ArrowLeft,
Copy,
RotateCcw,
Sparkles,
} from 'lucide-react';
import { Link } from '@/i18n/navigation';
@ -71,6 +77,11 @@ interface FormData extends CreateBlogPostRequest {
metaDescription: string;
primaryKeyword: string;
secondaryKeywordsInput: string;
// GEO / AI optimisation
aiSummary: string;
faqItems: BlogFaqItem[];
keyTakeawaysInput: string;
aiEntitiesInput: string;
}
const EMPTY_FORM: FormData = {
@ -86,6 +97,10 @@ const EMPTY_FORM: FormData = {
metaDescription: '',
primaryKeyword: '',
secondaryKeywordsInput: '',
aiSummary: '',
faqItems: [],
keyTakeawaysInput: '',
aiEntitiesInput: '',
};
function generateSlug(title: string): string {
@ -145,16 +160,23 @@ export default function AdminBlogPage() {
const [formData, setFormData] = useState<FormData>(EMPTY_FORM);
const [scheduledAt, setScheduledAt] = useState('');
const [seoOpen, setSeoOpen] = useState(false);
const [geoOpen, setGeoOpen] = useState(false);
const [isDraggingCover, setIsDraggingCover] = useState(false);
const [viewFilter, setViewFilter] = useState<'all' | 'published' | 'draft' | 'scheduled' | 'trash'>(
'all'
);
const [actioningId, setActioningId] = useState<string | null>(null);
const coverInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetchPosts();
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewFilter]);
const fetchPosts = async () => {
try {
setLoading(true);
const res = await getAllBlogPosts();
const res = await getAllBlogPosts({ trashed: viewFilter === 'trash' });
setPosts(res.posts);
setError(null);
} catch (err: any) {
@ -164,6 +186,12 @@ export default function AdminBlogPage() {
}
};
// Client-side status filtering for the non-trash views (posts are already loaded).
const visiblePosts =
viewFilter === 'all' || viewFilter === 'trash'
? posts
: posts.filter(p => p.status === viewFilter);
const buildPayload = (withStatus?: BlogPostStatus): CreateBlogPostRequest => {
const tags = tagsInput
? tagsInput
@ -177,6 +205,21 @@ export default function AdminBlogPage() {
.map(t => t.trim())
.filter(Boolean)
: [];
const keyTakeaways = formData.keyTakeawaysInput
? formData.keyTakeawaysInput
.split('\n')
.map(t => t.trim())
.filter(Boolean)
: [];
const aiEntities = formData.aiEntitiesInput
? formData.aiEntitiesInput
.split(',')
.map(t => t.trim())
.filter(Boolean)
: [];
const faq = formData.faqItems
.map(item => ({ question: item.question.trim(), answer: item.answer.trim() }))
.filter(item => item.question && item.answer);
return {
title: formData.title,
slug: formData.slug,
@ -192,6 +235,10 @@ export default function AdminBlogPage() {
metaDescription: formData.metaDescription || undefined,
primaryKeyword: formData.primaryKeyword || undefined,
secondaryKeywords,
aiSummary: formData.aiSummary.trim() ? formData.aiSummary : null,
faq,
keyTakeaways,
aiEntities,
scheduledAt: scheduledAt || undefined,
...(withStatus ? { status: withStatus } : {}),
};
@ -262,9 +309,11 @@ export default function AdminBlogPage() {
}
};
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const uploadCoverFile = async (file: File) => {
if (!file.type.startsWith('image/')) {
alert('Veuillez sélectionner une image');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('Image trop volumineuse (max 5 Mo)');
return;
@ -274,8 +323,9 @@ export default function AdminBlogPage() {
try {
const fd = new FormData();
fd.append('image', file);
// Dedicated endpoint: auto-crops to the public 16:9 card ratio.
const result = await upload<{ url: string; filename: string }>(
'/api/v1/admin/blog/images',
'/api/v1/admin/blog/cover-images',
fd
);
const coverUrl = result.url.startsWith('http') ? result.url : `${API_BASE_URL}${result.url}`;
@ -288,6 +338,73 @@ export default function AdminBlogPage() {
}
};
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) await uploadCoverFile(file);
};
const handleCoverDrop = async (e: React.DragEvent<HTMLElement>) => {
e.preventDefault();
setIsDraggingCover(false);
const file = e.dataTransfer.files?.[0];
if (file) await uploadCoverFile(file);
};
// FAQ (GEO) helpers
const addFaqItem = () => {
setFormData(prev => ({ ...prev, faqItems: [...prev.faqItems, { question: '', answer: '' }] }));
};
const updateFaqItem = (index: number, field: 'question' | 'answer', value: string) => {
setFormData(prev => ({
...prev,
faqItems: prev.faqItems.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
}));
};
const removeFaqItem = (index: number) => {
setFormData(prev => ({
...prev,
faqItems: prev.faqItems.filter((_, i) => i !== index),
}));
};
const handleDuplicate = async (post: BlogPost) => {
setActioningId(post.id);
try {
await duplicateBlogPost(post.id);
await fetchPosts();
} catch (err: any) {
alert(err.message || 'Erreur lors de la duplication');
} finally {
setActioningId(null);
}
};
const handleRestore = async (post: BlogPost) => {
setActioningId(post.id);
try {
await restoreBlogPost(post.id);
await fetchPosts();
} catch (err: any) {
alert(err.message || 'Erreur lors de la restauration');
} finally {
setActioningId(null);
}
};
const handlePermanentDelete = async () => {
if (!selectedPost) return;
try {
await permanentlyDeleteBlogPost(selectedPost.id);
await fetchPosts();
setShowDeleteConfirm(false);
setSelectedPost(null);
} catch (err: any) {
alert(err.message || 'Erreur lors de la suppression définitive');
}
};
const openCreate = () => {
setFormData(EMPTY_FORM);
setTagsInput('');
@ -295,6 +412,7 @@ export default function AdminBlogPage() {
setEditStatus('draft');
setSelectedPost(null);
setSeoOpen(false);
setGeoOpen(false);
setShowCreateModal(true);
};
@ -313,11 +431,23 @@ export default function AdminBlogPage() {
metaDescription: post.metaDescription ?? '',
primaryKeyword: post.primaryKeyword ?? '',
secondaryKeywordsInput: (post.secondaryKeywords ?? []).join(', '),
aiSummary: post.aiSummary ?? '',
faqItems: (post.faq ?? []).map(item => ({ ...item })),
keyTakeawaysInput: (post.keyTakeaways ?? []).join('\n'),
aiEntitiesInput: (post.aiEntities ?? []).join(', '),
});
setTagsInput(post.tags.join(', '));
setScheduledAt(post.status === 'scheduled' ? toLocalDatetimeValue(post.publishedAt) : '');
setEditStatus(post.status);
setSeoOpen(!!(post.metaTitle || post.metaDescription || post.primaryKeyword));
setGeoOpen(
!!(
post.aiSummary ||
(post.faq && post.faq.length) ||
(post.keyTakeaways && post.keyTakeaways.length) ||
(post.aiEntities && post.aiEntities.length)
)
);
setShowEditModal(true);
};
@ -331,6 +461,7 @@ export default function AdminBlogPage() {
setTagsInput('');
setScheduledAt('');
setSeoOpen(false);
setGeoOpen(false);
};
// Explicit close (X / Retour / after save): reset UI and pop the history
@ -407,10 +538,36 @@ export default function AdminBlogPage() {
</div>
)}
{/* Status filters */}
<div className="mt-6 flex flex-wrap gap-2">
{(
[
{ value: 'all', label: 'Tous' },
{ value: 'published', label: 'Publiés' },
{ value: 'draft', label: 'Brouillons' },
{ value: 'scheduled', label: 'Planifiés' },
{ value: 'trash', label: 'Corbeille' },
] as const
).map(tab => (
<button
key={tab.value}
onClick={() => setViewFilter(tab.value)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
viewFilter === tab.value
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-50'
}`}
>
{tab.value === 'trash' && <Trash2 className="w-3.5 h-3.5" />}
{tab.label}
</button>
))}
</div>
{loading ? (
<div className="text-center py-12 text-gray-500">Chargement des articles...</div>
) : (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-x-auto mt-4">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
@ -435,14 +592,16 @@ export default function AdminBlogPage() {
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{posts.length === 0 ? (
{visiblePosts.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
Aucun article. Créez votre premier article !
{viewFilter === 'trash'
? 'La corbeille est vide.'
: 'Aucun article. Créez votre premier article !'}
</td>
</tr>
) : (
posts.map(post => (
visiblePosts.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">
@ -498,55 +657,96 @@ export default function AdminBlogPage() {
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end space-x-1">
{(post.status === 'published' || post.status === 'scheduled') && (
<Link href={`/blog/${post.slug}`} target="_blank">
{viewFilter === 'trash' ? (
<>
<button
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors"
title="Voir"
onClick={() => handleRestore(post)}
disabled={actioningId === post.id}
className="p-1.5 text-gray-400 hover:text-green-600 transition-colors disabled:opacity-50"
title="Restaurer"
>
<ExternalLink className="w-4 h-4" />
{actioningId === post.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RotateCcw className="w-4 h-4" />
)}
</button>
</Link>
<button
onClick={() => {
setSelectedPost(post);
setShowDeleteConfirm(true);
}}
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors"
title="Supprimer définitivement"
>
<Trash2 className="w-4 h-4" />
</button>
</>
) : (
<>
{(post.status === 'published' || post.status === 'scheduled') && (
<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={() => handleDuplicate(post)}
disabled={actioningId === post.id}
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors disabled:opacity-50"
title="Dupliquer"
>
{actioningId === post.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
<button
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="Mettre à la corbeille"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
<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>
@ -752,6 +952,144 @@ export default function AdminBlogPage() {
</div>
)}
</div>
{/* GEO / AI Panel */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
<button
type="button"
onClick={() => setGeoOpen(o => !o)}
className="w-full flex items-center justify-between px-5 py-4 text-left"
>
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-gray-500" />
<span className="font-semibold text-gray-900 text-sm">
GEO Optimisation pour l&apos;IA
</span>
{(formData.aiSummary ||
formData.faqItems.length > 0 ||
formData.keyTakeawaysInput ||
formData.aiEntitiesInput) && (
<span className="w-2 h-2 rounded-full bg-green-500 inline-block" />
)}
</div>
{geoOpen ? (
<ChevronUp className="w-4 h-4 text-gray-400" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400" />
)}
</button>
{geoOpen && (
<div className="px-5 pb-5 space-y-4 border-t border-gray-100">
<p className="text-xs text-gray-400 mt-4">
Ces champs aident les moteurs IA (ChatGPT, Perplexity) et le référencement
structuré à comprendre et citer votre article.
</p>
{/* AI summary */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Résumé IA (TL;DR)
</label>
<textarea
rows={3}
value={formData.aiSummary}
onChange={e => setFormData(prev => ({ ...prev, aiSummary: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
placeholder="Un résumé concis de l'article, optimisé pour être cité par une IA."
/>
</div>
{/* FAQ */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700">
FAQ (questions / réponses)
</label>
<button
type="button"
onClick={addFaqItem}
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-700"
>
<Plus className="w-3.5 h-3.5" />
Ajouter
</button>
</div>
{formData.faqItems.length === 0 ? (
<p className="text-xs text-gray-400">Aucune question ajoutée.</p>
) : (
<div className="space-y-3">
{formData.faqItems.map((item, index) => (
<div
key={index}
className="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50"
>
<div className="flex items-center gap-2">
<input
type="text"
value={item.question}
onChange={e => updateFaqItem(index, 'question', e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Question"
/>
<button
type="button"
onClick={() => removeFaqItem(index)}
className="p-1.5 text-gray-400 hover:text-red-600"
title="Retirer"
>
<X className="w-4 h-4" />
</button>
</div>
<textarea
rows={2}
value={item.answer}
onChange={e => updateFaqItem(index, 'answer', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
placeholder="Réponse"
/>
</div>
))}
</div>
)}
</div>
{/* Key takeaways */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Points clés à retenir{' '}
<span className="text-gray-400 font-normal">(un par ligne)</span>
</label>
<textarea
rows={3}
value={formData.keyTakeawaysInput}
onChange={e =>
setFormData(prev => ({ ...prev, keyTakeawaysInput: e.target.value }))
}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
placeholder={'Le fret LCL est idéal pour les petits volumes\nComparez plusieurs transporteurs'}
/>
</div>
{/* AI entities */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Entités / sujets{' '}
<span className="text-gray-400 font-normal">(virgule)</span>
</label>
<input
type="text"
value={formData.aiEntitiesInput}
onChange={e =>
setFormData(prev => ({ ...prev, aiEntitiesInput: e.target.value }))
}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="ex: fret maritime, LCL, incoterms, Le Havre"
/>
</div>
</div>
)}
</div>
</div>
{/* Sidebar — 1/3 */}
@ -843,15 +1181,27 @@ export default function AdminBlogPage() {
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"
onDragOver={e => {
e.preventDefault();
setIsDraggingCover(true);
}}
onDragLeave={() => setIsDraggingCover(false)}
onDrop={handleCoverDrop}
className={`w-full h-32 border-2 border-dashed rounded-lg flex flex-col items-center justify-center gap-2 transition-colors disabled:opacity-50 ${
isDraggingCover
? 'border-blue-500 bg-blue-50 text-blue-600'
: 'border-gray-300 text-gray-400 hover:border-blue-400 hover:text-blue-500'
}`}
>
{uploadingCover ? (
<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>
<span className="text-sm">
{isDraggingCover ? "Déposez l'image ici" : 'Glisser-déposer ou cliquer'}
</span>
<span className="text-xs">JPG, PNG, WebP recadrée en 16:9</span>
</>
)}
</button>
@ -949,10 +1299,22 @@ export default function AdminBlogPage() {
{showDeleteConfirm && selectedPost && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-2">Supprimer l&apos;article</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-2">
{viewFilter === 'trash' ? 'Supprimer définitivement' : "Mettre à la corbeille"}
</h2>
<p className="text-gray-600 mb-6">
Êtes-vous sûr de vouloir supprimer <strong>«&nbsp;{selectedPost.title}&nbsp;»</strong>{' '}
? Cette action est irréversible.
{viewFilter === 'trash' ? (
<>
Êtes-vous sûr de vouloir supprimer définitivement{' '}
<strong>«&nbsp;{selectedPost.title}&nbsp;»</strong> ? Cette action est
irréversible.
</>
) : (
<>
<strong>«&nbsp;{selectedPost.title}&nbsp;»</strong> sera déplacé vers la corbeille.
Vous pourrez le restaurer plus tard.
</>
)}
</p>
<div className="flex justify-end space-x-3">
<button
@ -965,10 +1327,10 @@ export default function AdminBlogPage() {
Annuler
</button>
<button
onClick={handleDelete}
onClick={viewFilter === 'trash' ? handlePermanentDelete : handleDelete}
className="px-6 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
>
Supprimer
{viewFilter === 'trash' ? 'Supprimer définitivement' : 'Mettre à la corbeille'}
</button>
</div>
</div>

View File

@ -214,6 +214,16 @@ export default function BlogPostContent({ slug }: { slug: string }) {
{/* Content + Sidebar */}
<section className="py-12">
<div className="max-w-4xl mx-auto px-6 lg:px-8">
{/* En bref (AI summary / TL;DR) */}
{post.aiSummary && (
<div className="mb-10 rounded-2xl border border-brand-turquoise/30 bg-brand-turquoise/5 p-6">
<p className="text-xs font-semibold uppercase tracking-wide text-brand-turquoise mb-2">
En bref
</p>
<p className="text-gray-700 leading-relaxed">{post.aiSummary}</p>
</div>
)}
<div className="lg:grid lg:grid-cols-[1fr_80px] lg:gap-8 items-start">
{/* Article body */}
<motion.article
@ -245,6 +255,44 @@ export default function BlogPostContent({ slug }: { slug: string }) {
</div>
</div>
{/* À retenir (key takeaways) */}
{post.keyTakeaways && post.keyTakeaways.length > 0 && (
<div className="mt-12 rounded-2xl border border-gray-200 bg-gray-50 p-6">
<h2 className="text-lg font-bold text-brand-navy mb-4">À retenir</h2>
<ul className="space-y-2">
{post.keyTakeaways.map((item, i) => (
<li key={i} className="flex items-start gap-3 text-gray-700">
<span className="mt-2 w-1.5 h-1.5 rounded-full bg-brand-turquoise flex-shrink-0" />
<span className="leading-relaxed">{item}</span>
</li>
))}
</ul>
</div>
)}
{/* FAQ */}
{post.faq && post.faq.length > 0 && (
<div className="mt-12">
<h2 className="text-2xl font-bold text-brand-navy mb-6">Questions fréquentes</h2>
<div className="space-y-4">
{post.faq.map((item, i) => (
<details
key={i}
className="group rounded-xl border border-gray-200 bg-white p-5 open:shadow-sm"
>
<summary className="cursor-pointer font-semibold text-brand-navy list-none flex items-center justify-between gap-4">
<span>{item.question}</span>
<span className="text-brand-turquoise transition-transform group-open:rotate-45 text-xl leading-none">
+
</span>
</summary>
<p className="mt-3 text-gray-700 leading-relaxed">{item.answer}</p>
</details>
))}
</div>
</div>
)}
{/* Tags */}
{post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-12 pt-8 border-t border-gray-100">

View File

@ -3,29 +3,79 @@ import BlogPostContent from './BlogPostContent';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
async function fetchPostMeta(slug: string) {
interface BlogFaqItem {
question: string;
answer: string;
}
interface BlogPostMeta {
title: string;
slug: string;
excerpt: string;
metaTitle?: string;
metaDescription?: string;
primaryKeyword?: string;
secondaryKeywords?: string[];
coverImageUrl?: string;
authorName: string;
publishedAt?: string;
updatedAt: string;
aiSummary?: string | null;
faq?: BlogFaqItem[];
keyTakeaways?: string[];
}
async function fetchPostMeta(slug: string): Promise<BlogPostMeta | null> {
try {
const res = await fetch(`${API_BASE_URL}/api/v1/blog/${slug}`, {
cache: 'no-store',
});
if (!res.ok) return null;
return res.json() as Promise<{
title: string;
excerpt: string;
metaTitle?: string;
metaDescription?: string;
primaryKeyword?: string;
secondaryKeywords?: string[];
coverImageUrl?: string;
authorName: string;
publishedAt?: string;
updatedAt: string;
}>;
return (await res.json()) as BlogPostMeta;
} catch {
return null;
}
}
function buildJsonLd(post: BlogPostMeta, slug: string) {
const coverImage = post.coverImageUrl
? post.coverImageUrl.startsWith('http')
? post.coverImageUrl
: `${API_BASE_URL}${post.coverImageUrl}`
: undefined;
const article = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.metaTitle || post.title,
description: post.metaDescription || post.aiSummary || post.excerpt,
image: coverImage ? [coverImage] : undefined,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { '@type': 'Person', name: post.authorName },
publisher: { '@type': 'Organization', name: 'Xpeditis' },
mainEntityOfPage: { '@type': 'WebPage', '@id': `https://xpeditis.com/blog/${slug}` },
keywords: [post.primaryKeyword, ...(post.secondaryKeywords ?? [])]
.filter(Boolean)
.join(', '),
};
const faqPage =
post.faq && post.faq.length > 0
? {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: post.faq.map(item => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: { '@type': 'Answer', text: item.answer },
})),
}
: null;
return { article, faqPage };
}
export async function generateMetadata({
params,
}: {
@ -72,10 +122,31 @@ export async function generateMetadata({
};
}
export default function BlogPostPage({
export default async function BlogPostPage({
params,
}: {
params: { slug: string; locale: string };
}) {
return <BlogPostContent slug={params.slug} />;
const post = await fetchPostMeta(params.slug);
const jsonLd = post ? buildJsonLd(post, params.slug) : null;
return (
<>
{jsonLd && (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd.article) }}
/>
{jsonLd.faqPage && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd.faqPage) }}
/>
)}
</>
)}
<BlogPostContent slug={params.slug} />
</>
);
}

View File

@ -1,416 +0,0 @@
/**
* Edit Booking Page (before payment)
*
* Lets the owner adjust cargo details (volume, weight, pallets, notes) and manage
* documents of a booking that is still awaiting payment (PENDING_PAYMENT), then
* continue to the payment page. Carrier, route and price derive from the selected
* rate and are shown read-only.
*/
'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation';
import {
ArrowLeft,
Loader2,
AlertTriangle,
Save,
CreditCard,
FileText,
Trash2,
Upload,
Package,
} from 'lucide-react';
import { getCsvBooking, updateCsvBookingDetails } from '@/lib/api/bookings';
import { upload, del } from '@/lib/api/client';
interface BookingDoc {
id?: string;
type: string;
fileName: string;
url: string;
}
interface BookingData {
id: string;
bookingNumber?: string;
carrierName: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
palletCount: number;
containerType: string;
status: string;
notes?: string;
documents: BookingDoc[];
}
export default function EditBookingPage() {
const router = useRouter();
const params = useParams();
const bookingId = params.id as string;
const [booking, setBooking] = useState<BookingData | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [volumeCBM, setVolumeCBM] = useState('');
const [weightKG, setWeightKG] = useState('');
const [palletCount, setPalletCount] = useState('');
const [notes, setNotes] = useState('');
const [uploadingDocs, setUploadingDocs] = useState(false);
const [deletingDocId, setDeletingDocId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const applyBooking = (data: BookingData) => {
setBooking(data);
setVolumeCBM(String(data.volumeCBM ?? ''));
setWeightKG(String(data.weightKG ?? ''));
setPalletCount(String(data.palletCount ?? ''));
setNotes(data.notes ?? '');
};
const loadBooking = async () => {
try {
const data = (await getCsvBooking(bookingId)) as any as BookingData;
if (data.status !== 'PENDING_PAYMENT') {
// Only bookings awaiting payment can be edited.
router.replace('/dashboard/bookings');
return;
}
applyBooking(data);
} catch (err) {
setError('Impossible de charger la réservation');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (bookingId) loadBooking();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookingId]);
const persistDetails = async (): Promise<boolean> => {
setError(null);
const vol = parseFloat(volumeCBM);
const wgt = parseFloat(weightKG);
const plt = parseInt(palletCount, 10);
if (!Number.isFinite(vol) || vol <= 0) {
setError('Le volume doit être un nombre positif.');
return false;
}
if (!Number.isFinite(wgt) || wgt <= 0) {
setError('Le poids doit être un nombre positif.');
return false;
}
if (!Number.isFinite(plt) || plt < 0) {
setError('Le nombre de palettes est invalide.');
return false;
}
const updated = (await updateCsvBookingDetails(bookingId, {
volumeCBM: vol,
weightKG: wgt,
palletCount: plt,
notes: notes.trim() || undefined,
})) as any as BookingData;
applyBooking(updated);
return true;
};
const handleSave = async () => {
setSaving(true);
setSuccess(null);
try {
const ok = await persistDetails();
if (ok) setSuccess('Modifications enregistrées.');
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur lors de l'enregistrement");
} finally {
setSaving(false);
}
};
const handleContinueToPayment = async () => {
setSaving(true);
setSuccess(null);
try {
const ok = await persistDetails();
if (ok) router.push(`/dashboard/booking/${bookingId}/pay`);
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur lors de l'enregistrement");
} finally {
setSaving(false);
}
};
const handleAddDocuments = async (files: FileList | null) => {
if (!files || files.length === 0) return;
setUploadingDocs(true);
setError(null);
try {
const formData = new FormData();
Array.from(files).forEach(file => formData.append('documents', file));
await upload(`/api/v1/csv-bookings/${bookingId}/documents`, formData);
await loadBooking();
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur lors de l'ajout des documents");
} finally {
setUploadingDocs(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleDeleteDocument = async (docId?: string) => {
if (!docId) return;
if (!confirm('Supprimer ce document ?')) return;
setDeletingDocId(docId);
setError(null);
try {
await del(`/api/v1/csv-bookings/${bookingId}/documents/${docId}`);
await loadBooking();
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur lors de la suppression du document');
} finally {
setDeletingDocId(null);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
<div className="flex items-center space-x-3">
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
<span className="text-gray-600">Chargement...</span>
</div>
</div>
);
}
if (!booking) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
<div className="bg-white rounded-xl shadow-md p-8 max-w-md">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<p className="text-center text-gray-700">{error || 'Réservation introuvable'}</p>
<button
onClick={() => router.push('/dashboard/bookings')}
className="mt-4 w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retour aux réservations
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
<div className="max-w-4xl mx-auto">
<button
onClick={() => router.back()}
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour
</button>
<h1 className="text-2xl font-bold text-gray-900 mb-1">Modifier la réservation</h1>
<p className="text-gray-500 mb-8">
Corrigez les informations de votre réservation avant de procéder au paiement.
</p>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start space-x-3">
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{success && (
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-green-700 text-sm">{success}</p>
</div>
)}
{/* Read-only rate summary */}
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Réservation
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-gray-500">Transporteur</p>
<p className="font-semibold text-gray-900">{booking.carrierName}</p>
</div>
<div>
<p className="text-gray-500">Trajet</p>
<p className="font-semibold text-gray-900">
{booking.origin} {booking.destination}
</p>
</div>
<div>
<p className="text-gray-500">Type</p>
<p className="font-semibold text-gray-900">{booking.containerType}</p>
</div>
{booking.bookingNumber && (
<div>
<p className="text-gray-500">Numéro</p>
<p className="font-semibold text-gray-900">{booking.bookingNumber}</p>
</div>
)}
</div>
<p className="text-xs text-gray-400 mt-3">
Le transporteur, le trajet et le prix proviennent du tarif sélectionné et ne sont pas
modifiables ici.
</p>
</div>
{/* Editable cargo details */}
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6 space-y-4">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide flex items-center gap-2">
<Package className="h-4 w-4" />
Caractéristiques de la marchandise
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Volume (CBM)</label>
<input
type="number"
min="0"
step="0.01"
value={volumeCBM}
onChange={e => setVolumeCBM(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Poids (kg)</label>
<input
type="number"
min="0"
step="0.01"
value={weightKG}
onChange={e => setWeightKG(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Palettes</label>
<input
type="number"
min="0"
step="1"
value={palletCount}
onChange={e => setPalletCount(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea
rows={3}
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Instructions particulières..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
</div>
{/* Documents */}
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide flex items-center gap-2">
<FileText className="h-4 w-4" />
Documents
</h2>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingDocs}
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors disabled:opacity-50"
>
{uploadingDocs ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
Ajouter
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={e => handleAddDocuments(e.target.files)}
/>
</div>
{booking.documents.length === 0 ? (
<p className="text-sm text-gray-400">Aucun document.</p>
) : (
<ul className="divide-y divide-gray-100">
{booking.documents.map((doc, idx) => (
<li
key={doc.id || `${doc.fileName}-${idx}`}
className="flex items-center justify-between py-2.5"
>
<div className="flex items-center gap-3 min-w-0">
<FileText className="h-4 w-4 text-gray-400 flex-shrink-0" />
<span className="text-sm text-gray-800 truncate">{doc.fileName}</span>
</div>
{doc.id && (
<button
type="button"
onClick={() => handleDeleteDocument(doc.id)}
disabled={deletingDocId === doc.id}
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors disabled:opacity-50"
title="Supprimer"
>
{deletingDocId === doc.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
)}
</li>
))}
</ul>
)}
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={handleSave}
disabled={saving}
className="flex-1 py-3 bg-white border border-gray-300 text-gray-800 rounded-lg font-semibold hover:bg-gray-50 disabled:opacity-50 flex items-center justify-center gap-2 transition-colors"
>
{saving ? <Loader2 className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
Enregistrer les modifications
</button>
<button
onClick={handleContinueToPayment}
disabled={saving}
className="flex-1 py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2 transition-colors"
>
{saving ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<CreditCard className="h-5 w-5" />
)}
Continuer vers le paiement
</button>
</div>
</div>
</div>
);
}

View File

@ -227,7 +227,23 @@ export default function PayCommissionPage() {
Finalisez votre booking en réglant le forfait par booking
</p>
<button
onClick={() => router.push(`/dashboard/booking/${bookingId}/edit`)}
onClick={() => {
const opts = (booking as any).options || {};
const enabledOptions = Object.keys(opts)
.filter(k => opts[k])
.join(',');
const params = new URLSearchParams({
editBookingId: bookingId,
origin: booking.origin || '',
destination: booking.destination || '',
volumeCBM: String(booking.volumeCBM ?? ''),
weightKG: String(booking.weightKG ?? ''),
palletCount: String(booking.palletCount ?? ''),
hasDangerousGoods: String(!!opts.dangerousGoods),
});
if (enabledOptions) params.set('options', enabledOptions);
router.push(`/dashboard/search-advanced?${params.toString()}`);
}}
className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
>
<ArrowLeft className="h-4 w-4" />

View File

@ -193,6 +193,17 @@ function NewBookingPageContent() {
formDataToSend.append('notes', formData.notes);
}
// Options & services selected in the search form (persisted with the booking)
const optionsParam = searchParams.get('options') || '';
if (optionsParam) {
const optionsObj = optionsParam
.split(',')
.map(s => s.trim())
.filter(Boolean)
.reduce((acc, k) => ({ ...acc, [k]: true }), {} as Record<string, boolean>);
formDataToSend.append('options', JSON.stringify(optionsObj));
}
// Append documents
formData.documents.forEach(file => {
formDataToSend.append('documents', file);

View File

@ -4,11 +4,12 @@ import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listCsvBookings } from '@/lib/api';
import { Link } from '@/i18n/navigation';
import { Plus, Clock, Eye, X, CreditCard, Pencil } from 'lucide-react';
import { Plus, Clock, Eye, X, CreditCard, Pencil, MoreVertical, Lock, Sparkles } from 'lucide-react';
import ExportButton from '@/components/ExportButton';
import { useSearchParams } from 'next/navigation';
import { PageHeader } from '@/components/ui/PageHeader';
import { useTranslations, useLocale } from 'next-intl';
import { useReservationQuota } from '@/hooks/useReservationQuota';
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
@ -26,8 +27,55 @@ export default function BookingsListPage() {
const [page, setPage] = useState(1);
const [showTransferBanner, setShowTransferBanner] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<any | null>(null);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null);
const ITEMS_PER_PAGE = 20;
// "Modifier" reopens the existing booking flow (search → carrier choice →
// payment) pre-filled with the booking's data, so a change re-quotes the price.
const buildEditUrl = (b: any) => {
const enabledOptions = Object.keys(b.options || {})
.filter(k => b.options[k])
.join(',');
const params = new URLSearchParams({
editBookingId: b.id,
origin: b.origin || b.originCity || '',
destination: b.destination || b.destinationCity || '',
volumeCBM: String(b.volumeCBM ?? ''),
weightKG: String(b.weightKG ?? ''),
palletCount: String(b.palletCount ?? ''),
hasDangerousGoods: String(!!(b.options && b.options.dangerousGoods)),
});
if (enabledOptions) params.set('options', enabledOptions);
return `/dashboard/search-advanced?${params.toString()}`;
};
// Kebab (⋮) actions menu — anchored with fixed positioning so it is never
// clipped by the scrollable table container.
const toggleMenu = (e: React.MouseEvent, bookingId: string) => {
e.stopPropagation();
if (openMenuId === bookingId) {
setOpenMenuId(null);
return;
}
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setMenuPos({ top: rect.bottom + 6, right: window.innerWidth - rect.right });
setOpenMenuId(bookingId);
};
useEffect(() => {
if (!openMenuId) return;
const close = () => setOpenMenuId(null);
window.addEventListener('click', close);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
};
}, [openMenuId]);
useEffect(() => {
if (searchParams.get('transfer') === 'declared') {
setShowTransferBanner(true);
@ -49,6 +97,29 @@ export default function BookingsListPage() {
if (csvError) console.error('CSV bookings error:', csvError);
const quota = useReservationQuota();
// When the free-plan reservation limit is reached, the "New reservation"
// entry point is replaced by an upgrade call-to-action.
const newReservationAction = quota.blocked ? (
<Link
href="/dashboard/settings/subscription"
className="inline-flex items-center gap-2 px-3 sm:px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-500 hover:bg-amber-600"
>
<Sparkles className="h-4 w-4" />
<span className="hidden sm:inline">{t('limit.upgrade')}</span>
<span className="sm:hidden">{t('limit.upgradeShort')}</span>
</Link>
) : (
<Link
href="/dashboard/search-advanced"
className="inline-flex items-center px-3 sm:px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">{t('new')}</span>
</Link>
);
const filterBookings = (bookings: any[]) => {
let filtered = bookings;
@ -207,17 +278,30 @@ export default function BookingsListPage() {
},
]}
/>
<Link
href="/dashboard/search-advanced"
className="inline-flex items-center px-3 sm:px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">{t('new')}</span>
</Link>
{newReservationAction}
</>
}
/>
{quota.blocked && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
<Lock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-amber-800">{t('limit.title')}</p>
<p className="text-sm text-amber-700 mt-0.5">
{t('limit.message', { max: quota.max })}
</p>
<Link
href="/dashboard/settings/subscription"
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-amber-500 text-white text-sm font-medium rounded-lg hover:bg-amber-600 transition-colors"
>
<Sparkles className="h-4 w-4" />
{t('limit.upgrade')}
</Link>
</div>
</div>
)}
<div className="bg-white rounded-lg shadow p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
@ -369,33 +453,14 @@ export default function BookingsListPage() {
})
: t('mobile.booking', { number: booking.bookingNumber || '-' })}
</div>
<div className="flex items-center gap-2">
{booking.status === 'PENDING_PAYMENT' && (
<>
<Link
href={`/dashboard/booking/${booking.id}/edit`}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
{t('actions.edit')}
</Link>
<Link
href={`/dashboard/booking/${booking.id}/pay`}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
>
<CreditCard className="h-3.5 w-3.5" />
{t('actions.pay')}
</Link>
</>
)}
<button
onClick={() => setSelectedBooking(booking)}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
>
<Eye className="h-3.5 w-3.5" />
{t('actions.view')}
</button>
</div>
<button
onClick={e => toggleMenu(e, booking.id)}
className="p-2 -mr-1 rounded-full text-gray-500 hover:bg-gray-100 transition-colors"
title={t('columns.actions')}
aria-label={t('columns.actions')}
>
<MoreVertical className="h-5 w-5" />
</button>
</div>
</div>
))}
@ -420,7 +485,7 @@ export default function BookingsListPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.date')}
</th>
<th className="sticky right-0 z-10 bg-gray-50 px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.actions')}
</th>
</tr>
@ -491,34 +556,15 @@ export default function BookingsListPage() {
})
: 'N/A'}
</td>
<td className="sticky right-0 z-10 bg-white group-hover:bg-gray-50 px-6 py-4 whitespace-nowrap text-right shadow-[-8px_0_8px_-8px_rgba(0,0,0,0.08)]">
<div className="flex items-center justify-end gap-2">
{booking.status === 'PENDING_PAYMENT' && (
<>
<Link
href={`/dashboard/booking/${booking.id}/edit`}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
{t('actions.edit')}
</Link>
<Link
href={`/dashboard/booking/${booking.id}/pay`}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
>
<CreditCard className="h-3.5 w-3.5" />
{t('actions.pay')}
</Link>
</>
)}
<button
onClick={() => setSelectedBooking(booking)}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
>
<Eye className="h-3.5 w-3.5" />
{t('actions.view')}
</button>
</div>
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={e => toggleMenu(e, booking.id)}
className="p-2 rounded-full text-gray-500 hover:bg-gray-100 transition-colors"
title={t('columns.actions')}
aria-label={t('columns.actions')}
>
<MoreVertical className="h-5 w-5" />
</button>
</td>
</tr>
);
@ -660,18 +706,72 @@ export default function BookingsListPage() {
{searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings')}
</p>
<div className="mt-6">
<Link
href="/dashboard/search-advanced"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<Plus className="mr-2 h-4 w-4" />
{t('new')}
</Link>
{quota.blocked ? (
<Link
href="/dashboard/settings/subscription"
className="inline-flex items-center gap-2 px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-500 hover:bg-amber-600"
>
<Sparkles className="h-4 w-4" />
{t('limit.upgrade')}
</Link>
) : (
<Link
href="/dashboard/search-advanced"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<Plus className="mr-2 h-4 w-4" />
{t('new')}
</Link>
)}
</div>
</div>
)}
</div>
{/* Actions menu (⋮) — fixed-positioned to avoid clipping */}
{openMenuId &&
menuPos &&
(() => {
const b = paginatedBookings.find((x: any) => x.id === openMenuId);
if (!b) return null;
return (
<div
className="fixed z-50 w-44 bg-white rounded-lg shadow-lg border border-gray-200 py-1"
style={{ top: menuPos.top, right: menuPos.right }}
onClick={e => e.stopPropagation()}
>
{b.status === 'PENDING_PAYMENT' && (
<>
<Link
href={buildEditUrl(b)}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<Pencil className="h-4 w-4 text-gray-500" />
{t('actions.edit')}
</Link>
<Link
href={`/dashboard/booking/${b.id}/pay`}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<CreditCard className="h-4 w-4 text-orange-500" />
{t('actions.pay')}
</Link>
</>
)}
<button
onClick={() => {
setOpenMenuId(null);
setSelectedBooking(b);
}}
className="flex items-center gap-2 w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<Eye className="h-4 w-4 text-blue-600" />
{t('actions.view')}
</button>
</div>
);
})()}
{/* Booking Detail Modal */}
{selectedBooking && (
<div className="fixed inset-0 z-50 overflow-y-auto">
@ -772,7 +872,7 @@ export default function BookingsListPage() {
</p>
<div className="mt-3 flex items-center gap-2">
<Link
href={`/dashboard/booking/${selectedBooking.id}/edit`}
href={buildEditUrl(selectedBooking)}
onClick={() => setSelectedBooking(null)}
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors"
>

View File

@ -68,6 +68,7 @@ export default function UserDocumentsPage() {
const [deletingFile, setDeletingFile] = useState(false);
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number } | null>(null);
const getQuoteNumber = (booking: CsvBookingResponse): string => {
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
@ -309,15 +310,29 @@ export default function UserDocumentsPage() {
}
};
const toggleDropdown = (docId: string) => {
setOpenDropdownId(openDropdownId === docId ? null : docId);
const toggleDropdown = (docId: string, e: React.MouseEvent) => {
if (openDropdownId === docId) {
setOpenDropdownId(null);
return;
}
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
// Anchor the menu with fixed positioning so it is never clipped by the
// horizontally scrollable table container.
setDropdownPos({ top: rect.bottom + 6, right: window.innerWidth - rect.right });
setOpenDropdownId(docId);
};
useEffect(() => {
const handleClickOutside = () => setOpenDropdownId(null);
if (openDropdownId) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
window.addEventListener('scroll', handleClickOutside, true);
window.addEventListener('resize', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
window.removeEventListener('scroll', handleClickOutside, true);
window.removeEventListener('resize', handleClickOutside);
};
}
}, [openDropdownId]);
@ -533,7 +548,7 @@ export default function UserDocumentsPage() {
)}
{/* Documents Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="bg-white rounded-lg shadow overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
@ -600,7 +615,7 @@ export default function UserDocumentsPage() {
<button
onClick={e => {
e.stopPropagation();
toggleDropdown(`${doc.bookingId}-${doc.id}`);
toggleDropdown(`${doc.bookingId}-${doc.id}`, e);
}}
className="inline-flex items-center justify-center w-8 h-8 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title={t('table.actions')}
@ -612,9 +627,10 @@ export default function UserDocumentsPage() {
</svg>
</button>
{openDropdownId === `${doc.bookingId}-${doc.id}` && (
{openDropdownId === `${doc.bookingId}-${doc.id}` && dropdownPos && (
<div
className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"
className="fixed w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"
style={{ top: dropdownPos.top, right: dropdownPos.right }}
onClick={e => e.stopPropagation()}
>
<div className="py-1">

View File

@ -2,10 +2,13 @@
import { useState, useEffect } from 'react';
import { useRouter } from '@/i18n/navigation';
import { Search, Loader2 } from 'lucide-react';
import { useSearchParams } from 'next/navigation';
import { Search, Loader2, Lock, Sparkles } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { getAvailableOrigins, getAvailableDestinations, RoutePortInfo } from '@/lib/api/rates';
import { useReservationQuota } from '@/hooks/useReservationQuota';
import { Link } from '@/i18n/navigation';
import dynamic from 'next/dynamic';
const PortRouteMapLoader = () => {
@ -50,9 +53,29 @@ interface SearchForm {
t1Document: boolean;
}
// Options & services persisted with the booking (order irrelevant).
const OPTION_KEYS: (keyof SearchForm)[] = [
'eurDocument',
't1Document',
'customsStop',
'exportAssistance',
'dangerousGoods',
'regulatedProducts',
'specialHandling',
'tailgate',
'straps',
'thermalCover',
'appointment',
'insurance',
];
export default function AdvancedSearchPage() {
const t = useTranslations('dashboard.rateSearch');
const tLimit = useTranslations('dashboard.bookingsList.limit');
const router = useRouter();
const searchParams = useSearchParams();
const quota = useReservationQuota();
const [editBookingId, setEditBookingId] = useState<string | null>(null);
const [searchForm, setSearchForm] = useState<SearchForm>({
origin: '',
destination: '',
@ -137,6 +160,106 @@ export default function AdvancedSearchPage() {
}
}, [searchForm.origin, destinationsData]);
// Prefill the form when re-editing an existing unpaid booking (or from any
// deep link with query params). Runs once on mount.
useEffect(() => {
const originParam = searchParams.get('origin');
const destinationParam = searchParams.get('destination');
const editId = searchParams.get('editBookingId');
if (!originParam && !destinationParam && !editId) return;
if (editId) setEditBookingId(editId);
const vol = parseFloat(searchParams.get('volumeCBM') || '0');
const wgt = parseFloat(searchParams.get('weightKG') || '0');
const pallets = parseInt(searchParams.get('palletCount') || '0', 10);
const dg = searchParams.get('hasDangerousGoods') === 'true';
// Prefill the options & services from the "options" CSV param.
const enabledOptions = (searchParams.get('options') || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
const optionFlags = OPTION_KEYS.reduce(
(acc, k) => ({ ...acc, [k]: enabledOptions.includes(k) }),
{} as Record<string, boolean>
);
// Reconstruct a single representative package that reproduces the aggregate
// totals (the booking only stores totals, not individual packages).
const q = pallets > 0 ? pallets : 1;
const perVolM3 = vol > 0 ? vol / q : 0;
const isPalette = pallets > 0;
// The booking only stores aggregate totals, not per-package dimensions. For
// pallets we reconstruct the standard EUR pallet footprint (120×80 cm) and
// derive the height from the volume — so a default 0.96 CBM pallet comes back
// as 120×80×100. For loose parcels we fall back to a cube of equal volume.
let length: number;
let width: number;
let height: number;
if (isPalette) {
length = 120;
width = 80;
const baseM2 = (length / 100) * (width / 100); // 0.96 m²
height = perVolM3 > 0 ? Math.round((perVolM3 / baseM2) * 100 * 100) / 100 : 100;
} else {
const sideCm = perVolM3 > 0 ? Math.round(Math.cbrt(perVolM3) * 100 * 100) / 100 : 0;
length = sideCm;
width = sideCm;
height = sideCm;
}
const pkg = {
type: (isPalette ? 'palette' : 'colis') as Package['type'],
quantity: q,
length,
width,
height,
weight: wgt > 0 ? Math.round((wgt / q) * 100) / 100 : 0,
stackable: true,
};
setSearchForm(prev => ({
...prev,
...optionFlags,
origin: originParam || prev.origin,
destination: destinationParam || prev.destination,
dangerousGoods: dg || !!optionFlags.dangerousGoods,
packages: vol > 0 || wgt > 0 || pallets > 0 ? [pkg] : prev.packages,
}));
if (originParam) setOriginSearch(originParam);
if (destinationParam) setDestinationSearch(destinationParam);
// Start at the cargo (packages) step as requested.
setCurrentStep(2);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Once port lists load, resolve the prefilled codes to full port objects so
// the selection UI reflects them (and destination validation passes).
useEffect(() => {
if (searchForm.origin && !selectedOriginPort) {
const p = (originsData?.origins || []).find(o => o.code === searchForm.origin);
if (p) {
setSelectedOriginPort(p);
setOriginSearch(p.displayName);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [originsData, searchForm.origin]);
useEffect(() => {
if (searchForm.destination && !selectedDestinationPort) {
const p = (destinationsData?.destinations || []).find(
d => d.code === searchForm.destination
);
if (p) {
setSelectedDestinationPort(p);
setDestinationSearch(p.displayName);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [destinationsData, searchForm.destination]);
const calculateTotals = () => {
let totalVolumeCBM = 0;
let totalWeightKG = 0;
@ -172,6 +295,14 @@ export default function AdvancedSearchPage() {
requiresAppointment: searchForm.appointment.toString(),
});
if (editBookingId) {
params.set('editBookingId', editBookingId);
}
// Carry the selected options & services so they can be persisted.
const enabledOptions = OPTION_KEYS.filter(k => searchForm[k]).join(',');
if (enabledOptions) params.set('options', enabledOptions);
router.push(`/dashboard/search-advanced/results?${params.toString()}`);
};
@ -690,6 +821,39 @@ export default function AdvancedSearchPage() {
</div>
);
// Block starting a NEW reservation when the free-plan yearly limit is reached.
// Editing an existing unpaid booking (editBookingId) does not create a new
// shipment, so it is never blocked.
const isEditing = !!searchParams.get('editBookingId');
if (quota.blocked && !isEditing && !quota.loading) {
return (
<div className="max-w-2xl mx-auto py-10 px-4">
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-8 text-center">
<div className="mx-auto w-14 h-14 rounded-full bg-amber-100 flex items-center justify-center mb-4">
<Lock className="h-7 w-7 text-amber-600" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{tLimit('title')}</h1>
<p className="text-gray-600 mb-6">{tLimit('message', { max: quota.max })}</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href="/dashboard/settings/subscription"
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-amber-500 text-white rounded-lg font-semibold hover:bg-amber-600 transition-colors"
>
<Sparkles className="h-5 w-5" />
{tLimit('upgrade')}
</Link>
<Link
href="/dashboard/bookings"
className="inline-flex items-center justify-center px-6 py-3 bg-white border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
>
{tLimit('back')}
</Link>
</div>
</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto space-y-6">
<div>

View File

@ -5,6 +5,7 @@ import { useSearchParams } from 'next/navigation';
import { useRouter } from '@/i18n/navigation';
import { useTranslations, useLocale } from 'next-intl';
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
import { updateCsvBookingRate } from '@/lib/api/bookings';
import type { CsvRateSearchResult } from '@/types/rates';
import {
Search,
@ -38,6 +39,69 @@ export default function SearchResultsPage() {
const volumeCBM = parseFloat(searchParams.get('volumeCBM') || '0');
const weightKG = parseFloat(searchParams.get('weightKG') || '0');
const palletCount = parseInt(searchParams.get('palletCount') || '0');
// When present, we are re-selecting a rate for an existing unpaid booking:
// update it in place and go straight to payment (skip the new-booking form).
const editBookingId = searchParams.get('editBookingId');
const optionsParam = searchParams.get('options') || '';
const optionsObj: Record<string, boolean> = optionsParam
? optionsParam
.split(',')
.map(s => s.trim())
.filter(Boolean)
.reduce((acc, k) => ({ ...acc, [k]: true }), {})
: {};
const [submittingId, setSubmittingId] = useState<string | null>(null);
const [selectError, setSelectError] = useState<string | null>(null);
const handleSelect = async (option: CsvRateSearchResult, key: string) => {
if (editBookingId) {
setSubmittingId(key);
setSelectError(null);
try {
const pb = option.priceBreakdown;
const freightTotal = pb.totalFreight;
const freightCurrency = pb.freightCurrency;
const fobTotal = pb.totalFob;
const fobCurrency = pb.fobCurrency;
await updateCsvBookingRate(editBookingId, {
carrierName: option.companyName,
carrierEmail: option.companyEmail,
origin: option.origin,
destination: option.destination,
containerType: option.containerType,
transitDays: option.adjustedTransitDays ?? option.transitDays,
volumeCBM,
weightKG,
palletCount,
priceUSD:
(freightCurrency === 'USD' ? freightTotal : 0) +
(fobCurrency === 'USD' ? fobTotal : 0),
priceEUR:
(freightCurrency === 'EUR' ? freightTotal : 0) +
(fobCurrency === 'EUR' ? fobTotal : 0),
primaryCurrency: pb.primaryCurrency || 'USD',
freightTotal,
freightCurrency,
fobTotal,
fobCurrency,
options: optionsObj,
});
router.push(`/dashboard/booking/${editBookingId}/pay`);
} catch (err) {
setSelectError(
err instanceof Error ? err.message : 'Erreur lors de la mise à jour de la réservation'
);
setSubmittingId(null);
}
return;
}
const rateData = encodeURIComponent(JSON.stringify(option));
const optionsQuery = optionsParam ? `&options=${encodeURIComponent(optionsParam)}` : '';
router.push(
`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}${optionsQuery}`
);
};
const performSearch = useCallback(async () => {
setIsLoading(true);
@ -260,6 +324,17 @@ export default function SearchResultsPage() {
</div>
</div>
{editBookingId && (
<div className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">
{t('editMode')}
</div>
)}
{selectError && (
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{selectError}
</div>
)}
{/* Best Options */}
{bestOptions && (
<div className="mb-12">
@ -323,15 +398,15 @@ export default function SearchResultsPage() {
</div>
<button
onClick={() => {
const rateData = encodeURIComponent(JSON.stringify(card.option));
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`}
onClick={() => handleSelect(card.option!, card.type)}
disabled={submittingId === card.type}
className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors disabled:opacity-60`}
>
{t('select')}
{submittingId === card.type
? '...'
: editBookingId
? t('selectAndPay')
: t('select')}
</button>
</div>
</div>
@ -416,15 +491,15 @@ export default function SearchResultsPage() {
)}
</div>
<button
onClick={() => {
const rateData = encodeURIComponent(JSON.stringify(result));
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"
onClick={() => handleSelect(result, `all-${index}`)}
disabled={submittingId === `all-${index}`}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-60"
>
{t('selectShort')}
{submittingId === `all-${index}`
? '...'
: editBookingId
? t('selectAndPay')
: t('selectShort')}
</button>
</div>
</div>

View File

@ -398,6 +398,13 @@
"title": "Bookings",
"description": "Manage and track your shipments",
"new": "New Booking",
"limit": {
"title": "Reservation limit reached",
"message": "Your free plan is limited to {max} reservations per year. Upgrade your plan to keep booking.",
"upgrade": "Upgrade my plan",
"upgradeShort": "Upgrade",
"back": "Back to bookings"
},
"transferBanner": {
"title": "Transfer declared",
"message": "Your transfer has been recorded. An administrator will verify receipt and activate your booking. You will be notified once validated."
@ -619,7 +626,9 @@
},
"validUntil": "✓ Valid until {date}",
"surcharges": "Applicable surcharges",
"selectShort": "Select"
"selectShort": "Select",
"selectAndPay": "Select and pay",
"editMode": "Editing an existing booking: pick a carrier to update your booking, then proceed to payment."
}
},
"notificationsPage": {

View File

@ -398,6 +398,13 @@
"title": "Réservations",
"description": "Gérez et suivez vos envois",
"new": "Nouvelle Réservation",
"limit": {
"title": "Limite de réservations atteinte",
"message": "Votre abonnement gratuit est limité à {max} réservations par an. Passez à un abonnement supérieur pour continuer à réserver.",
"upgrade": "Améliorer mon abonnement",
"upgradeShort": "Améliorer",
"back": "Retour aux réservations"
},
"transferBanner": {
"title": "Virement déclaré",
"message": "Votre virement a été enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation."
@ -619,7 +626,9 @@
},
"validUntil": "✓ Valide jusqu'au {date}",
"surcharges": "Surcharges applicables",
"selectShort": "Sélectionner"
"selectShort": "Sélectionner",
"selectAndPay": "Choisir et payer",
"editMode": "Modification d'une réservation existante : choisissez une compagnie pour mettre à jour votre réservation, puis passez au paiement."
}
},
"notificationsPage": {

View File

@ -0,0 +1,40 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { getReservationQuota } from '@/lib/api/bookings';
import { useAuth } from '@/lib/context/auth-context';
/**
* Reservation (paid shipment) quota for the current plan.
*
* The free (Bronze) plan is limited to a number of PAID reservations per year
* (`maxShipmentsPerYear`, e.g. 5); higher plans are unlimited. The value comes
* from the backend, which uses the organization's real plan (not the ADMIN
* PLATINIUM override) and counts only paid shipments so the UI can prompt an
* upgrade once the limit is reached instead of failing at the end of the flow.
*/
export function useReservationQuota() {
const { isAuthenticated } = useAuth();
const { data, isLoading } = useQuery({
queryKey: ['reservation-quota'],
queryFn: getReservationQuota,
enabled: isAuthenticated,
staleTime: 30_000,
});
const max = data?.max ?? -1;
const used = data?.used ?? 0;
const unlimited = data?.unlimited ?? true;
const limitReached = data?.limitReached ?? false;
return {
loading: isLoading,
max,
used,
remaining: unlimited ? Infinity : Math.max(0, max - used),
unlimited,
limitReached,
blocked: limitReached,
};
}

View File

@ -197,12 +197,22 @@ export async function getOrganizationDocuments(organizationId: string): Promise<
// ==================== BLOG ====================
export interface BlogFaqItem {
question: string;
answer: string;
}
export interface BlogPostSeoFields {
metaTitle?: string;
metaDescription?: string;
primaryKeyword?: string;
secondaryKeywords?: string[];
scheduledAt?: string;
// GEO (Generative Engine Optimisation)
aiSummary?: string | null;
faq?: BlogFaqItem[];
keyTakeaways?: string[];
aiEntities?: string[];
}
export interface CreateBlogPostRequest extends BlogPostSeoFields {
@ -233,11 +243,13 @@ export async function getAllBlogPosts(params?: {
status?: BlogPostStatus;
category?: BlogPostCategory;
search?: string;
trashed?: boolean;
}): Promise<{ posts: BlogPost[]; total: number }> {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.category) query.set('category', params.category);
if (params?.search) query.set('search', params.search);
if (params?.trashed) query.set('trashed', 'true');
const qs = query.toString();
return get(`/api/v1/admin/blog${qs ? `?${qs}` : ''}`);
}
@ -250,6 +262,22 @@ export async function updateBlogPost(id: string, data: UpdateBlogPostRequest): P
return patch<BlogPost>(`/api/v1/admin/blog/${id}`, data);
}
/** Move a post to the trash (soft delete). */
export async function deleteBlogPost(id: string): Promise<void> {
return del<void>(`/api/v1/admin/blog/${id}`);
}
/** Restore a post from the trash. */
export async function restoreBlogPost(id: string): Promise<BlogPost> {
return post<BlogPost>(`/api/v1/admin/blog/${id}/restore`, {});
}
/** Permanently delete a post from the trash. */
export async function permanentlyDeleteBlogPost(id: string): Promise<void> {
return del<void>(`/api/v1/admin/blog/${id}/permanent`);
}
/** Duplicate a post as a new draft. */
export async function duplicateBlogPost(id: string): Promise<BlogPost> {
return post<BlogPost>(`/api/v1/admin/blog/${id}/duplicate`, {});
}

View File

@ -3,6 +3,11 @@ import { get } from './client';
export type BlogPostStatus = 'draft' | 'scheduled' | 'published' | 'archived';
export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news';
export interface BlogFaqItem {
question: string;
answer: string;
}
export interface BlogPost {
id: string;
title: string;
@ -20,6 +25,11 @@ export interface BlogPost {
metaDescription?: string;
primaryKeyword?: string;
secondaryKeywords: string[];
// GEO (Generative Engine Optimisation)
aiSummary?: string | null;
faq?: BlogFaqItem[];
keyTakeaways?: string[];
aiEntities?: string[];
createdAt: string;
updatedAt: string;
}

View File

@ -66,6 +66,7 @@ export interface CsvBookingResponse {
updatedAt: string;
commissionRate?: number;
commissionAmountEur?: number;
options?: Record<string, boolean>;
}
export interface CommissionPaymentResponse {
@ -255,6 +256,21 @@ export async function getCsvBookingStats(): Promise<CsvBookingStatsResponse> {
return get<CsvBookingStatsResponse>('/api/v1/csv-bookings/stats');
}
/**
* Reservation (paid shipment) quota for the current organization.
* GET /api/v1/csv-bookings/reservation-quota
*/
export interface ReservationQuotaResponse {
max: number;
used: number;
unlimited: boolean;
limitReached: boolean;
}
export async function getReservationQuota(): Promise<ReservationQuotaResponse> {
return get<ReservationQuotaResponse>('/api/v1/csv-bookings/reservation-quota');
}
/**
* Cancel a pending CSV booking
* PATCH /api/v1/csv-bookings/:id/cancel
@ -272,6 +288,14 @@ export interface UpdateCsvBookingDetailsRequest {
weightKG?: number;
palletCount?: number;
notes?: string;
// Recomputed pricing (optional; sent when cargo details change)
priceUSD?: number;
priceEUR?: number;
primaryCurrency?: string;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
}
export async function updateCsvBookingDetails(
@ -281,6 +305,38 @@ export async function updateCsvBookingDetails(
return patch<CsvBookingResponse>(`/api/v1/csv-bookings/${id}/details`, data);
}
/**
* Re-apply a full rate selection to a booking awaiting payment
* PATCH /api/v1/csv-bookings/:id/rate
*/
export interface UpdateCsvBookingRateRequest {
carrierName: string;
carrierEmail: string;
origin: string;
destination: string;
containerType: string;
transitDays: number;
volumeCBM: number;
weightKG: number;
palletCount: number;
priceUSD: number;
priceEUR: number;
primaryCurrency: string;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
notes?: string;
options?: Record<string, boolean>;
}
export async function updateCsvBookingRate(
id: string,
data: UpdateCsvBookingRateRequest
): Promise<CsvBookingResponse> {
return patch<CsvBookingResponse>(`/api/v1/csv-bookings/${id}/rate`, data);
}
/**
* Accept a CSV booking (public endpoint, no auth required)
* POST /api/v1/csv-bookings/:token/accept