diff --git a/apps/frontend/app/[locale]/dashboard/bookings/page.tsx b/apps/frontend/app/[locale]/dashboard/bookings/page.tsx
index 453121e..40b86b8 100644
--- a/apps/frontend/app/[locale]/dashboard/bookings/page.tsx
+++ b/apps/frontend/app/[locale]/dashboard/bookings/page.tsx
@@ -151,7 +151,10 @@ export default function BookingsListPage() {
.toLocaleDateString(dateLocale)
.includes(term);
case 'quote':
+ // `bookingNumber` est le numero affiche en premiere colonne : sans
+ // lui, rechercher le numero qu'on a sous les yeux ne donnait rien.
return (
+ booking.bookingNumber?.toLowerCase().includes(term) ||
booking.id?.toLowerCase().includes(term) ||
booking.quoteNumber?.toLowerCase().includes(term)
);
diff --git a/apps/frontend/app/[locale]/dashboard/page.tsx b/apps/frontend/app/[locale]/dashboard/page.tsx
index 3874ab4..5f0a3e9 100644
--- a/apps/frontend/app/[locale]/dashboard/page.tsx
+++ b/apps/frontend/app/[locale]/dashboard/page.tsx
@@ -103,11 +103,21 @@ export default function DashboardPage() {
* N'affiche une variation que lorsqu'elle compare quelque chose : deux
* periodes vides donnent 0 %, un badge vert qui ne veut rien dire.
*/
+ const hasTrend = (value: number | undefined, change: number | undefined) =>
+ Boolean(kpis) && ((value ?? 0) !== 0 || (change ?? 0) !== 0);
+
const trendOf = (value: number | undefined, change: number | undefined, positiveIsGood = true) =>
- kpis && ((value ?? 0) !== 0 || (change ?? 0) !== 0)
+ hasTrend(value, change)
? { value: change ?? 0, label: t('kpi.vsLastMonth'), positiveIsGood }
: undefined;
+ /**
+ * « vs mois dernier » accompagne le pourcentage ; seul, il ne dit rien.
+ * Il disparait donc avec lui.
+ */
+ const hintOf = (value: number | undefined, change: number | undefined) =>
+ hasTrend(value, change) ? t('kpi.vsLastMonth') : undefined;
+
// Le backend renvoie des cles ISO `YYYY-MM` : le nom du mois se compose ici,
// dans la langue de l'utilisateur.
const monthFormat = new Intl.DateTimeFormat(intl, { month: 'short' });
@@ -203,7 +213,7 @@ export default function DashboardPage() {
label={t('kpi.bookingsThisMonth')}
value={numberFormat.format(kpis?.bookingsThisMonth ?? 0)}
trend={trendOf(kpis?.bookingsThisMonth, kpis?.bookingsThisMonthChange)}
- hint={t('kpi.vsLastMonth')}
+ hint={hintOf(kpis?.bookingsThisMonth, kpis?.bookingsThisMonthChange)}
icon={Package}
loading={kpisLoading}
/>
@@ -211,7 +221,7 @@ export default function DashboardPage() {
label={t('kpi.volume')}
value={`${cbmFormat.format(kpis?.volumeCBM ?? 0)} CBM`}
trend={trendOf(kpis?.volumeCBM, kpis?.volumeCBMChange)}
- hint={t('kpi.vsLastMonth')}
+ hint={hintOf(kpis?.volumeCBM, kpis?.volumeCBMChange)}
icon={ContainerIcon}
loading={kpisLoading}
/>
@@ -219,7 +229,7 @@ export default function DashboardPage() {
label={t('kpi.revenue')}
value={currencyFormat.format(kpis?.estimatedRevenue ?? 0)}
trend={trendOf(kpis?.estimatedRevenue, kpis?.estimatedRevenueChange)}
- hint={t('kpi.vsLastMonth')}
+ hint={hintOf(kpis?.estimatedRevenue, kpis?.estimatedRevenueChange)}
icon={Euro}
loading={kpisLoading}
/>
@@ -229,7 +239,7 @@ export default function DashboardPage() {
// Une hausse des dossiers en attente n'est pas une bonne nouvelle :
// la couleur de la variation doit s'inverser.
trend={trendOf(kpis?.pendingConfirmations, kpis?.pendingConfirmationsChange, false)}
- hint={t('kpi.vsLastMonth')}
+ hint={hintOf(kpis?.pendingConfirmations, kpis?.pendingConfirmationsChange)}
icon={Clock}
loading={kpisLoading}
/>
@@ -499,12 +509,42 @@ export default function DashboardPage() {
);
}
+/**
+ * Destination d'une alerte.
+ *
+ * `/dashboard/bookings/{id}` n'existe pas vraiment : la page interroge
+ * `/api/v1/bookings/:id`, donc la table `bookings`, absente du schema. Elle
+ * repondait systematiquement en erreur, et ce bandeau etait le seul endroit de
+ * l'application a y renvoyer.
+ *
+ * Les deux destinations ci-dessous existent et fonctionnent :
+ * - une alerte de paiement mene la ou l'utilisateur peut agir, l'ecran de
+ * reglement de la commission ;
+ * - les autres mene a la liste des reservations filtree sur cette seule ligne
+ * (le filtre `quote` compare aussi l'identifiant), faute de vue de detail :
+ * la liste ouvre ses reservations dans un panneau, pas dans une route.
+ */
+function alertHref(alert: {
+ type?: string;
+ bookingId?: string;
+ bookingNumber?: string;
+}): string {
+ if (!alert.bookingId) return '/dashboard/bookings';
+ if (alert.type === 'payment') return `/dashboard/booking/${alert.bookingId}/pay`;
+ // On filtre sur le numero plutot que sur l'identifiant : c'est ce que le
+ // bouton affiche, donc ce que l'utilisateur retrouve dans la barre de
+ // recherche en arrivant.
+ const term = alert.bookingNumber || alert.bookingId;
+ return `/dashboard/bookings?by=quote&q=${encodeURIComponent(term)}`;
+}
+
/** Bandeau des points a traiter, en tete de tableau de bord. */
function AlertsStrip({
alerts,
}: {
alerts: Array<{
id: string;
+ type: 'delay' | 'confirmation' | 'document' | 'payment' | 'info';
severity: 'critical' | 'high' | 'medium' | 'low';
titleKey: string;
messageKey: string;
@@ -555,9 +595,7 @@ function AlertsStrip({
{alert.bookingId && (
)}
diff --git a/apps/frontend/app/[locale]/page.tsx b/apps/frontend/app/[locale]/page.tsx
index 46f44d7..3cbcc90 100644
--- a/apps/frontend/app/[locale]/page.tsx
+++ b/apps/frontend/app/[locale]/page.tsx
@@ -405,13 +405,17 @@ export default function LandingPage() {
{/* Deux colonnes a partir de lg : l'argument a gauche, sa preuve a
- droite. En dessous, la preuve passe sous l'argument. */}
-
+ droite. En dessous, la preuve passe sous l'argument.
+ La colonne de droite a une largeur fixe : en `auto`, elle se
+ dimensionnait sur le contenu de la carte, qui change a chaque
+ etape de la demonstration — la carte se retrecissait donc a
+ chaque reprise de boucle. */}
+
{t('hero.subtitle')}
@@ -453,14 +457,14 @@ export default function LandingPage() {
initial={{ opacity: 0, y: 20 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.45, ease: EASE }}
- className="mx-auto flex w-full max-w-md flex-col items-stretch gap-3 sm:max-w-none sm:flex-row sm:items-center sm:justify-center sm:gap-4 lg:justify-start"
+ className="mx-auto flex w-full max-w-md flex-col items-stretch gap-3 sm:max-w-none sm:flex-row sm:items-center sm:justify-center sm:gap-4 xl:justify-start"
>
{isAuthenticated && user ? (
{t('hero.ctaAuthenticated')}
@@ -470,7 +474,7 @@ export default function LandingPage() {
href="/register"
target="_blank"
rel="noopener noreferrer"
- className={`group inline-flex h-14 w-full items-center justify-center gap-2 rounded-lg bg-brand-turquoise px-8 text-base font-semibold text-white shadow-lg shadow-brand-navy/20 transition-all hover:bg-brand-turquoise/90 hover:shadow-2xl sm:w-auto sm:text-lg ${FOCUS_RING} focus-visible:ring-offset-brand-navy`}
+ className={`group inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-lg bg-brand-turquoise px-8 text-base font-semibold text-white shadow-lg shadow-brand-navy/20 transition-all hover:bg-brand-turquoise/90 hover:shadow-2xl sm:w-auto sm:text-lg ${FOCUS_RING} focus-visible:ring-offset-brand-navy`}
>
{t('hero.ctaRegister')}
@@ -481,7 +485,7 @@ export default function LandingPage() {
onClick={() => setIsDemoOpen(true)}
// Action secondaire : contour sur le fond plutot qu'un second
// aplat, pour que le CTA principal reste seul a porter l'oeil.
- className={`inline-flex h-14 w-full items-center justify-center rounded-lg border border-white/40 bg-white/10 px-8 text-base font-semibold text-white backdrop-blur-sm transition-all hover:bg-white/20 sm:w-auto sm:text-lg ${FOCUS_RING} focus-visible:ring-offset-brand-navy`}
+ className={`inline-flex min-h-14 w-full items-center justify-center rounded-lg border border-white/40 bg-white/10 px-8 text-base font-semibold text-white backdrop-blur-sm transition-all hover:bg-white/20 sm:w-auto sm:text-lg ${FOCUS_RING} focus-visible:ring-offset-brand-navy`}
>
{t('hero.ctaDemo')}
@@ -493,7 +497,7 @@ export default function LandingPage() {
initial={{ opacity: 0 }}
animate={isHeroInView ? { opacity: 1 } : {}}
transition={{ duration: 0.8, delay: 0.65, ease: EASE }}
- className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-white/70 lg:justify-start"
+ className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-white/70 xl:justify-start"
>
{[
t('cta.features.noCard'),
@@ -516,7 +520,7 @@ export default function LandingPage() {
initial={{ opacity: 0, y: 28 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.9, delay: 0.5, ease: EASE }}
- className="flex justify-center lg:justify-end"
+ className="flex justify-center xl:justify-end"
>
@@ -1181,7 +1185,7 @@ export default function LandingPage() {
href="/dashboard"
target="_blank"
rel="noopener noreferrer"
- className={`group inline-flex h-14 w-full items-center justify-center gap-2 rounded-lg bg-brand-turquoise px-8 text-base font-semibold text-white transition-all hover:bg-brand-turquoise/90 hover:shadow-xl sm:w-auto sm:text-lg ${FOCUS_RING}`}
+ className={`group inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-lg bg-brand-turquoise px-8 text-base font-semibold text-white transition-all hover:bg-brand-turquoise/90 hover:shadow-xl sm:w-auto sm:text-lg ${FOCUS_RING}`}
>
{t('cta.ctaAuthenticated')}
@@ -1192,7 +1196,7 @@ export default function LandingPage() {
href="/register"
target="_blank"
rel="noopener noreferrer"
- className={`group inline-flex h-14 w-full items-center justify-center gap-2 rounded-lg bg-brand-turquoise px-8 text-base font-semibold text-white transition-all hover:bg-brand-turquoise/90 hover:shadow-xl sm:w-auto sm:text-lg ${FOCUS_RING}`}
+ className={`group inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-lg bg-brand-turquoise px-8 text-base font-semibold text-white transition-all hover:bg-brand-turquoise/90 hover:shadow-xl sm:w-auto sm:text-lg ${FOCUS_RING}`}
>
{t('cta.ctaRegister')}
@@ -1201,7 +1205,7 @@ export default function LandingPage() {
href="/login"
target="_blank"
rel="noopener noreferrer"
- className={`inline-flex h-14 w-full items-center justify-center rounded-lg border border-brand-navy/15 bg-white px-8 text-base font-semibold text-brand-navy transition-all hover:bg-white hover:shadow-md sm:w-auto sm:text-lg ${FOCUS_RING}`}
+ className={`inline-flex min-h-14 w-full items-center justify-center rounded-lg border border-brand-navy/15 bg-white px-8 text-base font-semibold text-brand-navy transition-all hover:bg-white hover:shadow-md sm:w-auto sm:text-lg ${FOCUS_RING}`}
>
{t('cta.ctaLogin')}
diff --git a/apps/frontend/src/components/landing/RateSearchDemo.tsx b/apps/frontend/src/components/landing/RateSearchDemo.tsx
index 2f74240..bd83b9c 100644
--- a/apps/frontend/src/components/landing/RateSearchDemo.tsx
+++ b/apps/frontend/src/components/landing/RateSearchDemo.tsx
@@ -29,10 +29,14 @@ const OFFERS = [
/**
* Duree de chaque etape de la sequence, en millisecondes.
* 0 remise a zero · 1 depart saisi · 2 arrivee saisie · 3 recherche ·
- * 4 resultats. L'etape 0 est breve mais necessaire : sans elle, la reprise de
- * boucle effacait l'arrivee au milieu de la sortie des resultats.
+ * 4 resultats.
+ *
+ * L'etape 0 dure assez longtemps pour que les resultats aient fini de sortir
+ * avant que le regard revienne sur les ports : a 450 ms, on voyait les anciens
+ * tarifs encore lisibles au-dessus de champs deja vides, ce qui se lisait comme
+ * un bug d'affichage.
*/
-const STEPS = [450, 900, 1000, 1100, 5000];
+const STEPS = [900, 900, 1000, 1100, 5000];
export function RateSearchDemo() {
const t = useTranslations('landing.hero.demo');
@@ -93,7 +97,9 @@ export function RateSearchDemo() {
className="absolute left-0 h-px w-full origin-left bg-brand-turquoise"
initial={{ scaleX: 0 }}
animate={{ scaleX: hasDestination ? 1 : 0 }}
- transition={{ duration: 0.6, ease: EASE }}
+ // Le trace prend son temps a l'aller (c'est la traversee), mais se
+ // retracte au rythme du reste a l'effacement.
+ transition={{ duration: hasDestination ? 0.6 : 0.4, ease: EASE }}
/>
- {/* Pied : le chiffre qui porte l'argument. */}
-
+ {/* Pied : le chiffre qui porte l'argument.
+ Le libelle suit reellement l'etape — annoncer « interrogation des
+ compagnies » avant meme que l'arrivee soit saisie faisait mentir la
+ carte. Le temps ecoule n'apparait qu'avec les resultats : c'est une
+ mesure, elle n'a pas de sens tant qu'il n'y a rien a mesurer. */}
+
);
@@ -247,7 +268,10 @@ function PortField({
className="truncate text-[11px] text-neutral-500"
initial={{ opacity: 0 }}
animate={{ opacity: filled ? 1 : 0 }}
- transition={{ duration: 0.4, ease: EASE, delay: 0.1 }}
+ // Le decalage ne vaut qu'a la saisie, ou la ville arrive apres son code.
+ // A l'effacement il desynchronisait les deux lignes : le code repassait
+ // en pointilles alors que la ville restait pleinement lisible.
+ transition={{ duration: 0.4, ease: EASE, delay: filled ? 0.1 : 0 }}
>
{city}
diff --git a/apps/frontend/src/lib/api/dashboard.ts b/apps/frontend/src/lib/api/dashboard.ts
index 67c96d0..240deec 100644
--- a/apps/frontend/src/lib/api/dashboard.ts
+++ b/apps/frontend/src/lib/api/dashboard.ts
@@ -48,6 +48,8 @@ export interface TradeLane {
*/
export interface DashboardAlert {
id: string;
+ /** Determine vers quel ecran l'alerte renvoie. */
+ type: 'delay' | 'confirmation' | 'document' | 'payment' | 'info';
severity: 'critical' | 'high' | 'medium' | 'low';
/** Cles de traduction resolues par le client. */
titleKey: string;
diff --git a/apps/frontend/src/lib/context/subscription-context.tsx b/apps/frontend/src/lib/context/subscription-context.tsx
index 16686b7..ee1ab99 100644
--- a/apps/frontend/src/lib/context/subscription-context.tsx
+++ b/apps/frontend/src/lib/context/subscription-context.tsx
@@ -21,16 +21,31 @@ interface SubscriptionContextType {
const SubscriptionContext = createContext(undefined);
export function SubscriptionProvider({ children }: { children: React.ReactNode }) {
- const { user, isAuthenticated } = useAuth();
+ const { user, isAuthenticated, loading: authLoading } = useAuth();
const [subscription, setSubscription] = useState(null);
const [loading, setLoading] = useState(true);
const fetchSubscription = async () => {
+ // Tant que l'authentification n'a pas repondu, on reste en chargement.
+ // Sans ce garde, le provider passait `loading` a false des le premier rendu
+ // — ou `isAuthenticated` est encore false, le /auth/me etant en vol. Les
+ // ecrans proteges par une fonctionnalite lisaient alors « charge, aucune
+ // fonctionnalite » et redirigeaient : un rechargement direct de /dashboard
+ // renvoyait systematiquement vers /dashboard/bookings.
+ if (authLoading) {
+ setLoading(true);
+ return;
+ }
+
if (!isAuthenticated) {
setSubscription(null);
setLoading(false);
return;
}
+
+ // Remis a true a chaque rechargement : sinon un refetch laissait `loading`
+ // a false avec des donnees encore vides, rouvrant la meme fenetre.
+ setLoading(true);
try {
const data = await getSubscriptionOverview();
setSubscription(data);
@@ -43,7 +58,7 @@ export function SubscriptionProvider({ children }: { children: React.ReactNode }
useEffect(() => {
fetchSubscription();
- }, [isAuthenticated, user?.organizationId]);
+ }, [authLoading, isAuthenticated, user?.organizationId]);
const plan = subscription?.plan ?? null;
const planFeatures = subscription?.planDetails?.planFeatures ?? [];
diff --git a/apps/frontend/src/lib/utils.ts b/apps/frontend/src/lib/utils.ts
index 9ad0df4..ebb74ce 100644
--- a/apps/frontend/src/lib/utils.ts
+++ b/apps/frontend/src/lib/utils.ts
@@ -1,5 +1,48 @@
import { type ClassValue, clsx } from 'clsx';
-import { twMerge } from 'tailwind-merge';
+import { extendTailwindMerge } from 'tailwind-merge';
+
+/**
+ * Tailles de police maison, declarees dans `tailwind.config.ts`.
+ *
+ * tailwind-merge ne connait que l'echelle Tailwind par defaut (`text-sm`,
+ * `text-lg`...). Face a `text-body-sm`, il ne reconnait pas une taille et la
+ * range parmi les **couleurs** de texte — un groupe ou elle entre alors en
+ * conflit avec `text-white`, qui la precede et se fait donc supprimer.
+ *
+ * Concretement, `cn('bg-brand-navy text-white', 'text-body-sm')` rendait
+ * `bg-brand-navy text-body-sm` : tous les boutons du produit perdaient leur
+ * couleur de texte, quelle que soit la valeur ecrite dans le composant. Le
+ * meme mecanisme effacait `text-brand-navy` sur les boutons clairs.
+ *
+ * Declarer l'echelle ici replace ces utilitaires dans le groupe `font-size`,
+ * ou ils ne concurrencent plus aucune couleur.
+ */
+const FONT_SIZES = [
+ 'display-lg',
+ 'display-md',
+ 'display-sm',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'body-lg',
+ 'body',
+ 'body-sm',
+ 'body-xs',
+ 'label-lg',
+ 'label',
+ 'label-sm',
+];
+
+const twMerge = extendTailwindMerge({
+ extend: {
+ classGroups: {
+ 'font-size': [{ text: FONT_SIZES }],
+ },
+ },
+});
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));