fix pages
This commit is contained in:
parent
8b294d6ed1
commit
ed1d5d913b
@ -151,7 +151,10 @@ export default function BookingsListPage() {
|
|||||||
.toLocaleDateString(dateLocale)
|
.toLocaleDateString(dateLocale)
|
||||||
.includes(term);
|
.includes(term);
|
||||||
case 'quote':
|
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 (
|
return (
|
||||||
|
booking.bookingNumber?.toLowerCase().includes(term) ||
|
||||||
booking.id?.toLowerCase().includes(term) ||
|
booking.id?.toLowerCase().includes(term) ||
|
||||||
booking.quoteNumber?.toLowerCase().includes(term)
|
booking.quoteNumber?.toLowerCase().includes(term)
|
||||||
);
|
);
|
||||||
|
|||||||
@ -103,11 +103,21 @@ export default function DashboardPage() {
|
|||||||
* N'affiche une variation que lorsqu'elle compare quelque chose : deux
|
* N'affiche une variation que lorsqu'elle compare quelque chose : deux
|
||||||
* periodes vides donnent 0 %, un badge vert qui ne veut rien dire.
|
* 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) =>
|
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 }
|
? { value: change ?? 0, label: t('kpi.vsLastMonth'), positiveIsGood }
|
||||||
: undefined;
|
: 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,
|
// Le backend renvoie des cles ISO `YYYY-MM` : le nom du mois se compose ici,
|
||||||
// dans la langue de l'utilisateur.
|
// dans la langue de l'utilisateur.
|
||||||
const monthFormat = new Intl.DateTimeFormat(intl, { month: 'short' });
|
const monthFormat = new Intl.DateTimeFormat(intl, { month: 'short' });
|
||||||
@ -203,7 +213,7 @@ export default function DashboardPage() {
|
|||||||
label={t('kpi.bookingsThisMonth')}
|
label={t('kpi.bookingsThisMonth')}
|
||||||
value={numberFormat.format(kpis?.bookingsThisMonth ?? 0)}
|
value={numberFormat.format(kpis?.bookingsThisMonth ?? 0)}
|
||||||
trend={trendOf(kpis?.bookingsThisMonth, kpis?.bookingsThisMonthChange)}
|
trend={trendOf(kpis?.bookingsThisMonth, kpis?.bookingsThisMonthChange)}
|
||||||
hint={t('kpi.vsLastMonth')}
|
hint={hintOf(kpis?.bookingsThisMonth, kpis?.bookingsThisMonthChange)}
|
||||||
icon={Package}
|
icon={Package}
|
||||||
loading={kpisLoading}
|
loading={kpisLoading}
|
||||||
/>
|
/>
|
||||||
@ -211,7 +221,7 @@ export default function DashboardPage() {
|
|||||||
label={t('kpi.volume')}
|
label={t('kpi.volume')}
|
||||||
value={`${cbmFormat.format(kpis?.volumeCBM ?? 0)} CBM`}
|
value={`${cbmFormat.format(kpis?.volumeCBM ?? 0)} CBM`}
|
||||||
trend={trendOf(kpis?.volumeCBM, kpis?.volumeCBMChange)}
|
trend={trendOf(kpis?.volumeCBM, kpis?.volumeCBMChange)}
|
||||||
hint={t('kpi.vsLastMonth')}
|
hint={hintOf(kpis?.volumeCBM, kpis?.volumeCBMChange)}
|
||||||
icon={ContainerIcon}
|
icon={ContainerIcon}
|
||||||
loading={kpisLoading}
|
loading={kpisLoading}
|
||||||
/>
|
/>
|
||||||
@ -219,7 +229,7 @@ export default function DashboardPage() {
|
|||||||
label={t('kpi.revenue')}
|
label={t('kpi.revenue')}
|
||||||
value={currencyFormat.format(kpis?.estimatedRevenue ?? 0)}
|
value={currencyFormat.format(kpis?.estimatedRevenue ?? 0)}
|
||||||
trend={trendOf(kpis?.estimatedRevenue, kpis?.estimatedRevenueChange)}
|
trend={trendOf(kpis?.estimatedRevenue, kpis?.estimatedRevenueChange)}
|
||||||
hint={t('kpi.vsLastMonth')}
|
hint={hintOf(kpis?.estimatedRevenue, kpis?.estimatedRevenueChange)}
|
||||||
icon={Euro}
|
icon={Euro}
|
||||||
loading={kpisLoading}
|
loading={kpisLoading}
|
||||||
/>
|
/>
|
||||||
@ -229,7 +239,7 @@ export default function DashboardPage() {
|
|||||||
// Une hausse des dossiers en attente n'est pas une bonne nouvelle :
|
// Une hausse des dossiers en attente n'est pas une bonne nouvelle :
|
||||||
// la couleur de la variation doit s'inverser.
|
// la couleur de la variation doit s'inverser.
|
||||||
trend={trendOf(kpis?.pendingConfirmations, kpis?.pendingConfirmationsChange, false)}
|
trend={trendOf(kpis?.pendingConfirmations, kpis?.pendingConfirmationsChange, false)}
|
||||||
hint={t('kpi.vsLastMonth')}
|
hint={hintOf(kpis?.pendingConfirmations, kpis?.pendingConfirmationsChange)}
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
loading={kpisLoading}
|
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. */
|
/** Bandeau des points a traiter, en tete de tableau de bord. */
|
||||||
function AlertsStrip({
|
function AlertsStrip({
|
||||||
alerts,
|
alerts,
|
||||||
}: {
|
}: {
|
||||||
alerts: Array<{
|
alerts: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
type: 'delay' | 'confirmation' | 'document' | 'payment' | 'info';
|
||||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
messageKey: string;
|
messageKey: string;
|
||||||
@ -555,9 +595,7 @@ function AlertsStrip({
|
|||||||
</div>
|
</div>
|
||||||
{alert.bookingId && (
|
{alert.bookingId && (
|
||||||
<Button asChild variant="outline" size="sm" className="shrink-0">
|
<Button asChild variant="outline" size="sm" className="shrink-0">
|
||||||
<Link href={`/dashboard/bookings/${alert.bookingId}`}>
|
<Link href={alertHref(alert)}>{alert.bookingNumber ?? <ArrowRight />}</Link>
|
||||||
{alert.bookingNumber ?? <ArrowRight />}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -405,13 +405,17 @@ export default function LandingPage() {
|
|||||||
|
|
||||||
<div className={`relative z-10 pt-28 pb-20 sm:pt-32 ${CONTAINER}`}>
|
<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
|
{/* 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.
|
||||||
<div className="grid items-center gap-12 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-16">
|
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
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||||
transition={{ duration: 0.8, ease: EASE }}
|
transition={{ duration: 0.8, ease: EASE }}
|
||||||
className="text-center lg:text-left"
|
className="text-center xl:text-left"
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
@ -444,7 +448,7 @@ export default function LandingPage() {
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||||
transition={{ duration: 0.8, delay: 0.35, ease: EASE }}
|
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')}
|
{t('hero.subtitle')}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
@ -453,14 +457,14 @@ export default function LandingPage() {
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||||
transition={{ duration: 0.8, delay: 0.45, ease: EASE }}
|
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 ? (
|
{isAuthenticated && user ? (
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>
|
<span>{t('hero.ctaAuthenticated')}</span>
|
||||||
<LayoutDashboard className="h-5 w-5 transition-transform group-hover:translate-x-1" />
|
<LayoutDashboard className="h-5 w-5 transition-transform group-hover:translate-x-1" />
|
||||||
@ -470,7 +474,7 @@ export default function LandingPage() {
|
|||||||
href="/register"
|
href="/register"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>
|
<span>{t('hero.ctaRegister')}</span>
|
||||||
<ArrowRight className="h-5 w-5 transition-transform group-hover:translate-x-1" />
|
<ArrowRight className="h-5 w-5 transition-transform group-hover:translate-x-1" />
|
||||||
@ -481,7 +485,7 @@ export default function LandingPage() {
|
|||||||
onClick={() => setIsDemoOpen(true)}
|
onClick={() => setIsDemoOpen(true)}
|
||||||
// Action secondaire : contour sur le fond plutot qu'un second
|
// Action secondaire : contour sur le fond plutot qu'un second
|
||||||
// aplat, pour que le CTA principal reste seul a porter l'oeil.
|
// 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')}
|
{t('hero.ctaDemo')}
|
||||||
</button>
|
</button>
|
||||||
@ -493,7 +497,7 @@ export default function LandingPage() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={isHeroInView ? { opacity: 1 } : {}}
|
animate={isHeroInView ? { opacity: 1 } : {}}
|
||||||
transition={{ duration: 0.8, delay: 0.65, ease: EASE }}
|
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'),
|
t('cta.features.noCard'),
|
||||||
@ -516,7 +520,7 @@ export default function LandingPage() {
|
|||||||
initial={{ opacity: 0, y: 28 }}
|
initial={{ opacity: 0, y: 28 }}
|
||||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||||
transition={{ duration: 0.9, delay: 0.5, ease: EASE }}
|
transition={{ duration: 0.9, delay: 0.5, ease: EASE }}
|
||||||
className="flex justify-center lg:justify-end"
|
className="flex justify-center xl:justify-end"
|
||||||
>
|
>
|
||||||
<RateSearchDemo />
|
<RateSearchDemo />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -1181,7 +1185,7 @@ export default function LandingPage() {
|
|||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>
|
<span>{t('cta.ctaAuthenticated')}</span>
|
||||||
<LayoutDashboard className="h-5 w-5 transition-transform group-hover:translate-x-1" />
|
<LayoutDashboard className="h-5 w-5 transition-transform group-hover:translate-x-1" />
|
||||||
@ -1192,7 +1196,7 @@ export default function LandingPage() {
|
|||||||
href="/register"
|
href="/register"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>
|
<span>{t('cta.ctaRegister')}</span>
|
||||||
<ArrowRight className="h-5 w-5 transition-transform group-hover:translate-x-1" />
|
<ArrowRight className="h-5 w-5 transition-transform group-hover:translate-x-1" />
|
||||||
@ -1201,7 +1205,7 @@ export default function LandingPage() {
|
|||||||
href="/login"
|
href="/login"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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')}
|
{t('cta.ctaLogin')}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -29,10 +29,14 @@ const OFFERS = [
|
|||||||
/**
|
/**
|
||||||
* Duree de chaque etape de la sequence, en millisecondes.
|
* Duree de chaque etape de la sequence, en millisecondes.
|
||||||
* 0 remise a zero · 1 depart saisi · 2 arrivee saisie · 3 recherche ·
|
* 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
|
* 4 resultats.
|
||||||
* boucle effacait l'arrivee au milieu de la sortie des 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() {
|
export function RateSearchDemo() {
|
||||||
const t = useTranslations('landing.hero.demo');
|
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"
|
className="absolute left-0 h-px w-full origin-left bg-brand-turquoise"
|
||||||
initial={{ scaleX: 0 }}
|
initial={{ scaleX: 0 }}
|
||||||
animate={{ scaleX: hasDestination ? 1 : 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
|
<motion.div
|
||||||
className="absolute left-1/2 -translate-x-1/2 rounded-full bg-white px-1"
|
className="absolute left-1/2 -translate-x-1/2 rounded-full bg-white px-1"
|
||||||
@ -201,15 +207,30 @@ export function RateSearchDemo() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pied : le chiffre qui porte l'argument. */}
|
{/* Pied : le chiffre qui porte l'argument.
|
||||||
<div className="flex items-center justify-between border-t border-border bg-white px-5 py-3">
|
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">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-neutral-400">
|
||||||
{hasResults ? t('resultCount', { count: OFFERS.length }) : t('searching')}
|
{hasResults
|
||||||
</p>
|
? t('resultCount', { count: OFFERS.length })
|
||||||
<p className="flex items-center gap-1.5 font-heading text-sm font-bold tabular-nums text-brand-navy">
|
: isSearching
|
||||||
{t('elapsed')}
|
? t('searching')
|
||||||
<ArrowRight className="size-3.5 text-brand-turquoise" aria-hidden="true" />
|
: t('label')}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -247,7 +268,10 @@ function PortField({
|
|||||||
className="truncate text-[11px] text-neutral-500"
|
className="truncate text-[11px] text-neutral-500"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: filled ? 1 : 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}
|
{city}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|||||||
@ -48,6 +48,8 @@ export interface TradeLane {
|
|||||||
*/
|
*/
|
||||||
export interface DashboardAlert {
|
export interface DashboardAlert {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Determine vers quel ecran l'alerte renvoie. */
|
||||||
|
type: 'delay' | 'confirmation' | 'document' | 'payment' | 'info';
|
||||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
/** Cles de traduction resolues par le client. */
|
/** Cles de traduction resolues par le client. */
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
|
|||||||
@ -21,16 +21,31 @@ interface SubscriptionContextType {
|
|||||||
const SubscriptionContext = createContext<SubscriptionContextType | undefined>(undefined);
|
const SubscriptionContext = createContext<SubscriptionContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function SubscriptionProvider({ children }: { children: React.ReactNode }) {
|
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 [subscription, setSubscription] = useState<SubscriptionOverviewResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const fetchSubscription = async () => {
|
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) {
|
if (!isAuthenticated) {
|
||||||
setSubscription(null);
|
setSubscription(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
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 {
|
try {
|
||||||
const data = await getSubscriptionOverview();
|
const data = await getSubscriptionOverview();
|
||||||
setSubscription(data);
|
setSubscription(data);
|
||||||
@ -43,7 +58,7 @@ export function SubscriptionProvider({ children }: { children: React.ReactNode }
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSubscription();
|
fetchSubscription();
|
||||||
}, [isAuthenticated, user?.organizationId]);
|
}, [authLoading, isAuthenticated, user?.organizationId]);
|
||||||
|
|
||||||
const plan = subscription?.plan ?? null;
|
const plan = subscription?.plan ?? null;
|
||||||
const planFeatures = subscription?.planDetails?.planFeatures ?? [];
|
const planFeatures = subscription?.planDetails?.planFeatures ?? [];
|
||||||
|
|||||||
@ -1,5 +1,48 @@
|
|||||||
import { type ClassValue, clsx } from 'clsx';
|
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[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user