fix pages
This commit is contained in:
parent
8b294d6ed1
commit
ed1d5d913b
@ -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)
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 ?? [];
|
||||
|
||||
@ -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));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user