fix pages

This commit is contained in:
David 2026-08-25 16:33:08 +02:00
parent 8b294d6ed1
commit ed1d5d913b
7 changed files with 165 additions and 36 deletions

View File

@ -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)
);

View File

@ -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({
</div>
{alert.bookingId && (
<Button asChild variant="outline" size="sm" className="shrink-0">
<Link href={`/dashboard/bookings/${alert.bookingId}`}>
{alert.bookingNumber ?? <ArrowRight />}
</Link>
<Link href={alertHref(alert)}>{alert.bookingNumber ?? <ArrowRight />}</Link>
</Button>
)}
</li>

View File

@ -405,13 +405,17 @@ export default function LandingPage() {
<div className={`relative z-10 pt-28 pb-20 sm:pt-32 ${CONTAINER}`}>
{/* Deux colonnes a partir de lg : l'argument a gauche, sa preuve a
droite. En dessous, la preuve passe sous l'argument. */}
<div className="grid items-center gap-12 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-16">
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. */}
<div className="grid items-center gap-12 xl:grid-cols-[minmax(0,1fr)_28rem] xl:gap-16">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, ease: EASE }}
className="text-center lg:text-left"
className="text-center xl:text-left"
>
<motion.div
initial={{ opacity: 0, y: 10 }}
@ -444,7 +448,7 @@ export default function LandingPage() {
initial={{ opacity: 0, y: 20 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.35, ease: EASE }}
className="mx-auto mb-8 max-w-xl text-pretty text-base leading-relaxed text-white/80 sm:mb-10 sm:text-xl lg:mx-0"
className="mx-auto mb-8 max-w-xl text-pretty text-base leading-relaxed text-white/80 sm:mb-10 sm:text-xl xl:mx-0"
>
{t('hero.subtitle')}
</motion.p>
@ -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 ? (
<Link
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 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`}
>
<span>{t('hero.ctaAuthenticated')}</span>
<LayoutDashboard className="h-5 w-5 transition-transform group-hover:translate-x-1" />
@ -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`}
>
<span>{t('hero.ctaRegister')}</span>
<ArrowRight className="h-5 w-5 transition-transform group-hover:translate-x-1" />
@ -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')}
</button>
@ -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"
>
<RateSearchDemo />
</motion.div>
@ -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}`}
>
<span>{t('cta.ctaAuthenticated')}</span>
<LayoutDashboard className="h-5 w-5 transition-transform group-hover:translate-x-1" />
@ -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}`}
>
<span>{t('cta.ctaRegister')}</span>
<ArrowRight className="h-5 w-5 transition-transform group-hover:translate-x-1" />
@ -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')}
</Link>

View File

@ -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 }}
/>
<motion.div
className="absolute left-1/2 -translate-x-1/2 rounded-full bg-white px-1"
@ -201,15 +207,30 @@ export function RateSearchDemo() {
</AnimatePresence>
</div>
{/* Pied : le chiffre qui porte l'argument. */}
<div className="flex items-center justify-between border-t border-border bg-white px-5 py-3">
{/* 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. */}
<div className="flex min-h-[45px] items-center justify-between border-t border-border bg-white px-5 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-neutral-400">
{hasResults ? t('resultCount', { count: OFFERS.length }) : t('searching')}
</p>
<p className="flex items-center gap-1.5 font-heading text-sm font-bold tabular-nums text-brand-navy">
{t('elapsed')}
<ArrowRight className="size-3.5 text-brand-turquoise" aria-hidden="true" />
{hasResults
? t('resultCount', { count: OFFERS.length })
: isSearching
? t('searching')
: t('label')}
</p>
{hasResults && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: EASE }}
className="flex items-center gap-1.5 font-heading text-sm font-bold tabular-nums text-brand-navy"
>
{t('elapsed')}
<ArrowRight className="size-3.5 text-brand-turquoise" aria-hidden="true" />
</motion.p>
)}
</div>
</div>
);
@ -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}
</motion.p>

View File

@ -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;

View File

@ -21,16 +21,31 @@ interface SubscriptionContextType {
const SubscriptionContext = createContext<SubscriptionContextType | undefined>(undefined);
export function SubscriptionProvider({ children }: { children: React.ReactNode }) {
const { user, isAuthenticated } = useAuth();
const { user, isAuthenticated, loading: authLoading } = useAuth();
const [subscription, setSubscription] = useState<SubscriptionOverviewResponse | null>(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 ?? [];

View File

@ -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));