From ed1d5d913b226eca107ca422cf7fae05a1e5b819 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Aug 2026 16:33:08 +0200 Subject: [PATCH] fix pages --- .../app/[locale]/dashboard/bookings/page.tsx | 3 ++ apps/frontend/app/[locale]/dashboard/page.tsx | 54 ++++++++++++++++--- apps/frontend/app/[locale]/page.tsx | 30 ++++++----- .../src/components/landing/RateSearchDemo.tsx | 48 ++++++++++++----- apps/frontend/src/lib/api/dashboard.ts | 2 + .../src/lib/context/subscription-context.tsx | 19 ++++++- apps/frontend/src/lib/utils.ts | 45 +++++++++++++++- 7 files changed, 165 insertions(+), 36 deletions(-) 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. */} +

- {hasResults ? t('resultCount', { count: OFFERS.length }) : t('searching')} -

-

- {t('elapsed')} -

+ {hasResults && ( + + {t('elapsed')} + + )}
); @@ -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));